From 2ef31740d62a415e7df59f22c450ae954ee97193 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 1 Aug 2019 21:22:55 +0800 Subject: Expired token now has a unique code. --- Timeline/Services/UserService.cs | 3 +++ 1 file changed, 3 insertions(+) (limited to 'Timeline/Services/UserService.cs') diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index c63ded1e..3164a645 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -208,6 +208,9 @@ namespace Timeline.Services public async Task VerifyToken(string token) { + if (token == null) + throw new ArgumentNullException(nameof(token)); + TokenInfo tokenInfo; try { -- cgit v1.2.3 From 9aeca6f6adf1a20d85e1fdbc8bdc8dfb35be28c1 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 1 Aug 2019 22:32:40 +0800 Subject: Add token expire time. --- Timeline/Controllers/TokenController.cs | 24 +++++++++++++--- Timeline/Entities/Http/Token.cs | 2 ++ Timeline/Migrations/20190412144150_AddAdminUser.cs | 2 +- Timeline/Services/Clock.cs | 32 ++++++++++++++++++++++ Timeline/Services/JwtService.cs | 8 ++++-- Timeline/Services/UserService.cs | 7 +++-- Timeline/Startup.cs | 1 + 7 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 Timeline/Services/Clock.cs (limited to 'Timeline/Services/UserService.cs') diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs index 66c97b59..f9dcfd76 100644 --- a/Timeline/Controllers/TokenController.cs +++ b/Timeline/Controllers/TokenController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using System; using System.Threading.Tasks; using Timeline.Entities.Http; using Timeline.Services; @@ -23,6 +24,7 @@ namespace Timeline.Controllers { public const int Create_UserNotExist = -1001; public const int Create_BadPassword = -1002; + public const int Create_BadExpireOffset = -1003; public const int Verify_BadToken = -2001; public const int Verify_UserNotExist = -2002; @@ -32,28 +34,42 @@ namespace Timeline.Controllers private readonly IUserService _userService; private readonly ILogger _logger; + private readonly IClock _clock; - public TokenController(IUserService userService, ILogger logger) + public TokenController(IUserService userService, ILogger logger, IClock clock) { _userService = userService; _logger = logger; + _clock = clock; } [HttpPost("create")] [AllowAnonymous] public async Task Create([FromBody] CreateTokenRequest request) { + TimeSpan? expireOffset = null; + if (request.ExpireOffset != null) + { + if (request.ExpireOffset.Value <= 0.0) + { + var code = ErrorCodes.Create_BadExpireOffset; + _logger.LogInformation(LoggingEventIds.LogInFailed, "Attemp to login failed because expire time offset is bad. Code: {} Username: {} Password: {} Bad Expire Offset: {}.", code, request.Username, request.Password, request.ExpireOffset); + return BadRequest(new CommonResponse(code, "Expire time is not bigger than 0.")); + } + expireOffset = TimeSpan.FromDays(request.ExpireOffset.Value); + } + try { - var result = await _userService.CreateToken(request.Username, request.Password); - _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login succeeded. Username: {} .", request.Username); + var result = await _userService.CreateToken(request.Username, request.Password, expireOffset == null ? null : (DateTime?)(_clock.GetCurrentTime() + expireOffset.Value)); + _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login succeeded. Username: {} Expire Time Offset: {} days.", request.Username, request.ExpireOffset); return Ok(new CreateTokenResponse { Token = result.Token, User = result.User }); } - catch(UserNotExistException e) + catch (UserNotExistException e) { var code = ErrorCodes.Create_UserNotExist; _logger.LogInformation(LoggingEventIds.LogInFailed, e, "Attemp to login failed because user does not exist. Code: {} Username: {} Password: {} .", code, request.Username, request.Password); diff --git a/Timeline/Entities/Http/Token.cs b/Timeline/Entities/Http/Token.cs index aeb9fbf2..8a02ed2e 100644 --- a/Timeline/Entities/Http/Token.cs +++ b/Timeline/Entities/Http/Token.cs @@ -4,6 +4,8 @@ { public string Username { get; set; } public string Password { get; set; } + // in day + public double? ExpireOffset { get; set; } } public class CreateTokenResponse diff --git a/Timeline/Migrations/20190412144150_AddAdminUser.cs b/Timeline/Migrations/20190412144150_AddAdminUser.cs index 9fac05ff..1b3f14b7 100644 --- a/Timeline/Migrations/20190412144150_AddAdminUser.cs +++ b/Timeline/Migrations/20190412144150_AddAdminUser.cs @@ -8,7 +8,7 @@ namespace Timeline.Migrations protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.InsertData("user", new string[] { "name", "password", "roles" }, - new string[] { "crupest", new PasswordService(null).HashPassword("yang0101"), "user,admin" }); + new string[] { "crupest", new PasswordService().HashPassword("yang0101"), "user,admin" }); } protected override void Down(MigrationBuilder migrationBuilder) diff --git a/Timeline/Services/Clock.cs b/Timeline/Services/Clock.cs new file mode 100644 index 00000000..98451ad9 --- /dev/null +++ b/Timeline/Services/Clock.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Timeline.Services +{ + /// + /// Convenient for unit test. + /// + public interface IClock + { + /// + /// Get current time. + /// + /// Current time. + DateTime GetCurrentTime(); + } + + public class Clock : IClock + { + public Clock() + { + + } + + public DateTime GetCurrentTime() + { + return DateTime.Now; + } + } +} diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs index f3416cce..52e892f6 100644 --- a/Timeline/Services/JwtService.cs +++ b/Timeline/Services/JwtService.cs @@ -94,10 +94,12 @@ namespace Timeline.Services private readonly IOptionsMonitor _jwtConfig; private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); + private readonly IClock _clock; - public JwtService(IOptionsMonitor jwtConfig) + public JwtService(IOptionsMonitor jwtConfig, IClock clock) { _jwtConfig = jwtConfig; + _clock = clock; } public string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null) @@ -118,8 +120,8 @@ namespace Timeline.Services Audience = config.Audience, SigningCredentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)), SecurityAlgorithms.HmacSha384), - IssuedAt = DateTime.Now, - Expires = expires.GetValueOrDefault(DateTime.Now.AddSeconds(config.DefaultExpireOffset)) + IssuedAt = _clock.GetCurrentTime(), + Expires = expires.GetValueOrDefault(_clock.GetCurrentTime().AddSeconds(config.DefaultExpireOffset)) }; var token = _tokenHandler.CreateToken(tokenDescriptor); diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 3164a645..328dbff0 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -58,11 +58,12 @@ namespace Timeline.Services /// /// 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. /// Thrown when or is null. /// Thrown when the user with given username does not exist. /// Thrown when password is wrong. - Task CreateToken(string username, string password); + Task CreateToken(string username, string password, DateTime? expires = null); /// /// Verify the given token. @@ -170,7 +171,7 @@ namespace Timeline.Services _memoryCache.Remove(GenerateCacheKeyByUserId(id)); } - public async Task CreateToken(string username, string password) + public async Task CreateToken(string username, string password, DateTime? expires) { if (username == null) throw new ArgumentNullException(nameof(username)); @@ -198,7 +199,7 @@ namespace Timeline.Services { Id = user.Id, Version = user.Version - }); + }, expires); return new CreateTokenResult { Token = token, diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index a6965190..8f702da5 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -51,6 +51,7 @@ namespace Timeline services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddTransient(); var databaseConfig = Configuration.GetSection(nameof(DatabaseConfig)).Get(); -- cgit v1.2.3 From 7f7fdd4d3d72980d575f7e27cea4a2d11c8482a8 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 4 Aug 2019 15:46:31 +0800 Subject: Continue to add unit tests for token. Fix a bug thanks to unit test. --- Timeline.Tests/Helpers/TestClock.cs | 25 ++++++++++ .../Helpers/WebApplicationFactoryExtensions.cs | 6 +++ Timeline.Tests/TokenUnitTest.cs | 58 +++++++++++++++++++--- Timeline/Controllers/TokenController.cs | 2 +- Timeline/Services/UserService.cs | 23 ++++----- 5 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 Timeline.Tests/Helpers/TestClock.cs (limited to 'Timeline/Services/UserService.cs') diff --git a/Timeline.Tests/Helpers/TestClock.cs b/Timeline.Tests/Helpers/TestClock.cs new file mode 100644 index 00000000..fc200be9 --- /dev/null +++ b/Timeline.Tests/Helpers/TestClock.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System; +using Timeline.Services; + +namespace Timeline.Tests.Helpers +{ + public class TestClock : IClock + { + DateTime? MockCurrentTime { get; set; } = null; + + public DateTime GetCurrentTime() + { + return MockCurrentTime.GetValueOrDefault(DateTime.Now); + } + } + + public static class TestClockWebApplicationFactoryExtensions + { + public static TestClock GetTestClock(this WebApplicationFactory factory) where T : class + { + return factory.Server.Host.Services.GetRequiredService() as TestClock; + } + } +} diff --git a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs b/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs index a7616b41..aa005ba3 100644 --- a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs +++ b/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Timeline.Models; +using Timeline.Services; using Xunit.Abstractions; namespace Timeline.Tests.Helpers @@ -46,6 +48,10 @@ namespace Timeline.Tests.Helpers db.Users.AddRange(TestMockUsers.MockUsers); db.SaveChanges(); } + }) + .ConfigureTestServices(services => + { + services.AddSingleton(); }); }); } diff --git a/Timeline.Tests/TokenUnitTest.cs b/Timeline.Tests/TokenUnitTest.cs index 27c2ed32..d7df8797 100644 --- a/Timeline.Tests/TokenUnitTest.cs +++ b/Timeline.Tests/TokenUnitTest.cs @@ -1,10 +1,14 @@ using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using System.Linq; using System.Net; using System.Net.Http; using Timeline.Controllers; +using Timeline.Entities; using Timeline.Entities.Http; +using Timeline.Models; +using Timeline.Services; using Timeline.Tests.Helpers; using Timeline.Tests.Helpers.Authentication; using Xunit; @@ -80,23 +84,63 @@ namespace Timeline.Tests { var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = "bad token hahaha" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Verify_BadToken, body.Code); } } [Fact] - public async void VerifyTokenTest_GoodToken() + public async void VerifyTokenTest_BadVersion_AND_UserNotExist() { using (var client = _factory.CreateDefaultClient()) { - var createTokenResult = await client.CreateUserTokenAsync("admin", "admin"); + using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped. + { + // create a user for test + var userService = scope.ServiceProvider.GetRequiredService(); + + const string username = "verifytokentest0"; + const string password = "12345678"; + + await userService.PutUser(username, password, false); + + // create a token + var token = (await client.CreateUserTokenAsync(username, password)).Token; + // increase version + await userService.PatchUser(username, null, null); + + // test against bad version + var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Verify_BadVersion, body.Code); + + // create another token + var token2 = (await client.CreateUserTokenAsync(username, password)).Token; + + // delete user + await userService.DeleteUser(username); + + // test against user not exist + var response2 = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); + Assert.Equal(HttpStatusCode.BadRequest, response2.StatusCode); + var body2 = await response2.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Verify_UserNotExist, body2.Code); + } + } + } + + [Fact] + public async void VerifyTokenTest_Success() + { + using (var client = _factory.CreateDefaultClient()) + { + var createTokenResult = await client.CreateUserTokenAsync("admin", "admin"); var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = createTokenResult.Token }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - Assert.NotNull(result.User); - Assert.Equal(createTokenResult.User.Username, result.User.Username); - Assert.Equal(createTokenResult.User.Administrator, result.User.Administrator); + var body = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer); } } } diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs index 66cf3dad..21f87ded 100644 --- a/Timeline/Controllers/TokenController.cs +++ b/Timeline/Controllers/TokenController.cs @@ -119,7 +119,7 @@ namespace Timeline.Controllers } catch (BadTokenVersionException e) { - var code = ErrorCodes.Verify_BadToken; + var code = ErrorCodes.Verify_BadVersion; _logger.LogInformation(LoggingEventIds.VerifyFailed, e, "Attemp to verify a bad token because version is old. Code: {} Token: {}.", code, request.Token); return BadRequest(new CommonResponse(code, "The token is expired. Try recreate a token.")); } diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 328dbff0..65ac98d3 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -19,7 +19,7 @@ namespace Timeline.Services [Serializable] public class UserNotExistException : Exception { - public UserNotExistException(): base("The user does not exist.") { } + public UserNotExistException() : base("The user does not exist.") { } public UserNotExistException(string message) : base(message) { } public UserNotExistException(string message, Exception inner) : base(message, inner) { } protected UserNotExistException( @@ -30,7 +30,7 @@ namespace Timeline.Services [Serializable] public class BadPasswordException : Exception { - public BadPasswordException(): base("Password is wrong.") { } + public BadPasswordException() : base("Password is wrong.") { } public BadPasswordException(string message) : base(message) { } public BadPasswordException(string message, Exception inner) : base(message, inner) { } protected BadPasswordException( @@ -42,7 +42,7 @@ namespace Timeline.Services [Serializable] public class BadTokenVersionException : Exception { - public BadTokenVersionException(): base("Token version is expired.") { } + public BadTokenVersionException() : base("Token version is expired.") { } public BadTokenVersionException(string message) : base(message) { } public BadTokenVersionException(string message, Exception inner) : base(message, inner) { } protected BadTokenVersionException( @@ -105,6 +105,8 @@ namespace Timeline.Services /// /// Partially modify a user of given username. + /// + /// Note that whether actually modified or not, Version of the user will always increase. /// /// Username of the user to modify. Can't be null. /// New password. Null if not modify. @@ -309,27 +311,20 @@ namespace Timeline.Services if (user == null) throw new UserNotExistException(); - bool modified = false; - if (password != null) { - modified = true; user.EncryptedPassword = _passwordService.HashPassword(password); } if (administrator != null) { - modified = true; user.RoleString = IsAdminToRoleString(administrator.Value); } - if (modified) - { - user.Version += 1; - await _databaseContext.SaveChangesAsync(); - //clear cache - RemoveCache(user.Id); - } + user.Version += 1; + await _databaseContext.SaveChangesAsync(); + //clear cache + RemoveCache(user.Id); } public async Task DeleteUser(string username) -- cgit v1.2.3 From 6a8f6e48daa3ec101fa770ba96240f406b436a0b Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 4 Aug 2019 16:54:20 +0800 Subject: WIP: Need to solve the entity framework problem. --- .../Authentication/AuthenticationExtensions.cs | 4 +- Timeline.Tests/Helpers/TestClock.cs | 50 +-- .../Helpers/WebApplicationFactoryExtensions.cs | 12 +- Timeline.Tests/Timeline.Tests.csproj | 45 +-- Timeline.Tests/TokenUnitTest.cs | 312 ++++++++------- Timeline/Services/JwtService.cs | 363 ++++++++--------- Timeline/Services/PasswordService.cs | 432 ++++++++++----------- Timeline/Services/UserService.cs | 10 +- 8 files changed, 624 insertions(+), 604 deletions(-) (limited to 'Timeline/Services/UserService.cs') diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs index 03fb9714..27362ac3 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -10,9 +10,9 @@ namespace Timeline.Tests.Helpers.Authentication { private const string CreateTokenUrl = "/token/create"; - public static async Task CreateUserTokenAsync(this HttpClient client, string username, string password) + public static async Task CreateUserTokenAsync(this HttpClient client, string username, string password, double? expireOffset = null) { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = username, Password = password }); + var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = username, Password = password, ExpireOffset = expireOffset }); var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); return result; } diff --git a/Timeline.Tests/Helpers/TestClock.cs b/Timeline.Tests/Helpers/TestClock.cs index fc200be9..91523f2b 100644 --- a/Timeline.Tests/Helpers/TestClock.cs +++ b/Timeline.Tests/Helpers/TestClock.cs @@ -1,25 +1,25 @@ -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using System; -using Timeline.Services; - -namespace Timeline.Tests.Helpers -{ - public class TestClock : IClock - { - DateTime? MockCurrentTime { get; set; } = null; - - public DateTime GetCurrentTime() - { - return MockCurrentTime.GetValueOrDefault(DateTime.Now); - } - } - - public static class TestClockWebApplicationFactoryExtensions - { - public static TestClock GetTestClock(this WebApplicationFactory factory) where T : class - { - return factory.Server.Host.Services.GetRequiredService() as TestClock; - } - } -} +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System; +using Timeline.Services; + +namespace Timeline.Tests.Helpers +{ + public class TestClock : IClock + { + public DateTime? MockCurrentTime { get; set; } = null; + + public DateTime GetCurrentTime() + { + return MockCurrentTime.GetValueOrDefault(DateTime.Now); + } + } + + public static class TestClockWebApplicationFactoryExtensions + { + public static TestClock GetTestClock(this WebApplicationFactory factory) where T : class + { + return factory.Server.Host.Services.GetRequiredService() as TestClock; + } + } +} diff --git a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs b/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs index aa005ba3..5a1f97d5 100644 --- a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs +++ b/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Timeline.Models; -using Timeline.Services; +using Timeline.Services; using Xunit.Abstractions; namespace Timeline.Tests.Helpers @@ -24,12 +24,12 @@ namespace Timeline.Tests.Helpers .ConfigureServices(services => { var serviceProvider = new ServiceCollection() - .AddEntityFrameworkInMemoryDatabase() + .AddEntityFrameworkSqlite() .BuildServiceProvider(); services.AddDbContext(options => { - options.UseInMemoryDatabase("timeline"); + options.UseSqlite("Data Source=:memory:;"); //TODO! This not work! options.UseInternalServiceProvider(serviceProvider); }); @@ -50,8 +50,8 @@ namespace Timeline.Tests.Helpers } }) .ConfigureTestServices(services => - { - services.AddSingleton(); + { + services.AddSingleton(); }); }); } diff --git a/Timeline.Tests/Timeline.Tests.csproj b/Timeline.Tests/Timeline.Tests.csproj index 820737cc..1a5f2850 100644 --- a/Timeline.Tests/Timeline.Tests.csproj +++ b/Timeline.Tests/Timeline.Tests.csproj @@ -1,22 +1,23 @@ - - - - netcoreapp2.2 - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - + + + + netcoreapp2.2 + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + diff --git a/Timeline.Tests/TokenUnitTest.cs b/Timeline.Tests/TokenUnitTest.cs index d7df8797..1fe3cff6 100644 --- a/Timeline.Tests/TokenUnitTest.cs +++ b/Timeline.Tests/TokenUnitTest.cs @@ -1,147 +1,165 @@ -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using System.Linq; -using System.Net; -using System.Net.Http; -using Timeline.Controllers; -using Timeline.Entities; -using Timeline.Entities.Http; -using Timeline.Models; -using Timeline.Services; -using Timeline.Tests.Helpers; -using Timeline.Tests.Helpers.Authentication; -using Xunit; -using Xunit.Abstractions; - -namespace Timeline.Tests -{ - public class TokenUnitTest : IClassFixture> - { - private const string CreateTokenUrl = "token/create"; - private const string VerifyTokenUrl = "token/verify"; - - private readonly WebApplicationFactory _factory; - - public TokenUnitTest(WebApplicationFactory factory, ITestOutputHelper outputHelper) - { - _factory = factory.WithTestConfig(outputHelper); - } - - [Fact] - public async void CreateTokenTest_UserNotExist() - { - using (var client = _factory.CreateDefaultClient()) - { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "usernotexist", Password = "???" }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Create_UserNotExist, body.Code); - } - } - - [Fact] - public async void CreateTokenTest_BadPassword() - { - using (var client = _factory.CreateDefaultClient()) - { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "???" }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Create_BadPassword, body.Code); - } - } - - [Fact] - public async void CreateTokenTest_BadExpireOffset() - { - using (var client = _factory.CreateDefaultClient()) - { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "???", Password = "???", ExpireOffset = -1000 }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Create_BadExpireOffset, body.Code); - } - } - - [Fact] - public async void CreateTokenTest_Success() - { - using (var client = _factory.CreateDefaultClient()) - { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "user" }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.ReadBodyAsJson(); - Assert.NotEmpty(body.Token); - Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer); - } - } - - [Fact] - public async void VerifyTokenTest_BadToken() - { - using (var client = _factory.CreateDefaultClient()) - { - var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = "bad token hahaha" }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Verify_BadToken, body.Code); - } - } - - [Fact] - public async void VerifyTokenTest_BadVersion_AND_UserNotExist() - { - using (var client = _factory.CreateDefaultClient()) - { - using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped. - { - // create a user for test - var userService = scope.ServiceProvider.GetRequiredService(); - - const string username = "verifytokentest0"; - const string password = "12345678"; - - await userService.PutUser(username, password, false); - - // create a token - var token = (await client.CreateUserTokenAsync(username, password)).Token; - - // increase version - await userService.PatchUser(username, null, null); - - // test against bad version - var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Verify_BadVersion, body.Code); - - // create another token - var token2 = (await client.CreateUserTokenAsync(username, password)).Token; - - // delete user - await userService.DeleteUser(username); - - // test against user not exist - var response2 = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); - Assert.Equal(HttpStatusCode.BadRequest, response2.StatusCode); - var body2 = await response2.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Verify_UserNotExist, body2.Code); - } - } - } - - [Fact] - public async void VerifyTokenTest_Success() - { - using (var client = _factory.CreateDefaultClient()) - { - var createTokenResult = await client.CreateUserTokenAsync("admin", "admin"); - var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = createTokenResult.Token }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer); - } - } - } -} +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using Timeline.Controllers; +using Timeline.Entities; +using Timeline.Entities.Http; +using Timeline.Models; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Xunit; +using Xunit.Abstractions; + +namespace Timeline.Tests +{ + public class TokenUnitTest : IClassFixture> + { + private const string CreateTokenUrl = "token/create"; + private const string VerifyTokenUrl = "token/verify"; + + private readonly WebApplicationFactory _factory; + + public TokenUnitTest(WebApplicationFactory factory, ITestOutputHelper outputHelper) + { + _factory = factory.WithTestConfig(outputHelper); + } + + [Fact] + public async void CreateTokenTest_UserNotExist() + { + using (var client = _factory.CreateDefaultClient()) + { + var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "usernotexist", Password = "???" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Create_UserNotExist, body.Code); + } + } + + [Fact] + public async void CreateTokenTest_BadPassword() + { + using (var client = _factory.CreateDefaultClient()) + { + var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "???" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Create_BadPassword, body.Code); + } + } + + [Fact] + public async void CreateTokenTest_BadExpireOffset() + { + using (var client = _factory.CreateDefaultClient()) + { + var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "???", Password = "???", ExpireOffset = -1000 }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Create_BadExpireOffset, body.Code); + } + } + + [Fact] + public async void CreateTokenTest_Success() + { + using (var client = _factory.CreateDefaultClient()) + { + var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "user" }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.NotEmpty(body.Token); + Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer); + } + } + + [Fact] + public async void VerifyTokenTest_BadToken() + { + using (var client = _factory.CreateDefaultClient()) + { + var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = "bad token hahaha" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Verify_BadToken, body.Code); + } + } + + [Fact] + public async void VerifyTokenTest_BadVersion_AND_UserNotExist() + { + using (var client = _factory.CreateDefaultClient()) + { + using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped. + { + // create a user for test + var userService = scope.ServiceProvider.GetRequiredService(); + + const string username = "verifytokentest0"; + const string password = "12345678"; + + await userService.PutUser(username, password, false); + + // create a token + var token = (await client.CreateUserTokenAsync(username, password)).Token; + + // increase version + await userService.PatchUser(username, null, null); + + // test against bad version + var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Verify_BadVersion, body.Code); + + // create another token + var token2 = (await client.CreateUserTokenAsync(username, password)).Token; + + // delete user + await userService.DeleteUser(username); + + // test against user not exist + var response2 = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); + Assert.Equal(HttpStatusCode.BadRequest, response2.StatusCode); + var body2 = await response2.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Verify_UserNotExist, body2.Code); + } + } + } + + [Fact] + public async void VerifyTokenTest_Expired() + { + using (var client = _factory.CreateDefaultClient()) + { + // I can only control the token expired time but not current time + // because verify logic is encapsuled in other library. + var mockClock = _factory.GetTestClock(); + mockClock.MockCurrentTime = DateTime.Now - TimeSpan.FromDays(2); + var token = (await client.CreateUserTokenAsync("user", "user", 1)).Token; + var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); + var body = await response.ReadBodyAsJson(); + Assert.Equal(TokenController.ErrorCodes.Verify_Expired, body.Code); + mockClock.MockCurrentTime = null; + } + } + + [Fact] + public async void VerifyTokenTest_Success() + { + using (var client = _factory.CreateDefaultClient()) + { + var createTokenResult = await client.CreateUserTokenAsync("user", "user"); + var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = createTokenResult.Token }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer); + } + } + } +} diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs index 52e892f6..94afe745 100644 --- a/Timeline/Services/JwtService.cs +++ b/Timeline/Services/JwtService.cs @@ -1,181 +1,182 @@ -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System; -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; } - } - - [Serializable] - public class JwtTokenVerifyException : 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 JwtTokenVerifyException(int code) : base(GetErrorMessage(code)) { ErrorCode = code; } - public JwtTokenVerifyException(string message, int code) : base(message) { ErrorCode = code; } - public JwtTokenVerifyException(Exception inner, int code) : base(GetErrorMessage(code), inner) { ErrorCode = code; } - public JwtTokenVerifyException(string message, Exception inner, int code) : base(message, inner) { ErrorCode = code; } - protected JwtTokenVerifyException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public int ErrorCode { get; private set; } - - private static string GetErrorMessage(int errorCode) - { - switch (errorCode) - { - case ErrorCodes.Others: - return "Uncommon error, see inner exception for more information."; - case ErrorCodes.NoIdClaim: - return "Id claim does not exist."; - case ErrorCodes.IdClaimBadFormat: - return "Id claim is not a number."; - case ErrorCodes.NoVersionClaim: - return "Version claim does not exist."; - case ErrorCodes.VersionClaimBadFormat: - return "Version claim is not a number"; - case ErrorCodes.Expired: - return "Token is expired."; - default: - return "Unknown error code."; - } - } - } - - 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(), ClaimValueTypes.Integer64)); - identity.AddClaim(new Claim(VersionClaimType, tokenInfo.Version.ToString(), 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)) - }; - - 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 JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.NoIdClaim); - if (!long.TryParse(idClaim, out var id)) - throw new JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.IdClaimBadFormat); - - var versionClaim = principal.FindFirstValue(VersionClaimType); - if (versionClaim == null) - throw new JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.NoVersionClaim); - if (!long.TryParse(versionClaim, out var version)) - throw new JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.VersionClaimBadFormat); - - return new TokenInfo - { - Id = id, - Version = version - }; - } - catch (SecurityTokenExpiredException e) - { - throw new JwtTokenVerifyException(e, JwtTokenVerifyException.ErrorCodes.Expired); - } - catch (Exception e) - { - throw new JwtTokenVerifyException(e, JwtTokenVerifyException.ErrorCodes.Others); - } - } - } -} +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System; +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; } + } + + [Serializable] + public class JwtTokenVerifyException : 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 JwtTokenVerifyException(int code) : base(GetErrorMessage(code)) { ErrorCode = code; } + public JwtTokenVerifyException(string message, int code) : base(message) { ErrorCode = code; } + public JwtTokenVerifyException(Exception inner, int code) : base(GetErrorMessage(code), inner) { ErrorCode = code; } + public JwtTokenVerifyException(string message, Exception inner, int code) : base(message, inner) { ErrorCode = code; } + protected JwtTokenVerifyException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public int ErrorCode { get; private set; } + + private static string GetErrorMessage(int errorCode) + { + switch (errorCode) + { + case ErrorCodes.Others: + return "Uncommon error, see inner exception for more information."; + case ErrorCodes.NoIdClaim: + return "Id claim does not exist."; + case ErrorCodes.IdClaimBadFormat: + return "Id claim is not a number."; + case ErrorCodes.NoVersionClaim: + return "Version claim does not exist."; + case ErrorCodes.VersionClaimBadFormat: + return "Version claim is not a number"; + case ErrorCodes.Expired: + return "Token is expired."; + default: + return "Unknown error code."; + } + } + } + + 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(), ClaimValueTypes.Integer64)); + identity.AddClaim(new Claim(VersionClaimType, tokenInfo.Version.ToString(), 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 JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.NoIdClaim); + if (!long.TryParse(idClaim, out var id)) + throw new JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.IdClaimBadFormat); + + var versionClaim = principal.FindFirstValue(VersionClaimType); + if (versionClaim == null) + throw new JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.NoVersionClaim); + if (!long.TryParse(versionClaim, out var version)) + throw new JwtTokenVerifyException(JwtTokenVerifyException.ErrorCodes.VersionClaimBadFormat); + + return new TokenInfo + { + Id = id, + Version = version + }; + } + catch (SecurityTokenExpiredException e) + { + throw new JwtTokenVerifyException(e, JwtTokenVerifyException.ErrorCodes.Expired); + } + catch (Exception e) + { + throw new JwtTokenVerifyException(e, JwtTokenVerifyException.ErrorCodes.Others); + } + } + } +} diff --git a/Timeline/Services/PasswordService.cs b/Timeline/Services/PasswordService.cs index d114bb26..8c67d046 100644 --- a/Timeline/Services/PasswordService.cs +++ b/Timeline/Services/PasswordService.cs @@ -1,216 +1,216 @@ -using Microsoft.AspNetCore.Cryptography.KeyDerivation; -using System; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; - -namespace Timeline.Services -{ - /// - /// Hashed password is of bad format. - /// - /// - [Serializable] - public class HashedPasswordBadFromatException : Exception - { - public HashedPasswordBadFromatException(string hashedPassword, string message) : base(message) { HashedPassword = hashedPassword; } - public HashedPasswordBadFromatException(string hashedPassword, string message, Exception inner) : base(message, inner) { HashedPassword = hashedPassword; } - protected HashedPasswordBadFromatException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public string HashedPassword { get; private set; } - } - - public interface IPasswordService - { - /// - /// Hash a password. - /// - /// The password to hash. - /// A hashed representation of the supplied . - /// Thrown when is null. - string HashPassword(string password); - - /// - /// Verify whether the password fits into the hashed one. - /// - /// Usually you only need to check the returned bool value. - /// Catching usually is not necessary. - /// Because if your program logic is right and always call - /// and in pair, this exception will never be thrown. - /// A thrown one usually means the data you saved is corupted, which is a critical problem. - /// - /// The hashed password. - /// The password supplied for comparison. - /// True indicating password is right. Otherwise false. - /// Thrown when or is null. - /// Thrown when the hashed password is of bad format. - bool VerifyPassword(string hashedPassword, string providedPassword); - } - - /// - /// Copied from https://github.com/aspnet/AspNetCore/blob/master/src/Identity/Extensions.Core/src/PasswordHasher.cs - /// Remove V2 format and unnecessary format version check. - /// Remove configuration options. - /// Remove user related parts. - /// Change the exceptions. - /// - public class PasswordService : IPasswordService - { - /* ======================= - * HASHED PASSWORD FORMATS - * ======================= - * - * Version 3: - * PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations. - * Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey } - * (All UInt32s are stored big-endian.) - */ - - private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); - - public PasswordService() - { - } - - // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized. - [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] - private static bool ByteArraysEqual(byte[] a, byte[] b) - { - if (a == null && b == null) - { - return true; - } - if (a == null || b == null || a.Length != b.Length) - { - return false; - } - var areSame = true; - for (var i = 0; i < a.Length; i++) - { - areSame &= (a[i] == b[i]); - } - return areSame; - } - - public string HashPassword(string password) - { - if (password == null) - throw new ArgumentNullException(nameof(password)); - return Convert.ToBase64String(HashPasswordV3(password, _rng)); - } - - private byte[] HashPasswordV3(string password, RandomNumberGenerator rng) - { - return HashPasswordV3(password, rng, - prf: KeyDerivationPrf.HMACSHA256, - iterCount: 10000, - saltSize: 128 / 8, - numBytesRequested: 256 / 8); - } - - private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng, KeyDerivationPrf prf, int iterCount, int saltSize, int numBytesRequested) - { - // Produce a version 3 (see comment above) text hash. - byte[] salt = new byte[saltSize]; - rng.GetBytes(salt); - byte[] subkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, numBytesRequested); - - var outputBytes = new byte[13 + salt.Length + subkey.Length]; - outputBytes[0] = 0x01; // format marker - WriteNetworkByteOrder(outputBytes, 1, (uint)prf); - WriteNetworkByteOrder(outputBytes, 5, (uint)iterCount); - WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize); - Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length); - Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length); - return outputBytes; - } - - public bool VerifyPassword(string hashedPassword, string providedPassword) - { - if (hashedPassword == null) - throw new ArgumentNullException(nameof(hashedPassword)); - if (providedPassword == null) - throw new ArgumentNullException(nameof(providedPassword)); - - byte[] decodedHashedPassword; - try - { - decodedHashedPassword = Convert.FromBase64String(hashedPassword); - } - catch (FormatException e) - { - throw new HashedPasswordBadFromatException(hashedPassword, "Not of valid base64 format. See inner exception.", e); - } - - // read the format marker from the hashed password - if (decodedHashedPassword.Length == 0) - { - throw new HashedPasswordBadFromatException(hashedPassword, "Decoded hashed password is of length 0."); - } - switch (decodedHashedPassword[0]) - { - case 0x01: - return VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword); - - default: - throw new HashedPasswordBadFromatException(hashedPassword, "Unknown format marker."); - } - } - - private bool VerifyHashedPasswordV3(byte[] hashedPassword, string password, string hashedPasswordString) - { - try - { - // Read header information - KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(hashedPassword, 1); - int iterCount = (int)ReadNetworkByteOrder(hashedPassword, 5); - int saltLength = (int)ReadNetworkByteOrder(hashedPassword, 9); - - // Read the salt: must be >= 128 bits - if (saltLength < 128 / 8) - { - throw new HashedPasswordBadFromatException(hashedPasswordString, "Salt length < 128 bits."); - } - byte[] salt = new byte[saltLength]; - Buffer.BlockCopy(hashedPassword, 13, salt, 0, salt.Length); - - // Read the subkey (the rest of the payload): must be >= 128 bits - int subkeyLength = hashedPassword.Length - 13 - salt.Length; - if (subkeyLength < 128 / 8) - { - throw new HashedPasswordBadFromatException(hashedPasswordString, "Subkey length < 128 bits."); - } - byte[] expectedSubkey = new byte[subkeyLength]; - Buffer.BlockCopy(hashedPassword, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); - - // Hash the incoming password and verify it - byte[] actualSubkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, subkeyLength); - return ByteArraysEqual(actualSubkey, expectedSubkey); - } - catch (Exception e) - { - // This should never occur except in the case of a malformed payload, where - // we might go off the end of the array. Regardless, a malformed payload - // implies verification failed. - throw new HashedPasswordBadFromatException(hashedPasswordString, "See inner exception.", e); - } - } - - private static uint ReadNetworkByteOrder(byte[] buffer, int offset) - { - return ((uint)(buffer[offset + 0]) << 24) - | ((uint)(buffer[offset + 1]) << 16) - | ((uint)(buffer[offset + 2]) << 8) - | ((uint)(buffer[offset + 3])); - } - - private static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value) - { - buffer[offset + 0] = (byte)(value >> 24); - buffer[offset + 1] = (byte)(value >> 16); - buffer[offset + 2] = (byte)(value >> 8); - buffer[offset + 3] = (byte)(value >> 0); - } - } -} +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace Timeline.Services +{ + /// + /// Hashed password is of bad format. + /// + /// + [Serializable] + public class HashedPasswordBadFromatException : Exception + { + public HashedPasswordBadFromatException(string hashedPassword, string message) : base(message) { HashedPassword = hashedPassword; } + public HashedPasswordBadFromatException(string hashedPassword, string message, Exception inner) : base(message, inner) { HashedPassword = hashedPassword; } + protected HashedPasswordBadFromatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string HashedPassword { get; private set; } + } + + public interface IPasswordService + { + /// + /// Hash a password. + /// + /// The password to hash. + /// A hashed representation of the supplied . + /// Thrown when is null. + string HashPassword(string password); + + /// + /// Verify whether the password fits into the hashed one. + /// + /// Usually you only need to check the returned bool value. + /// Catching usually is not necessary. + /// Because if your program logic is right and always call + /// and in pair, this exception will never be thrown. + /// A thrown one usually means the data you saved is corupted, which is a critical problem. + /// + /// The hashed password. + /// The password supplied for comparison. + /// True indicating password is right. Otherwise false. + /// Thrown when or is null. + /// Thrown when the hashed password is of bad format. + bool VerifyPassword(string hashedPassword, string providedPassword); + } + + /// + /// Copied from https://github.com/aspnet/AspNetCore/blob/master/src/Identity/Extensions.Core/src/PasswordHasher.cs + /// Remove V2 format and unnecessary format version check. + /// Remove configuration options. + /// Remove user related parts. + /// Change the exceptions. + /// + public class PasswordService : IPasswordService + { + /* ======================= + * HASHED PASSWORD FORMATS + * ======================= + * + * Version 3: + * PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations. + * Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey } + * (All UInt32s are stored big-endian.) + */ + + private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); + + public PasswordService() + { + } + + // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized. + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static bool ByteArraysEqual(byte[] a, byte[] b) + { + if (a == null && b == null) + { + return true; + } + if (a == null || b == null || a.Length != b.Length) + { + return false; + } + var areSame = true; + for (var i = 0; i < a.Length; i++) + { + areSame &= (a[i] == b[i]); + } + return areSame; + } + + public string HashPassword(string password) + { + if (password == null) + throw new ArgumentNullException(nameof(password)); + return Convert.ToBase64String(HashPasswordV3(password, _rng)); + } + + private byte[] HashPasswordV3(string password, RandomNumberGenerator rng) + { + return HashPasswordV3(password, rng, + prf: KeyDerivationPrf.HMACSHA256, + iterCount: 10000, + saltSize: 128 / 8, + numBytesRequested: 256 / 8); + } + + private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng, KeyDerivationPrf prf, int iterCount, int saltSize, int numBytesRequested) + { + // Produce a version 3 (see comment above) text hash. + byte[] salt = new byte[saltSize]; + rng.GetBytes(salt); + byte[] subkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, numBytesRequested); + + var outputBytes = new byte[13 + salt.Length + subkey.Length]; + outputBytes[0] = 0x01; // format marker + WriteNetworkByteOrder(outputBytes, 1, (uint)prf); + WriteNetworkByteOrder(outputBytes, 5, (uint)iterCount); + WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize); + Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length); + Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length); + return outputBytes; + } + + public bool VerifyPassword(string hashedPassword, string providedPassword) + { + if (hashedPassword == null) + throw new ArgumentNullException(nameof(hashedPassword)); + if (providedPassword == null) + throw new ArgumentNullException(nameof(providedPassword)); + + byte[] decodedHashedPassword; + try + { + decodedHashedPassword = Convert.FromBase64String(hashedPassword); + } + catch (FormatException e) + { + throw new HashedPasswordBadFromatException(hashedPassword, "Not of valid base64 format. See inner exception.", e); + } + + // read the format marker from the hashed password + if (decodedHashedPassword.Length == 0) + { + throw new HashedPasswordBadFromatException(hashedPassword, "Decoded hashed password is of length 0."); + } + switch (decodedHashedPassword[0]) + { + case 0x01: + return VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword); + + default: + throw new HashedPasswordBadFromatException(hashedPassword, "Unknown format marker."); + } + } + + private bool VerifyHashedPasswordV3(byte[] hashedPassword, string password, string hashedPasswordString) + { + try + { + // Read header information + KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(hashedPassword, 1); + int iterCount = (int)ReadNetworkByteOrder(hashedPassword, 5); + int saltLength = (int)ReadNetworkByteOrder(hashedPassword, 9); + + // Read the salt: must be >= 128 bits + if (saltLength < 128 / 8) + { + throw new HashedPasswordBadFromatException(hashedPasswordString, "Salt length < 128 bits."); + } + byte[] salt = new byte[saltLength]; + Buffer.BlockCopy(hashedPassword, 13, salt, 0, salt.Length); + + // Read the subkey (the rest of the payload): must be >= 128 bits + int subkeyLength = hashedPassword.Length - 13 - salt.Length; + if (subkeyLength < 128 / 8) + { + throw new HashedPasswordBadFromatException(hashedPasswordString, "Subkey length < 128 bits."); + } + byte[] expectedSubkey = new byte[subkeyLength]; + Buffer.BlockCopy(hashedPassword, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); + + // Hash the incoming password and verify it + byte[] actualSubkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, subkeyLength); + return ByteArraysEqual(actualSubkey, expectedSubkey); + } + catch (Exception e) + { + // This should never occur except in the case of a malformed payload, where + // we might go off the end of the array. Regardless, a malformed payload + // implies verification failed. + throw new HashedPasswordBadFromatException(hashedPasswordString, "See inner exception.", e); + } + } + + private static uint ReadNetworkByteOrder(byte[] buffer, int offset) + { + return ((uint)(buffer[offset + 0]) << 24) + | ((uint)(buffer[offset + 1]) << 16) + | ((uint)(buffer[offset + 2]) << 8) + | ((uint)(buffer[offset + 3])); + } + + private static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value) + { + buffer[offset + 0] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)(value >> 0); + } + } +} diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 65ac98d3..0d6934ff 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -319,11 +319,11 @@ namespace Timeline.Services if (administrator != null) { user.RoleString = IsAdminToRoleString(administrator.Value); - } - - user.Version += 1; - await _databaseContext.SaveChangesAsync(); - //clear cache + } + + user.Version += 1; + await _databaseContext.SaveChangesAsync(); + //clear cache RemoveCache(user.Id); } -- cgit v1.2.3