From 4aadb05cd5718c7d16bf432c96e23ae4e7db4783 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 21 Jan 2020 01:11:17 +0800 Subject: ... --- Timeline.Tests/Controllers/TokenControllerTest.cs | 6 +- Timeline.Tests/Controllers/UserControllerTest.cs | 4 +- Timeline.Tests/DatabaseTest.cs | 4 +- Timeline.Tests/Helpers/TestDatabase.cs | 6 +- Timeline.Tests/IntegratedTests/UserTest.cs | 6 +- Timeline.Tests/Services/UserAvatarServiceTest.cs | 6 +- Timeline.Tests/Services/UserDetailServiceTest.cs | 2 +- Timeline/Controllers/TokenController.cs | 6 +- Timeline/Controllers/UserController.cs | 6 +- Timeline/Entities/DatabaseContext.cs | 10 +- Timeline/Entities/TimelineEntity.cs | 2 +- Timeline/Entities/TimelineMemberEntity.cs | 2 +- Timeline/Entities/TimelinePostEntity.cs | 2 +- Timeline/Entities/User.cs | 42 ------ Timeline/Entities/UserAvatar.cs | 28 ---- Timeline/Entities/UserAvatarEntity.cs | 28 ++++ Timeline/Entities/UserDetail.cs | 21 --- Timeline/Entities/UserDetailEntity.cs | 17 +++ Timeline/Entities/UserEntity.cs | 42 ++++++ Timeline/Models/Http/Token.cs | 4 +- Timeline/Models/Http/User.cs | 6 + Timeline/Models/UserConvert.cs | 67 ---------- Timeline/Models/UserInfo.cs | 23 +--- Timeline/Models/UserRoleConvert.cs | 44 ++++++ Timeline/Resources/Services/Exception.Designer.cs | 96 +++++++------- Timeline/Resources/Services/Exception.resx | 46 +++---- Timeline/Services/DatabaseExtensions.cs | 2 +- Timeline/Services/JwtService.cs | 132 ------------------ .../Services/JwtUserTokenBadFormatException.cs | 48 +++++++ Timeline/Services/JwtVerifyException.cs | 59 --------- Timeline/Services/UserAvatarService.cs | 2 +- Timeline/Services/UserDetailService.cs | 2 +- Timeline/Services/UserService.cs | 68 +++------- Timeline/Services/UserTokenException.cs | 71 ++++++++++ Timeline/Services/UserTokenManager.cs | 93 +++++++++++++ Timeline/Services/UserTokenService.cs | 147 +++++++++++++++++++++ Timeline/Services/UsernameBadFormatException.cs | 2 +- Timeline/Startup.cs | 2 +- 38 files changed, 632 insertions(+), 522 deletions(-) delete mode 100644 Timeline/Entities/User.cs delete mode 100644 Timeline/Entities/UserAvatar.cs create mode 100644 Timeline/Entities/UserAvatarEntity.cs delete mode 100644 Timeline/Entities/UserDetail.cs create mode 100644 Timeline/Entities/UserDetailEntity.cs create mode 100644 Timeline/Entities/UserEntity.cs delete mode 100644 Timeline/Models/UserConvert.cs create mode 100644 Timeline/Models/UserRoleConvert.cs delete mode 100644 Timeline/Services/JwtService.cs create mode 100644 Timeline/Services/JwtUserTokenBadFormatException.cs delete mode 100644 Timeline/Services/JwtVerifyException.cs create mode 100644 Timeline/Services/UserTokenException.cs create mode 100644 Timeline/Services/UserTokenManager.cs create mode 100644 Timeline/Services/UserTokenService.cs diff --git a/Timeline.Tests/Controllers/TokenControllerTest.cs b/Timeline.Tests/Controllers/TokenControllerTest.cs index 2b3547ea..740d8377 100644 --- a/Timeline.Tests/Controllers/TokenControllerTest.cs +++ b/Timeline.Tests/Controllers/TokenControllerTest.cs @@ -97,9 +97,9 @@ namespace Timeline.Tests.Controllers public static IEnumerable Verify_BadRequest_Data() { - yield return new object[] { new JwtVerifyException(JwtVerifyException.ErrorCodes.Expired), ErrorCodes.TokenController.Verify_TimeExpired }; - yield return new object[] { new JwtVerifyException(JwtVerifyException.ErrorCodes.IdClaimBadFormat), ErrorCodes.TokenController.Verify_BadFormat }; - yield return new object[] { new JwtVerifyException(JwtVerifyException.ErrorCodes.OldVersion), ErrorCodes.TokenController.Verify_OldVersion }; + yield return new object[] { new JwtUserTokenBadFormatException(JwtUserTokenBadFormatException.ErrorCodes.Expired), ErrorCodes.TokenController.Verify_TimeExpired }; + yield return new object[] { new JwtUserTokenBadFormatException(JwtUserTokenBadFormatException.ErrorCodes.IdClaimBadFormat), ErrorCodes.TokenController.Verify_BadFormat }; + yield return new object[] { new JwtUserTokenBadFormatException(JwtUserTokenBadFormatException.ErrorCodes.OldVersion), ErrorCodes.TokenController.Verify_OldVersion }; yield return new object[] { new UserNotExistException(), ErrorCodes.TokenController.Verify_UserNotExist }; } diff --git a/Timeline.Tests/Controllers/UserControllerTest.cs b/Timeline.Tests/Controllers/UserControllerTest.cs index 043062c3..262dbe11 100644 --- a/Timeline.Tests/Controllers/UserControllerTest.cs +++ b/Timeline.Tests/Controllers/UserControllerTest.cs @@ -46,7 +46,7 @@ namespace Timeline.Tests.Controllers public async Task Get_Success() { const string username = "aaa"; - _mockUserService.Setup(s => s.GetUser(username)).ReturnsAsync(MockUser.User.Info); + _mockUserService.Setup(s => s.GetUserByUsername(username)).ReturnsAsync(MockUser.User.Info); var action = await _controller.Get(username); action.Result.Should().BeAssignableTo() .Which.Value.Should().BeEquivalentTo(MockUser.User.Info); @@ -56,7 +56,7 @@ namespace Timeline.Tests.Controllers public async Task Get_NotFound() { const string username = "aaa"; - _mockUserService.Setup(s => s.GetUser(username)).Returns(Task.FromResult(null)); + _mockUserService.Setup(s => s.GetUserByUsername(username)).Returns(Task.FromResult(null)); var action = await _controller.Get(username); action.Result.Should().BeAssignableTo() .Which.Value.Should().BeAssignableTo() diff --git a/Timeline.Tests/DatabaseTest.cs b/Timeline.Tests/DatabaseTest.cs index a7b97c16..a15823a9 100644 --- a/Timeline.Tests/DatabaseTest.cs +++ b/Timeline.Tests/DatabaseTest.cs @@ -28,7 +28,7 @@ namespace Timeline.Tests { var user = _context.Users.First(); _context.UserAvatars.Count().Should().Be(0); - _context.UserAvatars.Add(new UserAvatar + _context.UserAvatars.Add(new UserAvatarEntity { Data = null, Type = null, @@ -48,7 +48,7 @@ namespace Timeline.Tests { var user = _context.Users.First(); _context.UserDetails.Count().Should().Be(0); - _context.UserDetails.Add(new UserDetail + _context.UserDetails.Add(new UserDetailEntity { Nickname = null, UserId = user.Id diff --git a/Timeline.Tests/Helpers/TestDatabase.cs b/Timeline.Tests/Helpers/TestDatabase.cs index 9560f353..3163279a 100644 --- a/Timeline.Tests/Helpers/TestDatabase.cs +++ b/Timeline.Tests/Helpers/TestDatabase.cs @@ -14,9 +14,9 @@ namespace Timeline.Tests.Helpers // currently password service is thread safe, so we share a static one. private static PasswordService PasswordService { get; } = new PasswordService(); - private static User CreateEntityFromMock(MockUser user) + private static UserEntity CreateEntityFromMock(MockUser user) { - return new User + return new UserEntity { Name = user.Username, EncryptedPassword = PasswordService.HashPassword(user.Password), @@ -25,7 +25,7 @@ namespace Timeline.Tests.Helpers }; } - private static IEnumerable CreateDefaultMockEntities() + private static IEnumerable CreateDefaultMockEntities() { // emmmmmmm. Never reuse the user instances because EF Core uses them, which will cause strange things. yield return CreateEntityFromMock(MockUser.User); diff --git a/Timeline.Tests/IntegratedTests/UserTest.cs b/Timeline.Tests/IntegratedTests/UserTest.cs index fbef6da3..ea9f1177 100644 --- a/Timeline.Tests/IntegratedTests/UserTest.cs +++ b/Timeline.Tests/IntegratedTests/UserTest.cs @@ -24,7 +24,7 @@ namespace Timeline.Tests.IntegratedTests using var client = await CreateClientAsAdmin(); var res = await client.GetAsync("users"); res.Should().HaveStatusCode(200) - .And.HaveJsonBody() + .And.HaveJsonBody() .Which.Should().BeEquivalentTo(MockUser.UserInfoList); } @@ -34,7 +34,7 @@ namespace Timeline.Tests.IntegratedTests using var client = await CreateClientAsAdmin(); var res = await client.GetAsync("users/" + MockUser.User.Username); res.Should().HaveStatusCode(200) - .And.HaveJsonBody() + .And.HaveJsonBody() .Which.Should().BeEquivalentTo(MockUser.User.Info); } @@ -77,7 +77,7 @@ namespace Timeline.Tests.IntegratedTests { var res = await client.GetAsync("users/" + username); res.Should().HaveStatusCode(200) - .And.HaveJsonBody() + .And.HaveJsonBody() .Which.Administrator.Should().Be(administrator); } diff --git a/Timeline.Tests/Services/UserAvatarServiceTest.cs b/Timeline.Tests/Services/UserAvatarServiceTest.cs index 2729aa6f..d4371c48 100644 --- a/Timeline.Tests/Services/UserAvatarServiceTest.cs +++ b/Timeline.Tests/Services/UserAvatarServiceTest.cs @@ -78,7 +78,7 @@ namespace Timeline.Tests.Services public class UserAvatarServiceTest : IDisposable { - private UserAvatar CreateMockAvatarEntity(string key) => new UserAvatar + private UserAvatarEntity CreateMockAvatarEntity(string key) => new UserAvatarEntity { Type = $"image/test{key}", Data = Encoding.ASCII.GetBytes($"mock{key}"), @@ -102,7 +102,7 @@ namespace Timeline.Tests.Services Data = Encoding.ASCII.GetBytes($"mock{key}") }; - private static Avatar ToAvatar(UserAvatar entity) + private static Avatar ToAvatar(UserAvatarEntity entity) { return new Avatar { @@ -111,7 +111,7 @@ namespace Timeline.Tests.Services }; } - private static AvatarInfo ToAvatarInfo(UserAvatar entity) + private static AvatarInfo ToAvatarInfo(UserAvatarEntity entity) { return new AvatarInfo { diff --git a/Timeline.Tests/Services/UserDetailServiceTest.cs b/Timeline.Tests/Services/UserDetailServiceTest.cs index 9a869c89..e6eabadf 100644 --- a/Timeline.Tests/Services/UserDetailServiceTest.cs +++ b/Timeline.Tests/Services/UserDetailServiceTest.cs @@ -52,7 +52,7 @@ namespace Timeline.Tests.Services { var context = _testDatabase.Context; var userId = (await context.Users.Where(u => u.Name == MockUser.User.Username).Select(u => new { u.Id }).SingleAsync()).Id; - context.UserDetails.Add(new UserDetail + context.UserDetails.Add(new UserDetailEntity { Nickname = nickname, UserId = userId diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs index 67001e87..851c7606 100644 --- a/Timeline/Controllers/TokenController.cs +++ b/Timeline/Controllers/TokenController.cs @@ -94,16 +94,16 @@ namespace Timeline.Controllers User = result }); } - catch (JwtVerifyException e) + catch (JwtUserTokenBadFormatException e) { - if (e.ErrorCode == JwtVerifyException.ErrorCodes.Expired) + if (e.ErrorCode == JwtUserTokenBadFormatException.ErrorCodes.Expired) { var innerException = e.InnerException as SecurityTokenExpiredException; LogFailure(LogVerifyExpire, e, ("Expires", innerException?.Expires), ("Current Time", _clock.GetCurrentTime())); return BadRequest(ErrorResponse.TokenController.Verify_TimeExpired()); } - else if (e.ErrorCode == JwtVerifyException.ErrorCodes.OldVersion) + else if (e.ErrorCode == JwtUserTokenBadFormatException.ErrorCodes.OldVersion) { var innerException = e.InnerException as JwtBadVersionException; LogFailure(LogVerifyOldVersion, e, diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 956865dc..65ee3a0f 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -27,15 +27,15 @@ namespace Timeline.Controllers } [HttpGet("users"), AdminAuthorize] - public async Task> List() + public async Task> List() { return Ok(await _userService.ListUsers()); } [HttpGet("users/{username}"), AdminAuthorize] - public async Task> Get([FromRoute][Username] string username) + public async Task> Get([FromRoute][Username] string username) { - var user = await _userService.GetUser(username); + var user = await _userService.GetUserByUsername(username); if (user == null) { _logger.LogInformation(Log.Format(LogGetUserNotExist, ("Username", username))); diff --git a/Timeline/Entities/DatabaseContext.cs b/Timeline/Entities/DatabaseContext.cs index ffb6158a..738440b2 100644 --- a/Timeline/Entities/DatabaseContext.cs +++ b/Timeline/Entities/DatabaseContext.cs @@ -13,13 +13,13 @@ namespace Timeline.Entities [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity().Property(e => e.Version).HasDefaultValue(0); - modelBuilder.Entity().HasIndex(e => e.Name).IsUnique(); + modelBuilder.Entity().Property(e => e.Version).HasDefaultValue(0); + modelBuilder.Entity().HasIndex(e => e.Name).IsUnique(); } - public DbSet Users { get; set; } = default!; - public DbSet UserAvatars { get; set; } = default!; - public DbSet UserDetails { get; set; } = default!; + public DbSet Users { get; set; } = default!; + public DbSet UserAvatars { get; set; } = default!; + public DbSet UserDetails { get; set; } = default!; public DbSet Timelines { get; set; } = default!; public DbSet TimelinePosts { get; set; } = default!; public DbSet TimelineMembers { get; set; } = default!; diff --git a/Timeline/Entities/TimelineEntity.cs b/Timeline/Entities/TimelineEntity.cs index 9cacfcae..2bfd6107 100644 --- a/Timeline/Entities/TimelineEntity.cs +++ b/Timeline/Entities/TimelineEntity.cs @@ -26,7 +26,7 @@ namespace Timeline.Entities public long OwnerId { get; set; } [ForeignKey(nameof(OwnerId))] - public User Owner { get; set; } = default!; + public UserEntity Owner { get; set; } = default!; [Column("visibility")] public TimelineVisibility Visibility { get; set; } diff --git a/Timeline/Entities/TimelineMemberEntity.cs b/Timeline/Entities/TimelineMemberEntity.cs index dbe861bd..e76f2099 100644 --- a/Timeline/Entities/TimelineMemberEntity.cs +++ b/Timeline/Entities/TimelineMemberEntity.cs @@ -13,7 +13,7 @@ namespace Timeline.Entities public long UserId { get; set; } [ForeignKey(nameof(UserId))] - public User User { get; set; } = default!; + public UserEntity User { get; set; } = default!; [Column("timeline")] public long TimelineId { get; set; } diff --git a/Timeline/Entities/TimelinePostEntity.cs b/Timeline/Entities/TimelinePostEntity.cs index efef3ab5..a615c61f 100644 --- a/Timeline/Entities/TimelinePostEntity.cs +++ b/Timeline/Entities/TimelinePostEntity.cs @@ -20,7 +20,7 @@ namespace Timeline.Entities public long AuthorId { get; set; } [ForeignKey(nameof(AuthorId))] - public User Author { get; set; } = default!; + public UserEntity Author { get; set; } = default!; [Column("content")] public string? Content { get; set; } diff --git a/Timeline/Entities/User.cs b/Timeline/Entities/User.cs deleted file mode 100644 index e725a69a..00000000 --- a/Timeline/Entities/User.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Timeline.Entities -{ - public static class UserRoles - { - public const string Admin = "admin"; - public const string User = "user"; - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is an entity class.")] - [Table("users")] - public class User - { - [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long Id { get; set; } - - [Column("name"), MaxLength(26), Required] - public string Name { get; set; } = default!; - - [Column("password"), Required] - public string EncryptedPassword { get; set; } = default!; - - [Column("roles"), Required] - public string RoleString { get; set; } = default!; - - [Column("version"), Required] - public long Version { get; set; } - - public UserAvatar? Avatar { get; set; } - - public UserDetail? Detail { get; set; } - - public List Timelines { get; set; } = default!; - - public List TimelinePosts { get; set; } = default!; - - public List TimelinesJoined { get; set; } = default!; - } -} diff --git a/Timeline/Entities/UserAvatar.cs b/Timeline/Entities/UserAvatar.cs deleted file mode 100644 index 114246f3..00000000 --- a/Timeline/Entities/UserAvatar.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Timeline.Entities -{ - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "This is data base entity.")] - [Table("user_avatars")] - public class UserAvatar - { - [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long Id { get; set; } - - [Column("data")] - public byte[]? Data { get; set; } - - [Column("type")] - public string? Type { get; set; } - - [Column("etag"), MaxLength(30)] - public string? ETag { get; set; } - - [Column("last_modified"), Required] - public DateTime LastModified { get; set; } - - public long UserId { get; set; } - } -} diff --git a/Timeline/Entities/UserAvatarEntity.cs b/Timeline/Entities/UserAvatarEntity.cs new file mode 100644 index 00000000..eed819bc --- /dev/null +++ b/Timeline/Entities/UserAvatarEntity.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "This is data base entity.")] + [Table("user_avatars")] + public class UserAvatarEntity + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("data")] + public byte[]? Data { get; set; } + + [Column("type")] + public string? Type { get; set; } + + [Column("etag"), MaxLength(30)] + public string? ETag { get; set; } + + [Column("last_modified"), Required] + public DateTime LastModified { get; set; } + + public long UserId { get; set; } + } +} diff --git a/Timeline/Entities/UserDetail.cs b/Timeline/Entities/UserDetail.cs deleted file mode 100644 index 45f87e2b..00000000 --- a/Timeline/Entities/UserDetail.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using System.Threading.Tasks; - -namespace Timeline.Entities -{ - [Table("user_details")] - public class UserDetail - { - [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long Id { get; set; } - - [Column("nickname"), MaxLength(26)] - public string? Nickname { get; set; } - - public long UserId { get; set; } - } -} diff --git a/Timeline/Entities/UserDetailEntity.cs b/Timeline/Entities/UserDetailEntity.cs new file mode 100644 index 00000000..7a525294 --- /dev/null +++ b/Timeline/Entities/UserDetailEntity.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + [Table("user_details")] + public class UserDetailEntity + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("nickname"), MaxLength(26)] + public string? Nickname { get; set; } + + public long UserId { get; set; } + } +} diff --git a/Timeline/Entities/UserEntity.cs b/Timeline/Entities/UserEntity.cs new file mode 100644 index 00000000..83ef5621 --- /dev/null +++ b/Timeline/Entities/UserEntity.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + public static class UserRoles + { + public const string Admin = "admin"; + public const string User = "user"; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is an entity class.")] + [Table("users")] + public class UserEntity + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("name"), MaxLength(26), Required] + public string Name { get; set; } = default!; + + [Column("password"), Required] + public string EncryptedPassword { get; set; } = default!; + + [Column("roles"), Required] + public string RoleString { get; set; } = default!; + + [Column("version"), Required] + public long Version { get; set; } + + public UserAvatarEntity? Avatar { get; set; } + + public UserDetailEntity? Detail { get; set; } + + public List Timelines { get; set; } = default!; + + public List TimelinePosts { get; set; } = default!; + + public List TimelinesJoined { get; set; } = default!; + } +} diff --git a/Timeline/Models/Http/Token.cs b/Timeline/Models/Http/Token.cs index ea8b59ed..0649f1d1 100644 --- a/Timeline/Models/Http/Token.cs +++ b/Timeline/Models/Http/Token.cs @@ -16,7 +16,7 @@ namespace Timeline.Models.Http public class CreateTokenResponse { public string Token { get; set; } = default!; - public UserInfo User { get; set; } = default!; + public User User { get; set; } = default!; } public class VerifyTokenRequest @@ -27,6 +27,6 @@ namespace Timeline.Models.Http public class VerifyTokenResponse { - public UserInfo User { get; set; } = default!; + public User User { get; set; } = default!; } } diff --git a/Timeline/Models/Http/User.cs b/Timeline/Models/Http/User.cs index 516c1329..69bfacf2 100644 --- a/Timeline/Models/Http/User.cs +++ b/Timeline/Models/Http/User.cs @@ -3,6 +3,12 @@ using Timeline.Models.Validation; namespace Timeline.Models.Http { + public class User + { + public string Username { get; set; } = default!; + public bool Administrator { get; set; } + } + public class UserPutRequest { [Required] diff --git a/Timeline/Models/UserConvert.cs b/Timeline/Models/UserConvert.cs deleted file mode 100644 index 5b132421..00000000 --- a/Timeline/Models/UserConvert.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Timeline.Entities; -using Timeline.Services; - -namespace Timeline.Models -{ - public static class UserConvert - { - public static UserInfo CreateUserInfo(User user) - { - if (user == null) - throw new ArgumentNullException(nameof(user)); - return new UserInfo(user.Name, UserRoleConvert.ToBool(user.RoleString)); - } - - internal static UserCache CreateUserCache(User user) - { - if (user == null) - throw new ArgumentNullException(nameof(user)); - return new UserCache - { - Username = user.Name, - Administrator = UserRoleConvert.ToBool(user.RoleString), - Version = user.Version - }; - } - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "No need.")] - public static class UserRoleConvert - { - public const string UserRole = UserRoles.User; - public const string AdminRole = UserRoles.Admin; - - public static string[] ToArray(bool administrator) - { - return administrator ? new string[] { UserRole, AdminRole } : new string[] { UserRole }; - } - - public static string[] ToArray(string s) - { - return s.Split(',').ToArray(); - } - - public static bool ToBool(IReadOnlyCollection roles) - { - return roles.Contains(AdminRole); - } - - public static string ToString(IReadOnlyCollection roles) - { - return string.Join(',', roles); - } - - public static string ToString(bool administrator) - { - return administrator ? UserRole + "," + AdminRole : UserRole; - } - - public static bool ToBool(string s) - { - return s.Contains("admin", StringComparison.InvariantCulture); - } - } -} diff --git a/Timeline/Models/UserInfo.cs b/Timeline/Models/UserInfo.cs index b60bdfa2..eff47329 100644 --- a/Timeline/Models/UserInfo.cs +++ b/Timeline/Models/UserInfo.cs @@ -1,23 +1,10 @@ -namespace Timeline.Models +namespace Timeline.Models { - public sealed class UserInfo + public class UserInfo { - public UserInfo() - { - } - - public UserInfo(string username, bool administrator) - { - Username = username; - Administrator = administrator; - } - + public long Id { get; set; } + public long Version { get; set; } public string Username { get; set; } = default!; - public bool Administrator { get; set; } = default!; - - public override string ToString() - { - return $"Username: {Username} ; Administrator: {Administrator}"; - } + public bool Administrator { get; set; } } } diff --git a/Timeline/Models/UserRoleConvert.cs b/Timeline/Models/UserRoleConvert.cs new file mode 100644 index 00000000..ade9a799 --- /dev/null +++ b/Timeline/Models/UserRoleConvert.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Timeline.Entities; + +namespace Timeline.Models +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "No need.")] + public static class UserRoleConvert + { + public const string UserRole = UserRoles.User; + public const string AdminRole = UserRoles.Admin; + + public static string[] ToArray(bool administrator) + { + return administrator ? new string[] { UserRole, AdminRole } : new string[] { UserRole }; + } + + public static string[] ToArray(string s) + { + return s.Split(',').ToArray(); + } + + public static bool ToBool(IReadOnlyCollection roles) + { + return roles.Contains(AdminRole); + } + + public static string ToString(IReadOnlyCollection roles) + { + return string.Join(',', roles); + } + + public static string ToString(bool administrator) + { + return administrator ? UserRole + "," + AdminRole : UserRole; + } + + public static bool ToBool(string s) + { + return s.Contains("admin", StringComparison.InvariantCulture); + } + } +} diff --git a/Timeline/Resources/Services/Exception.Designer.cs b/Timeline/Resources/Services/Exception.Designer.cs index 1b46f9e9..0a3325d4 100644 --- a/Timeline/Resources/Services/Exception.Designer.cs +++ b/Timeline/Resources/Services/Exception.Designer.cs @@ -178,92 +178,65 @@ namespace Timeline.Resources.Services { } /// - /// Looks up a localized string similar to The version of the jwt token is old.. + /// Looks up a localized string similar to The token didn't pass verification because {0}.. /// - internal static string JwtBadVersionException { + internal static string JwtUserTokenBadFormatException { get { - return ResourceManager.GetString("JwtBadVersionException", resourceCulture); + return ResourceManager.GetString("JwtUserTokenBadFormatException", resourceCulture); } } /// - /// Looks up a localized string similar to The token didn't pass verification because {0}, see inner exception for information.. + /// Looks up a localized string similar to id claim is not a number. /// - internal static string JwtVerifyException { + internal static string JwtUserTokenBadFormatExceptionIdBadFormat { get { - return ResourceManager.GetString("JwtVerifyException", resourceCulture); + return ResourceManager.GetString("JwtUserTokenBadFormatExceptionIdBadFormat", resourceCulture); } } /// - /// Looks up a localized string similar to token is expired.. + /// Looks up a localized string similar to id claim does not exist. /// - internal static string JwtVerifyExceptionExpired { + internal static string JwtUserTokenBadFormatExceptionIdMissing { get { - return ResourceManager.GetString("JwtVerifyExceptionExpired", resourceCulture); + return ResourceManager.GetString("JwtUserTokenBadFormatExceptionIdMissing", resourceCulture); } } /// - /// Looks up a localized string similar to id claim is not a number.. + /// Looks up a localized string similar to other error, see inner exception for information. /// - internal static string JwtVerifyExceptionIdClaimBadFormat { + internal static string JwtUserTokenBadFormatExceptionOthers { get { - return ResourceManager.GetString("JwtVerifyExceptionIdClaimBadFormat", resourceCulture); + return ResourceManager.GetString("JwtUserTokenBadFormatExceptionOthers", resourceCulture); } } /// - /// Looks up a localized string similar to id claim does not exist.. - /// - internal static string JwtVerifyExceptionNoIdClaim { - get { - return ResourceManager.GetString("JwtVerifyExceptionNoIdClaim", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to version claim does not exist.. - /// - internal static string JwtVerifyExceptionNoVersionClaim { - get { - return ResourceManager.GetString("JwtVerifyExceptionNoVersionClaim", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to version of token is old.. - /// - internal static string JwtVerifyExceptionOldVersion { - get { - return ResourceManager.GetString("JwtVerifyExceptionOldVersion", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to uncommon error.. + /// Looks up a localized string similar to unknown error. /// - internal static string JwtVerifyExceptionOthers { + internal static string JwtUserTokenBadFormatExceptionUnknown { get { - return ResourceManager.GetString("JwtVerifyExceptionOthers", resourceCulture); + return ResourceManager.GetString("JwtUserTokenBadFormatExceptionUnknown", resourceCulture); } } /// - /// Looks up a localized string similar to unknown error code.. + /// Looks up a localized string similar to version claim is not a number.. /// - internal static string JwtVerifyExceptionUnknown { + internal static string JwtUserTokenBadFormatExceptionVersionBadFormat { get { - return ResourceManager.GetString("JwtVerifyExceptionUnknown", resourceCulture); + return ResourceManager.GetString("JwtUserTokenBadFormatExceptionVersionBadFormat", resourceCulture); } } /// - /// Looks up a localized string similar to version claim is not a number.. + /// Looks up a localized string similar to version claim does not exist.. /// - internal static string JwtVerifyExceptionVersionClaimBadFormat { + internal static string JwtUserTokenBadFormatExceptionVersionMissing { get { - return ResourceManager.GetString("JwtVerifyExceptionVersionClaimBadFormat", resourceCulture); + return ResourceManager.GetString("JwtUserTokenBadFormatExceptionVersionMissing", resourceCulture); } } @@ -356,5 +329,32 @@ namespace Timeline.Resources.Services { return ResourceManager.GetString("UserNotExistException", resourceCulture); } } + + /// + /// Looks up a localized string similar to The token is of bad format, which means it may not be created by the server.. + /// + internal static string UserTokenBadFormatException { + get { + return ResourceManager.GetString("UserTokenBadFormatException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The token is of bad version.. + /// + internal static string UserTokenBadVersionException { + get { + return ResourceManager.GetString("UserTokenBadVersionException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The token is expired because its expiration time has passed.. + /// + internal static string UserTokenTimeExpireException { + get { + return ResourceManager.GetString("UserTokenTimeExpireException", resourceCulture); + } + } } } diff --git a/Timeline/Resources/Services/Exception.resx b/Timeline/Resources/Services/Exception.resx index 1d9c0037..bc96248d 100644 --- a/Timeline/Resources/Services/Exception.resx +++ b/Timeline/Resources/Services/Exception.resx @@ -156,36 +156,27 @@ Unknown format marker. - - The version of the jwt token is old. + + The token didn't pass verification because {0}. - - The token didn't pass verification because {0}, see inner exception for information. + + id claim is not a number - - token is expired. + + id claim does not exist - - id claim is not a number. + + other error, see inner exception for information - - id claim does not exist. - - - version claim does not exist. - - - version of token is old. - - - uncommon error. - - - unknown error code. + + unknown error - + version claim is not a number. + + version claim does not exist. + The timeline with that name already exists. @@ -216,4 +207,13 @@ The user does not exist. + + The token is of bad format, which means it may not be created by the server. + + + The token is of bad version. + + + The token is expired because its expiration time has passed. + \ No newline at end of file diff --git a/Timeline/Services/DatabaseExtensions.cs b/Timeline/Services/DatabaseExtensions.cs index 140c3146..c5c96d8c 100644 --- a/Timeline/Services/DatabaseExtensions.cs +++ b/Timeline/Services/DatabaseExtensions.cs @@ -19,7 +19,7 @@ namespace Timeline.Services /// Thrown if is null. /// Thrown if is of bad format. /// Thrown if user does not exist. - internal static async Task CheckAndGetUser(DbSet userDbSet, string? username) + internal static async Task CheckAndGetUser(DbSet userDbSet, string? username) { if (username == null) throw new ArgumentNullException(nameof(username)); diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs deleted file mode 100644 index bf92966a..00000000 --- a/Timeline/Services/JwtService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System; -using System.Globalization; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Timeline.Configs; - -namespace Timeline.Services -{ - public class TokenInfo - { - public long Id { get; set; } - public long Version { get; set; } - } - - public interface IJwtService - { - /// - /// Create a JWT token for a given token info. - /// - /// The info to generate token. - /// The expire time. If null then use current time with offset in config. - /// Return the generated token. - /// Thrown when is null. - string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null); - - /// - /// Verify a JWT token. - /// Return null is is null. - /// - /// The token string to verify. - /// Return the saved info in token. - /// Thrown when is null. - /// Thrown when the token is invalid. - TokenInfo VerifyJwtToken(string token); - - } - - public class JwtService : IJwtService - { - private const string VersionClaimType = "timeline_version"; - - private readonly IOptionsMonitor _jwtConfig; - private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); - private readonly IClock _clock; - - public JwtService(IOptionsMonitor jwtConfig, IClock clock) - { - _jwtConfig = jwtConfig; - _clock = clock; - } - - public string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null) - { - if (tokenInfo == null) - throw new ArgumentNullException(nameof(tokenInfo)); - - var config = _jwtConfig.CurrentValue; - - var identity = new ClaimsIdentity(); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, tokenInfo.Id.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); - identity.AddClaim(new Claim(VersionClaimType, tokenInfo.Version.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); - - var tokenDescriptor = new SecurityTokenDescriptor() - { - Subject = identity, - Issuer = config.Issuer, - Audience = config.Audience, - SigningCredentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)), SecurityAlgorithms.HmacSha384), - IssuedAt = _clock.GetCurrentTime(), - Expires = expires.GetValueOrDefault(_clock.GetCurrentTime().AddSeconds(config.DefaultExpireOffset)), - NotBefore = _clock.GetCurrentTime() // I must explicitly set this or it will use the current time by default and mock is not work in which case test will not pass. - }; - - var token = _tokenHandler.CreateToken(tokenDescriptor); - var tokenString = _tokenHandler.WriteToken(token); - - return tokenString; - } - - - public TokenInfo VerifyJwtToken(string token) - { - if (token == null) - throw new ArgumentNullException(nameof(token)); - - var config = _jwtConfig.CurrentValue; - try - { - var principal = _tokenHandler.ValidateToken(token, new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateIssuerSigningKey = true, - ValidateLifetime = true, - ValidIssuer = config.Issuer, - ValidAudience = config.Audience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)) - }, out _); - - var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (idClaim == null) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.NoIdClaim); - if (!long.TryParse(idClaim, out var id)) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.IdClaimBadFormat); - - var versionClaim = principal.FindFirstValue(VersionClaimType); - if (versionClaim == null) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.NoVersionClaim); - if (!long.TryParse(versionClaim, out var version)) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.VersionClaimBadFormat); - - return new TokenInfo - { - Id = id, - Version = version - }; - } - catch (SecurityTokenExpiredException e) - { - throw new JwtVerifyException(e, JwtVerifyException.ErrorCodes.Expired); - } - catch (Exception e) - { - throw new JwtVerifyException(e, JwtVerifyException.ErrorCodes.Others); - } - } - } -} diff --git a/Timeline/Services/JwtUserTokenBadFormatException.cs b/Timeline/Services/JwtUserTokenBadFormatException.cs new file mode 100644 index 00000000..c528c3e3 --- /dev/null +++ b/Timeline/Services/JwtUserTokenBadFormatException.cs @@ -0,0 +1,48 @@ +using System; +using System.Globalization; +using static Timeline.Resources.Services.Exception; + +namespace Timeline.Services +{ + [Serializable] + public class JwtUserTokenBadFormatException : UserTokenBadFormatException + { + public enum ErrorKind + { + NoIdClaim, + IdClaimBadFormat, + NoVersionClaim, + VersionClaimBadFormat, + Other + } + + public JwtUserTokenBadFormatException() : this("", ErrorKind.Other) { } + public JwtUserTokenBadFormatException(string message) : base(message) { } + public JwtUserTokenBadFormatException(string message, Exception inner) : base(message, inner) { } + + public JwtUserTokenBadFormatException(string token, ErrorKind type) : base(token, GetErrorMessage(type)) { ErrorType = type; } + public JwtUserTokenBadFormatException(string token, ErrorKind type, Exception inner) : base(token, GetErrorMessage(type), inner) { ErrorType = type; } + public JwtUserTokenBadFormatException(string token, ErrorKind type, string message, Exception inner) : base(token, message, inner) { ErrorType = type; } + protected JwtUserTokenBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public ErrorKind ErrorType { get; set; } + + private static string GetErrorMessage(ErrorKind type) + { + var reason = type switch + { + ErrorKind.NoIdClaim => JwtUserTokenBadFormatExceptionIdMissing, + ErrorKind.IdClaimBadFormat => JwtUserTokenBadFormatExceptionIdBadFormat, + ErrorKind.NoVersionClaim => JwtUserTokenBadFormatExceptionVersionMissing, + ErrorKind.VersionClaimBadFormat => JwtUserTokenBadFormatExceptionVersionBadFormat, + ErrorKind.Other => JwtUserTokenBadFormatExceptionOthers, + _ => JwtUserTokenBadFormatExceptionUnknown + }; + + return string.Format(CultureInfo.CurrentCulture, + Resources.Services.Exception.JwtUserTokenBadFormatException, reason); + } + } +} diff --git a/Timeline/Services/JwtVerifyException.cs b/Timeline/Services/JwtVerifyException.cs deleted file mode 100644 index a915b51a..00000000 --- a/Timeline/Services/JwtVerifyException.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.IdentityModel.Tokens; -using System; -using System.Globalization; -using static Timeline.Resources.Services.Exception; - -namespace Timeline.Services -{ - [Serializable] - public class JwtVerifyException : Exception - { - public static class ErrorCodes - { - // Codes in -1000 ~ -1999 usually means the user provides a token that is not created by this server. - - public const int Others = -1001; - public const int NoIdClaim = -1002; - public const int IdClaimBadFormat = -1003; - public const int NoVersionClaim = -1004; - public const int VersionClaimBadFormat = -1005; - - /// - /// Corresponds to . - /// - public const int Expired = -2001; - public const int OldVersion = -2002; - } - - public JwtVerifyException() : base(GetErrorMessage(0)) { } - public JwtVerifyException(string message) : base(message) { } - public JwtVerifyException(string message, Exception inner) : base(message, inner) { } - - public JwtVerifyException(int code) : base(GetErrorMessage(code)) { ErrorCode = code; } - public JwtVerifyException(string message, int code) : base(message) { ErrorCode = code; } - public JwtVerifyException(Exception inner, int code) : base(GetErrorMessage(code), inner) { ErrorCode = code; } - public JwtVerifyException(string message, Exception inner, int code) : base(message, inner) { ErrorCode = code; } - protected JwtVerifyException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public int ErrorCode { get; set; } - - private static string GetErrorMessage(int errorCode) - { - var reason = errorCode switch - { - ErrorCodes.Others => JwtVerifyExceptionOthers, - ErrorCodes.NoIdClaim => JwtVerifyExceptionNoIdClaim, - ErrorCodes.IdClaimBadFormat => JwtVerifyExceptionIdClaimBadFormat, - ErrorCodes.NoVersionClaim => JwtVerifyExceptionNoVersionClaim, - ErrorCodes.VersionClaimBadFormat => JwtVerifyExceptionVersionClaimBadFormat, - ErrorCodes.Expired => JwtVerifyExceptionExpired, - ErrorCodes.OldVersion => JwtVerifyExceptionOldVersion, - _ => JwtVerifyExceptionUnknown - }; - - return string.Format(CultureInfo.InvariantCulture, Resources.Services.Exception.JwtVerifyException, reason); - } - } -} diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 01201864..ac7dd857 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -275,7 +275,7 @@ namespace Timeline.Services var create = avatarEntity == null; if (create) { - avatarEntity = new UserAvatar(); + avatarEntity = new UserAvatarEntity(); } avatarEntity!.Type = avatar.Type; avatarEntity.Data = avatar.Data; diff --git a/Timeline/Services/UserDetailService.cs b/Timeline/Services/UserDetailService.cs index 0b24e4e2..4f4a7942 100644 --- a/Timeline/Services/UserDetailService.cs +++ b/Timeline/Services/UserDetailService.cs @@ -77,7 +77,7 @@ namespace Timeline.Services var create = userDetail == null; if (create) { - userDetail = new UserDetail + userDetail = new UserDetailEntity { UserId = userId }; diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 4012539f..db2350a2 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -11,47 +11,37 @@ using Timeline.Models.Validation; namespace Timeline.Services { - public class CreateTokenResult - { - public string Token { get; set; } = default!; - public UserInfo User { get; set; } = default!; - } - public interface IUserService { /// - /// Try to anthenticate with the given username and password. - /// If success, create a token and return the user info. + /// Try to verify the given username and password. /// - /// The username of the user to anthenticate. - /// The password of the user to anthenticate. - /// The expired time point. Null then use default. See for what is default. - /// An containing the created token and user info. + /// The username of the user to verify. + /// The password of the user to verify. + /// The user info. /// Thrown when or is null. /// Thrown when username is of bad format. /// Thrown when the user with given username does not exist. /// Thrown when password is wrong. - Task CreateToken(string username, string password, DateTime? expires = null); + Task VerifyCredential(string username, string password); /// - /// Verify the given token. - /// If success, return the user info. + /// Try to get a user by id. /// - /// The token to verify. - /// The user info specified by the token. - /// Thrown when is null. - /// Thrown when the token is of bad format. Thrown by . - /// Thrown when the user specified by the token does not exist. Usually it has been deleted after the token was issued. - Task VerifyToken(string token); + /// The id of the user. + /// The user info. + /// Thrown when the user with given id does not exist. + Task GetUserById(long id); /// /// Get the user info of given username. /// /// Username of the user. - /// The info of the user. Null if the user of given username does not exists. + /// The info of the user. /// Thrown when is null. /// Thrown when is of bad format. - Task GetUser(string username); + /// Thrown when the user with given username does not exist. + Task GetUserByUsername(string username); /// /// List all users. @@ -120,39 +110,24 @@ namespace Timeline.Services Task ChangeUsername(string oldUsername, string newUsername); } - internal class UserCache - { - public string Username { get; set; } = default!; - public bool Administrator { get; set; } - public long Version { get; set; } - - public UserInfo ToUserInfo() - { - return new UserInfo(Username, Administrator); - } - } - public class UserService : IUserService { private readonly ILogger _logger; - private readonly IMemoryCache _memoryCache; private readonly DatabaseContext _databaseContext; - private readonly IJwtService _jwtService; + private readonly IMemoryCache _memoryCache; + private readonly IPasswordService _passwordService; - private readonly UsernameValidator _usernameValidator; + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); - public UserService(ILogger logger, IMemoryCache memoryCache, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService) + public UserService(ILogger logger, IMemoryCache memoryCache, DatabaseContext databaseContext, IPasswordService passwordService) { _logger = logger; _memoryCache = memoryCache; _databaseContext = databaseContext; - _jwtService = jwtService; _passwordService = passwordService; - - _usernameValidator = new UsernameValidator(); } private static string GenerateCacheKeyByUserId(long id) => $"user:{id}"; @@ -176,12 +151,13 @@ namespace Timeline.Services } } - public async Task CreateToken(string username, string password, DateTime? expires) + public async Task CheckCredential(string username, string password) { if (username == null) throw new ArgumentNullException(nameof(username)); if (password == null) throw new ArgumentNullException(nameof(password)); + CheckUsernameFormat(username); // We need password info, so always check the database. @@ -231,12 +207,12 @@ namespace Timeline.Services } if (tokenInfo.Version != cache.Version) - throw new JwtVerifyException(new JwtBadVersionException(tokenInfo.Version, cache.Version), JwtVerifyException.ErrorCodes.OldVersion); + throw new JwtUserTokenBadFormatException(new JwtBadVersionException(tokenInfo.Version, cache.Version), JwtUserTokenBadFormatException.ErrorCodes.OldVersion); return cache.ToUserInfo(); } - public async Task GetUser(string username) + public async Task GetUserByUsername(string username) { if (username == null) throw new ArgumentNullException(nameof(username)); @@ -267,7 +243,7 @@ namespace Timeline.Services if (user == null) { - var newUser = new User + var newUser = new UserEntity { Name = username, EncryptedPassword = _passwordService.HashPassword(password), diff --git a/Timeline/Services/UserTokenException.cs b/Timeline/Services/UserTokenException.cs new file mode 100644 index 00000000..e63305b1 --- /dev/null +++ b/Timeline/Services/UserTokenException.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Timeline.Services +{ + + [Serializable] + public class UserTokenException : Exception + { + public UserTokenException() { } + public UserTokenException(string message) : base(message) { } + public UserTokenException(string message, Exception inner) : base(message, inner) { } + public UserTokenException(string token, string message) : base(message) { Token = token; } + public UserTokenException(string token, string message, Exception inner) : base(message, inner) { Token = token; } + protected UserTokenException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string Token { get; private set; } = ""; + } + + + [Serializable] + public class UserTokenTimeExpireException : UserTokenException + { + public UserTokenTimeExpireException() : base(Resources.Services.Exception.UserTokenTimeExpireException) { } + public UserTokenTimeExpireException(string message) : base(message) { } + public UserTokenTimeExpireException(string message, Exception inner) : base(message, inner) { } + public UserTokenTimeExpireException(string token, DateTime expireTime, DateTime verifyTime) : base(token, Resources.Services.Exception.UserTokenTimeExpireException) { ExpireTime = expireTime; VerifyTime = verifyTime; } + public UserTokenTimeExpireException(string token, DateTime expireTime, DateTime verifyTime, Exception inner) : base(token, Resources.Services.Exception.UserTokenTimeExpireException, inner) { ExpireTime = expireTime; VerifyTime = verifyTime; } + protected UserTokenTimeExpireException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public DateTime ExpireTime { get; private set; } = default; + + public DateTime VerifyTime { get; private set; } = default; + } + + [Serializable] + public class UserTokenBadVersionException : UserTokenException + { + public UserTokenBadVersionException() : base(Resources.Services.Exception.UserTokenBadVersionException) { } + public UserTokenBadVersionException(string message) : base(message) { } + public UserTokenBadVersionException(string message, Exception inner) : base(message, inner) { } + public UserTokenBadVersionException(string token, long tokenVersion, long requiredVersion) : base(token, Resources.Services.Exception.UserTokenBadVersionException) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + public UserTokenBadVersionException(string token, long tokenVersion, long requiredVersion, Exception inner) : base(token, Resources.Services.Exception.UserTokenBadVersionException, inner) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + protected UserTokenBadVersionException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public long TokenVersion { get; set; } + + public long RequiredVersion { get; set; } + } + + [Serializable] + public class UserTokenBadFormatException : UserTokenException + { + public UserTokenBadFormatException() : base(Resources.Services.Exception.UserTokenBadFormatException) { } + public UserTokenBadFormatException(string token) : base(token, Resources.Services.Exception.UserTokenBadFormatException) { } + public UserTokenBadFormatException(string token, string message) : base(token, message) { } + public UserTokenBadFormatException(string token, Exception inner) : base(token, Resources.Services.Exception.UserTokenBadFormatException, inner) { } + public UserTokenBadFormatException(string token, string message, Exception inner) : base(token, message, inner) { } + protected UserTokenBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/Timeline/Services/UserTokenManager.cs b/Timeline/Services/UserTokenManager.cs new file mode 100644 index 00000000..c3cb51c9 --- /dev/null +++ b/Timeline/Services/UserTokenManager.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Timeline.Models; + +namespace Timeline.Services +{ + public class UserTokenCreateResult + { + public string Token { get; set; } = default!; + public UserInfo User { get; set; } = default!; + } + + public interface IUserTokenManager + { + /// + /// Try to create a token for given username and password. + /// + /// The username. + /// The password. + /// The expire time of the token. + /// The created token and the user info. + /// Thrown when or is null. + /// Thrown when is of bad format. + /// Thrown when the user with does not exist. + /// Thrown when is wrong. + public Task CreateToken(string username, string password, DateTime? expireAt = null); + + /// + /// Verify a token and get the saved user info. This also check the database for existence of the user. + /// + /// The token. + /// The user stored in token. + /// Thrown when is null. + /// Thrown when the token is expired. + /// Thrown when the token is of bad version. + /// Thrown when the token is of bad format. + /// Thrown when the user specified by the token does not exist. Usually the user had been deleted after the token was issued. + public Task VerifyToken(string token); + } + + public class UserTokenManager : IUserTokenManager + { + private readonly ILogger _logger; + private readonly IUserService _userService; + private readonly IUserTokenService _userTokenService; + private readonly IClock _clock; + + public UserTokenManager(ILogger logger, IUserService userService, IUserTokenService userTokenService, IClock clock) + { + _logger = logger; + _userService = userService; + _userTokenService = userTokenService; + _clock = clock; + } + + public async Task CreateToken(string username, string password, DateTime? expireAt = null) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + + var user = await _userService.VerifyCredential(username, password); + var token = _userTokenService.GenerateToken(new UserTokenInfo { Id = user.Id, Version = user.Version, ExpireAt = expireAt }); + + return new UserTokenCreateResult { Token = token, User = user }; + } + + + public async Task VerifyToken(string token) + { + if (token == null) + throw new ArgumentNullException(nameof(token)); + + var tokenInfo = _userTokenService.VerifyToken(token); + + if (tokenInfo.ExpireAt.HasValue) + { + var currentTime = _clock.GetCurrentTime(); + if (tokenInfo.ExpireAt < currentTime) + throw new UserTokenTimeExpireException(token, tokenInfo.ExpireAt.Value, currentTime); + } + + var user = await _userService.GetUserById(tokenInfo.Id); + + if (tokenInfo.Version < user.Version) + throw new UserTokenBadVersionException(token, tokenInfo.Version, user.Version); + + return user; + } + } +} diff --git a/Timeline/Services/UserTokenService.cs b/Timeline/Services/UserTokenService.cs new file mode 100644 index 00000000..c246fdff --- /dev/null +++ b/Timeline/Services/UserTokenService.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Timeline.Configs; + +namespace Timeline.Services +{ + public class UserTokenInfo + { + public long Id { get; set; } + public long Version { get; set; } + public DateTime? ExpireAt { get; set; } + } + + public interface IUserTokenService + { + /// + /// Create a token for a given token info. + /// + /// The info to generate token. + /// Return the generated token. + /// Thrown when is null. + string GenerateToken(UserTokenInfo tokenInfo); + + /// + /// Verify a token and get the saved info. + /// + /// The token to verify. + /// The saved info in token. + /// Thrown when is null. + /// Thrown when the token is of bad format. + /// + /// If this method throw , it usually means the token is not created by this service. + /// + UserTokenInfo VerifyToken(string token); + } + + public class JwtUserTokenService : IUserTokenService + { + private const string VersionClaimType = "timeline_version"; + + private readonly IOptionsMonitor _jwtConfig; + private readonly IClock _clock; + + private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); + private SymmetricSecurityKey _tokenSecurityKey; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "")] + public JwtUserTokenService(IOptionsMonitor jwtConfig, IClock clock) + { + _jwtConfig = jwtConfig; + _clock = clock; + + _tokenSecurityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.CurrentValue.SigningKey)); + jwtConfig.OnChange(config => + { + _tokenSecurityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)); + }); + } + + public string GenerateToken(UserTokenInfo tokenInfo) + { + if (tokenInfo == null) + throw new ArgumentNullException(nameof(tokenInfo)); + + var config = _jwtConfig.CurrentValue; + + var identity = new ClaimsIdentity(); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, tokenInfo.Id.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); + identity.AddClaim(new Claim(VersionClaimType, tokenInfo.Version.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = identity, + Issuer = config.Issuer, + Audience = config.Audience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)), SecurityAlgorithms.HmacSha384), + IssuedAt = _clock.GetCurrentTime(), + Expires = tokenInfo.ExpireAt.GetValueOrDefault(_clock.GetCurrentTime().AddSeconds(config.DefaultExpireOffset)), + NotBefore = _clock.GetCurrentTime() // I must explicitly set this or it will use the current time by default and mock is not work in which case test will not pass. + }; + + var token = _tokenHandler.CreateToken(tokenDescriptor); + var tokenString = _tokenHandler.WriteToken(token); + + return tokenString; + } + + + public UserTokenInfo VerifyToken(string token) + { + if (token == null) + throw new ArgumentNullException(nameof(token)); + + var config = _jwtConfig.CurrentValue; + try + { + var principal = _tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = false, + ValidIssuer = config.Issuer, + ValidAudience = config.Audience, + IssuerSigningKey = _tokenSecurityKey + }, out var t); + + var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (idClaim == null) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.NoIdClaim); + if (!long.TryParse(idClaim, out var id)) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.IdClaimBadFormat); + + var versionClaim = principal.FindFirstValue(VersionClaimType); + if (versionClaim == null) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.NoVersionClaim); + if (!long.TryParse(versionClaim, out var version)) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.VersionClaimBadFormat); + + var decodedToken = (JwtSecurityToken)t; + var exp = decodedToken.Payload.Exp; + DateTime? expireAt = null; + if (exp.HasValue) + { + expireAt = EpochTime.DateTime(exp.Value); + } + + return new UserTokenInfo + { + Id = id, + Version = version, + ExpireAt = expireAt + }; + } + catch (Exception e) when (e is SecurityTokenException || e is ArgumentException) + { + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.Other, e); + } + } + } +} diff --git a/Timeline/Services/UsernameBadFormatException.cs b/Timeline/Services/UsernameBadFormatException.cs index d82bf962..991be7df 100644 --- a/Timeline/Services/UsernameBadFormatException.cs +++ b/Timeline/Services/UsernameBadFormatException.cs @@ -22,6 +22,6 @@ namespace Timeline.Services /// /// Username of bad format. /// - public string? Username { get; private set; } + public string Username { get; private set; } = ""; } } diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 5b6499a4..c1e73182 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -84,7 +84,7 @@ namespace Timeline }); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddTransient(); services.AddUserAvatarService(); -- cgit v1.2.3