diff options
author | 杨宇千 <crupest@outlook.com> | 2019-08-04 21:35:04 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-08-04 21:35:04 +0800 |
commit | ebda3fc381ee4ed9f729fa85c1cee837ce4c5c3b (patch) | |
tree | d1c9c7b51353b67b47bb4cd89aa82754ef0a1234 | |
parent | 85d25348c9d6ad527b86c57fd5023829c8b9d6bf (diff) | |
parent | 2a32e03a384a30b14988b0b6e40db845f4a5444e (diff) | |
download | timeline-ebda3fc381ee4ed9f729fa85c1cee837ce4c5c3b.tar.gz timeline-ebda3fc381ee4ed9f729fa85c1cee837ce4c5c3b.tar.bz2 timeline-ebda3fc381ee4ed9f729fa85c1cee837ce4c5c3b.zip |
Merge pull request #34 from crupest/token-time
Set token expired time and write unit tests.
21 files changed, 816 insertions, 556 deletions
diff --git a/Timeline.Tests/AuthorizationUnitTest.cs b/Timeline.Tests/AuthorizationUnitTest.cs index ee3deac8..a25a8f9b 100644 --- a/Timeline.Tests/AuthorizationUnitTest.cs +++ b/Timeline.Tests/AuthorizationUnitTest.cs @@ -8,7 +8,7 @@ using Xunit.Abstractions; namespace Timeline.Tests { - public class AuthorizationUnitTest : IClassFixture<WebApplicationFactory<Startup>> + public class AuthorizationUnitTest : IClassFixture<MyWebApplicationFactory<Startup>> { private const string AuthorizeUrl = "Test/User/Authorize"; private const string UserUrl = "Test/User/User"; @@ -16,9 +16,9 @@ namespace Timeline.Tests private readonly WebApplicationFactory<Startup> _factory; - public AuthorizationUnitTest(WebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) + public AuthorizationUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) { - _factory = factory.WithTestConfig(outputHelper); + _factory = factory.WithTestLogging(outputHelper); } [Fact] @@ -44,12 +44,11 @@ namespace Timeline.Tests [Fact] public async Task UserAuthorizationTest() { - using (var client = _factory.CreateDefaultClient()) + using (var client = await _factory.CreateClientWithUser("user", "user")) { - var token = (await client.CreateUserTokenAsync("user", "user")).Token; - var response1 = await client.SendWithAuthenticationAsync(token, UserUrl); + var response1 = await client.GetAsync(UserUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); - var response2 = await client.SendWithAuthenticationAsync(token, AdminUrl); + var response2 = await client.GetAsync(AdminUrl); Assert.Equal(HttpStatusCode.Forbidden, response2.StatusCode); } } diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs index f4e2e45a..27362ac3 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Mvc.Testing; using Newtonsoft.Json; -using System; using System.Net.Http; using System.Threading.Tasks; using Timeline.Entities.Http; @@ -11,9 +10,9 @@ namespace Timeline.Tests.Helpers.Authentication { private const string CreateTokenUrl = "/token/create"; - public static async Task<CreateTokenResponse> CreateUserTokenAsync(this HttpClient client, string username, string password) + public static async Task<CreateTokenResponse> 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<CreateTokenResponse>(await response.Content.ReadAsStringAsync()); return result; } @@ -25,16 +24,5 @@ namespace Timeline.Tests.Helpers.Authentication client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); return client; } - - public static async Task<HttpResponseMessage> SendWithAuthenticationAsync(this HttpClient client, string token, string path, Action<HttpRequestMessage> requestBuilder = null) - { - var request = new HttpRequestMessage - { - RequestUri = new Uri(client.BaseAddress, path), - }; - request.Headers.Add("Authorization", "Bearer " + token); - requestBuilder?.Invoke(request); - return await client.SendAsync(request); - } } } diff --git a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs new file mode 100644 index 00000000..903cd670 --- /dev/null +++ b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Timeline.Models; +using Timeline.Services;
+using Xunit.Abstractions; + +namespace Timeline.Tests.Helpers +{ + public class MyWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
+ {
+ // We should keep the connection, so the database is persisted but not recreate every time.
+ // See https://docs.microsoft.com/en-us/ef/core/miscellaneous/testing/sqlite#writing-tests .
+ private readonly SqliteConnection _databaseConnection;
+
+ public MyWebApplicationFactory() : base()
+ {
+ _databaseConnection = new SqliteConnection("Data Source=:memory:;");
+ _databaseConnection.Open();
+
+ InitDatabase();
+ }
+
+ private void InitDatabase()
+ {
+ var options = new DbContextOptionsBuilder<DatabaseContext>()
+ .UseSqlite(_databaseConnection)
+ .Options;
+
+ using (var context = new DatabaseContext(options))
+ {
+ context.Database.EnsureCreated();
+ context.Users.AddRange(TestMockUsers.MockUsers);
+ context.SaveChanges();
+ }
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ services.AddEntityFrameworkSqlite();
+ services.AddDbContext<DatabaseContext>(options =>
+ {
+ options.UseSqlite(_databaseConnection);
+ });
+ })
+ .ConfigureTestServices(services =>
+ {
+ services.AddSingleton<IClock, TestClock>();
+ });
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _databaseConnection.Close();
+ _databaseConnection.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+ } + + public static class WebApplicationFactoryExtensions + { + public static WebApplicationFactory<TEntry> WithTestLogging<TEntry>(this WebApplicationFactory<TEntry> factory, ITestOutputHelper outputHelper) where TEntry : class + { + return factory.WithWebHostBuilder(builder => + { + builder.ConfigureLogging(logging =>
+ {
+ logging.AddXunit(outputHelper);
+ }); + }); + } + } +} diff --git a/Timeline.Tests/Helpers/ResponseExtensions.cs b/Timeline.Tests/Helpers/ResponseExtensions.cs new file mode 100644 index 00000000..86ac1c88 --- /dev/null +++ b/Timeline.Tests/Helpers/ResponseExtensions.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Timeline.Tests.Helpers +{ + public static class ResponseExtensions + { + public static async Task<T> ReadBodyAsJson<T>(this HttpResponseMessage response) + { + return JsonConvert.DeserializeObject<T>(await response.Content.ReadAsStringAsync()); + } + } +} diff --git a/Timeline.Tests/Helpers/TestClock.cs b/Timeline.Tests/Helpers/TestClock.cs new file mode 100644 index 00000000..91523f2b --- /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
+ {
+ public DateTime? MockCurrentTime { get; set; } = null;
+
+ public DateTime GetCurrentTime()
+ {
+ return MockCurrentTime.GetValueOrDefault(DateTime.Now);
+ }
+ }
+
+ public static class TestClockWebApplicationFactoryExtensions
+ {
+ public static TestClock GetTestClock<T>(this WebApplicationFactory<T> factory) where T : class
+ {
+ return factory.Server.Host.Services.GetRequiredService<IClock>() as TestClock;
+ }
+ }
+}
diff --git a/Timeline.Tests/Helpers/TestUsers.cs b/Timeline.Tests/Helpers/TestUsers.cs index dd00e38d..41dd83a9 100644 --- a/Timeline.Tests/Helpers/TestUsers.cs +++ b/Timeline.Tests/Helpers/TestUsers.cs @@ -11,19 +11,21 @@ namespace Timeline.Tests.Helpers static TestMockUsers() { var mockUsers = new List<User>(); - var passwordService = new PasswordService(null); + var passwordService = new PasswordService(); mockUsers.Add(new User { Name = "user", EncryptedPassword = passwordService.HashPassword("user"), - RoleString = "user" + RoleString = UserUtility.IsAdminToRoleString(false), + Version = 0, }); mockUsers.Add(new User { Name = "admin", EncryptedPassword = passwordService.HashPassword("admin"), - RoleString = "user,admin" + RoleString = UserUtility.IsAdminToRoleString(true), + Version = 0, }); MockUsers = mockUsers; diff --git a/Timeline.Tests/Helpers/UserInfoComparers.cs b/Timeline.Tests/Helpers/UserInfoComparers.cs index 0d91efe3..fcf37e5c 100644 --- a/Timeline.Tests/Helpers/UserInfoComparers.cs +++ b/Timeline.Tests/Helpers/UserInfoComparers.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using Timeline.Entities; namespace Timeline.Tests.Helpers diff --git a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs b/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs deleted file mode 100644 index a7616b41..00000000 --- a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Timeline.Models; -using Xunit.Abstractions; - -namespace Timeline.Tests.Helpers -{ - public static class WebApplicationFactoryExtensions - { - public static WebApplicationFactory<TEntry> WithTestConfig<TEntry>(this WebApplicationFactory<TEntry> factory, ITestOutputHelper outputHelper) where TEntry : class - { - return factory.WithWebHostBuilder(builder => - { - builder - .ConfigureLogging(logging => - { - logging.AddXunit(outputHelper); - }) - .ConfigureServices(services => - { - var serviceProvider = new ServiceCollection() - .AddEntityFrameworkInMemoryDatabase() - .BuildServiceProvider(); - - services.AddDbContext<DatabaseContext>(options => - { - options.UseInMemoryDatabase("timeline"); - options.UseInternalServiceProvider(serviceProvider); - }); - - var sp = services.BuildServiceProvider(); - - // Create a scope to obtain a reference to the database - // context (ApplicationDbContext). - using (var scope = sp.CreateScope()) - { - var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService<DatabaseContext>(); - - // Ensure the database is created. - db.Database.EnsureCreated(); - - db.Users.AddRange(TestMockUsers.MockUsers); - db.SaveChanges(); - } - }); - }); - } - } -} diff --git a/Timeline.Tests/JwtTokenUnitTest.cs b/Timeline.Tests/JwtTokenUnitTest.cs deleted file mode 100644 index 6c0d4213..00000000 --- a/Timeline.Tests/JwtTokenUnitTest.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.AspNetCore.Mvc.Testing; -using Newtonsoft.Json; -using System.Net; -using System.Net.Http; -using Timeline.Entities.Http; -using Timeline.Tests.Helpers; -using Timeline.Tests.Helpers.Authentication; -using Xunit; -using Xunit.Abstractions; - -namespace Timeline.Tests -{ - public class JwtTokenUnitTest : IClassFixture<WebApplicationFactory<Startup>> - { - private const string CreateTokenUrl = "token/create"; - private const string VerifyTokenUrl = "token/verify"; - - private readonly WebApplicationFactory<Startup> _factory; - - public JwtTokenUnitTest(WebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) - { - _factory = factory.WithTestConfig(outputHelper); - } - - [Fact] - public async void CreateTokenTest_BadCredential() - { - using (var client = _factory.CreateDefaultClient()) - { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "???", Password = "???" }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - } - - [Fact] - public async void CreateTokenTest_GoodCredential() - { - using (var client = _factory.CreateDefaultClient()) - { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "user" }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = JsonConvert.DeserializeObject<CreateTokenResponse>(await response.Content.ReadAsStringAsync()); - Assert.NotNull(result.Token); - Assert.NotNull(result.User); - } - } - - [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); - } - } - - [Fact] - public async void VerifyTokenTest_GoodToken() - { - 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<VerifyTokenResponse>(await response.Content.ReadAsStringAsync()); - Assert.NotNull(result.User); - Assert.Equal(createTokenResult.User.Username, result.User.Username); - Assert.Equal(createTokenResult.User.Administrator, result.User.Administrator); - } - } - } -} 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 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> - - <PropertyGroup> - <TargetFramework>netcoreapp2.2</TargetFramework> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.App" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Logging.Testing" Version="2.2.0-rtm-35646" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.0.1" /> - <PackageReference Include="xunit" Version="2.4.1" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\Timeline\Timeline.csproj" /> - </ItemGroup> -</Project> +<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>netcoreapp2.2</TargetFramework>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.AspNetCore.App" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="2.2.0" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
+ <PackageReference Include="Microsoft.Extensions.Logging.Testing" Version="2.2.0-rtm-35646" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
+ <PackageReference Include="xunit" Version="2.4.1" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Timeline\Timeline.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/Timeline.Tests/TokenUnitTest.cs b/Timeline.Tests/TokenUnitTest.cs new file mode 100644 index 00000000..7b83cd13 --- /dev/null +++ b/Timeline.Tests/TokenUnitTest.cs @@ -0,0 +1,163 @@ +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.Http;
+using Timeline.Services;
+using Timeline.Tests.Helpers;
+using Timeline.Tests.Helpers.Authentication;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Timeline.Tests
+{
+ public class TokenUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>
+ {
+ private const string CreateTokenUrl = "token/create";
+ private const string VerifyTokenUrl = "token/verify";
+
+ private readonly WebApplicationFactory<Startup> _factory;
+
+ public TokenUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper)
+ {
+ _factory = factory.WithTestLogging(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<CommonResponse>();
+ 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<CommonResponse>();
+ 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<CommonResponse>();
+ 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<CreateTokenResponse>();
+ 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<CommonResponse>();
+ 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<IUserService>();
+
+ 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<CommonResponse>();
+ 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<CommonResponse>();
+ 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<CommonResponse>();
+ 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<VerifyTokenResponse>(await response.Content.ReadAsStringAsync());
+ Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer);
+ }
+ }
+ }
+}
diff --git a/Timeline.Tests/UserUnitTest.cs b/Timeline.Tests/UserUnitTest.cs index a4b4dace..b3377f7b 100644 --- a/Timeline.Tests/UserUnitTest.cs +++ b/Timeline.Tests/UserUnitTest.cs @@ -11,13 +11,13 @@ using Xunit.Abstractions; namespace Timeline.Tests { - public class UserUnitTest : IClassFixture<WebApplicationFactory<Startup>> + public class UserUnitTest : IClassFixture<MyWebApplicationFactory<Startup>> { private readonly WebApplicationFactory<Startup> _factory; - public UserUnitTest(WebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) + public UserUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) { - _factory = factory.WithTestConfig(outputHelper); + _factory = factory.WithTestLogging(outputHelper); } [Fact] diff --git a/Timeline/Authenticate/AuthHandler.cs b/Timeline/Authenticate/AuthHandler.cs index 75d3b49f..41cb11c6 100644 --- a/Timeline/Authenticate/AuthHandler.cs +++ b/Timeline/Authenticate/AuthHandler.cs @@ -87,6 +87,10 @@ namespace Timeline.Authenticate return AuthenticateResult.Success(new AuthenticationTicket(principal, AuthConstants.Scheme)); } + catch (ArgumentException) + { + throw; // this exception usually means server error. + } catch (Exception e) { _logger.LogInformation(e, "A jwt token validation failed."); diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs index 023bd53f..21f87ded 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; @@ -19,40 +20,56 @@ namespace Timeline.Controllers public const int VerifyFailed = 2001; } - private static class ErrorCodes + public static class ErrorCodes { 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; public const int Verify_BadVersion = -2003; + public const int Verify_Expired = -2004; } private readonly IUserService _userService; private readonly ILogger<TokenController> _logger; + private readonly IClock _clock; - public TokenController(IUserService userService, ILogger<TokenController> logger) + public TokenController(IUserService userService, ILogger<TokenController> logger, IClock clock) { _userService = userService; _logger = logger; + _clock = clock; } [HttpPost("create")] [AllowAnonymous] public async Task<IActionResult> 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); @@ -81,9 +98,18 @@ namespace Timeline.Controllers } catch (JwtTokenVerifyException e) { - var code = ErrorCodes.Verify_BadToken; - _logger.LogInformation(LoggingEventIds.VerifyFailed, e, "Attemp to verify a bad token because of bad format. Code: {} Token: {}.", code, request.Token); - return BadRequest(new CommonResponse(code, "A token of bad format.")); + if (e.ErrorCode == JwtTokenVerifyException.ErrorCodes.Expired) + { + var code = ErrorCodes.Verify_Expired; + _logger.LogInformation(LoggingEventIds.VerifyFailed, e, "Attemp to verify a expired token. Code: {} Token: {}.", code, request.Token); + return BadRequest(new CommonResponse(code, "A expired token.")); + } + else + { + var code = ErrorCodes.Verify_BadToken; + _logger.LogInformation(LoggingEventIds.VerifyFailed, e, "Attemp to verify a bad token because of bad format. Code: {} Token: {}.", code, request.Token); + return BadRequest(new CommonResponse(code, "A token of bad format.")); + } } catch (UserNotExistException e) { @@ -93,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/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 +{ + /// <summary> + /// Convenient for unit test. + /// </summary> + public interface IClock + { + /// <summary> + /// Get current time. + /// </summary> + /// <returns>Current time.</returns> + 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 e970bbd4..94afe745 100644 --- a/Timeline/Services/JwtService.cs +++ b/Timeline/Services/JwtService.cs @@ -1,135 +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 JwtTokenVerifyException() { } - public JwtTokenVerifyException(string message) : base(message) { } - public JwtTokenVerifyException(string message, Exception inner) : base(message, inner) { } - protected JwtTokenVerifyException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - } - - public interface IJwtService - { - /// <summary> - /// Create a JWT token for a given token info. - /// </summary> - /// <param name="tokenInfo">The info to generate token.</param> - /// <param name="expires">The expire time. If null then use current time with offset in config.</param> - /// <returns>Return the generated token.</returns> - /// <exception cref="ArgumentNullException">Thrown when <paramref name="tokenInfo"/> is null.</exception> - string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null); - - /// <summary> - /// Verify a JWT token. - /// Return null is <paramref name="token"/> is null. - /// </summary> - /// <param name="token">The token string to verify.</param> - /// <returns>Return the saved info in token.</returns> - /// <exception cref="ArgumentNullException">Thrown when <paramref name="token"/> is null.</exception> - /// <exception cref="JwtTokenVerifyException">Thrown when the token is invalid.</exception> - TokenInfo VerifyJwtToken(string token); - - } - - public class JwtService : IJwtService - { - private const string VersionClaimType = "timeline_version"; - - private readonly IOptionsMonitor<JwtConfig> _jwtConfig; - private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); - - public JwtService(IOptionsMonitor<JwtConfig> jwtConfig) - { - _jwtConfig = jwtConfig; - } - - 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 = DateTime.Now, - Expires = expires.GetValueOrDefault(DateTime.Now.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("Id claim does not exist."); - if (!long.TryParse(idClaim, out var id)) - throw new JwtTokenVerifyException("Can't convert id claim into a integer number."); - - var versionClaim = principal.FindFirstValue(VersionClaimType); - if (versionClaim == null) - throw new JwtTokenVerifyException("Version claim does not exist."); - if (!long.TryParse(versionClaim, out var version)) - throw new JwtTokenVerifyException("Can't convert version claim into a integer number."); - - return new TokenInfo - { - Id = id, - Version = version - }; - } - catch (Exception e) - { - throw new JwtTokenVerifyException("Validate token failed caused by a SecurityTokenException. See inner exception.", e); - } - } - } -} +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;
+
+ /// <summary>
+ /// Corresponds to <see cref="SecurityTokenExpiredException"/>.
+ /// </summary>
+ 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
+ {
+ /// <summary>
+ /// Create a JWT token for a given token info.
+ /// </summary>
+ /// <param name="tokenInfo">The info to generate token.</param>
+ /// <param name="expires">The expire time. If null then use current time with offset in config.</param>
+ /// <returns>Return the generated token.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="tokenInfo"/> is null.</exception>
+ string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null);
+
+ /// <summary>
+ /// Verify a JWT token.
+ /// Return null is <paramref name="token"/> is null.
+ /// </summary>
+ /// <param name="token">The token string to verify.</param>
+ /// <returns>Return the saved info in token.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="token"/> is null.</exception>
+ /// <exception cref="JwtTokenVerifyException">Thrown when the token is invalid.</exception>
+ TokenInfo VerifyJwtToken(string token);
+
+ }
+
+ public class JwtService : IJwtService
+ {
+ private const string VersionClaimType = "timeline_version";
+
+ private readonly IOptionsMonitor<JwtConfig> _jwtConfig;
+ private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler();
+ private readonly IClock _clock;
+
+ public JwtService(IOptionsMonitor<JwtConfig> 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 106080f1..8c67d046 100644 --- a/Timeline/Services/PasswordService.cs +++ b/Timeline/Services/PasswordService.cs @@ -1,207 +1,216 @@ -using Microsoft.AspNetCore.Cryptography.KeyDerivation; -using Microsoft.Extensions.Logging; -using System; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; - -namespace Timeline.Services -{ - public interface IPasswordService - { - /// <summary> - /// Returns a hashed representation of the supplied <paramref name="password"/>. - /// </summary> - /// <param name="password">The password to hash.</param> - /// <returns>A hashed representation of the supplied <paramref name="password"/>.</returns> - string HashPassword(string password); - - /// <summary> - /// Returns a boolean indicating the result of a password hash comparison. - /// </summary> - /// <param name="hashedPassword">The hash value for a user's stored password.</param> - /// <param name="providedPassword">The password supplied for comparison.</param> - /// <returns>True indicating success. Otherwise false.</returns> - bool VerifyPassword(string hashedPassword, string providedPassword); - } - - //TODO! Use exceptions!!! - - /// <summary> - /// 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. - /// Add log for wrong format. - /// </summary> - 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 static EventId BadFormatEventId { get; } = new EventId(4000, "BadFormatPassword"); - - private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); - private readonly ILogger<PasswordService> _logger; - - public PasswordService(ILogger<PasswordService> logger) - { - _logger = logger; - } - - - // 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; - } - - private void LogBadFormatError(string hashedPassword, string message, Exception exception = null) - { - if (_logger == null) - return; - - if (exception != null) - _logger.LogError(BadFormatEventId, exception, $"{message} Hashed password is {hashedPassword} ."); - else - _logger.LogError(BadFormatEventId, $"{message} Hashed password is {hashedPassword} ."); - } - - public virtual bool VerifyPassword(string hashedPassword, string providedPassword) - { - if (hashedPassword == null) - throw new ArgumentNullException(nameof(hashedPassword)); - if (providedPassword == null) - throw new ArgumentNullException(nameof(providedPassword)); - - byte[] decodedHashedPassword = Convert.FromBase64String(hashedPassword); - - // read the format marker from the hashed password - if (decodedHashedPassword.Length == 0) - { - LogBadFormatError(hashedPassword, "Decoded hashed password is of length 0."); - return false; - } - switch (decodedHashedPassword[0]) - { - case 0x01: - return VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword); - - default: - LogBadFormatError(hashedPassword, "Unknown format marker."); - return false; // 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) - { - LogBadFormatError(hashedPasswordString, "Salt length < 128 bits."); - return false; - } - 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) - { - LogBadFormatError(hashedPasswordString, "Subkey length < 128 bits."); - return false; - } - 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. - LogBadFormatError(hashedPasswordString, "See exception.", e); - return false; - } - } - - 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
+{
+ /// <summary>
+ /// Hashed password is of bad format.
+ /// </summary>
+ /// <seealso cref="IPasswordService.VerifyPassword(string, string)"/>
+ [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
+ {
+ /// <summary>
+ /// Hash a password.
+ /// </summary>
+ /// <param name="password">The password to hash.</param>
+ /// <returns>A hashed representation of the supplied <paramref name="password"/>.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="password"/> is null.</exception>
+ string HashPassword(string password);
+
+ /// <summary>
+ /// Verify whether the password fits into the hashed one.
+ ///
+ /// Usually you only need to check the returned bool value.
+ /// Catching <see cref="HashedPasswordBadFromatException"/> usually is not necessary.
+ /// Because if your program logic is right and always call <see cref="HashPassword(string)"/>
+ /// and <see cref="VerifyPassword(string, string)"/> in pair, this exception will never be thrown.
+ /// A thrown one usually means the data you saved is corupted, which is a critical problem.
+ /// </summary>
+ /// <param name="hashedPassword">The hashed password.</param>
+ /// <param name="providedPassword">The password supplied for comparison.</param>
+ /// <returns>True indicating password is right. Otherwise false.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="hashedPassword"/> or <paramref name="providedPassword"/> is null.</exception>
+ /// <exception cref="HashedPasswordBadFromatException">Thrown when the hashed password is of bad format.</exception>
+ bool VerifyPassword(string hashedPassword, string providedPassword);
+ }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ 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 c63ded1e..0d6934ff 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( @@ -58,11 +58,12 @@ namespace Timeline.Services /// </summary> /// <param name="username">The username of the user to anthenticate.</param> /// <param name="password">The password of the user to anthenticate.</param> + /// <param name="expires">The expired time point. Null then use default. See <see cref="JwtService.GenerateJwtToken(TokenInfo, DateTime?)"/> for what is default.</param> /// <returns>An <see cref="CreateTokenResult"/> containing the created token and user info.</returns> /// <exception cref="ArgumentNullException">Thrown when <paramref name="username"/> or <paramref name="password"/> is null.</exception> /// <exception cref="UserNotExistException">Thrown when the user with given username does not exist.</exception> /// <exception cref="BadPasswordException">Thrown when password is wrong.</exception> - Task<CreateTokenResult> CreateToken(string username, string password); + Task<CreateTokenResult> CreateToken(string username, string password, DateTime? expires = null); /// <summary> /// Verify the given token. @@ -104,6 +105,8 @@ namespace Timeline.Services /// <summary> /// Partially modify a user of given username. + /// + /// Note that whether actually modified or not, Version of the user will always increase. /// </summary> /// <param name="username">Username of the user to modify. Can't be null.</param> /// <param name="password">New password. Null if not modify.</param> @@ -170,7 +173,7 @@ namespace Timeline.Services _memoryCache.Remove(GenerateCacheKeyByUserId(id)); } - public async Task<CreateTokenResult> CreateToken(string username, string password) + public async Task<CreateTokenResult> CreateToken(string username, string password, DateTime? expires) { if (username == null) throw new ArgumentNullException(nameof(username)); @@ -198,7 +201,7 @@ namespace Timeline.Services { Id = user.Id, Version = user.Version - }); + }, expires); return new CreateTokenResult { Token = token, @@ -208,6 +211,9 @@ namespace Timeline.Services public async Task<UserInfo> VerifyToken(string token) { + if (token == null) + throw new ArgumentNullException(nameof(token)); + TokenInfo tokenInfo; try { @@ -305,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) diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index a6965190..242e816d 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -51,6 +51,7 @@ namespace Timeline services.AddScoped<IUserService, UserService>(); services.AddScoped<IJwtService, JwtService>(); services.AddTransient<IPasswordService, PasswordService>(); + services.AddTransient<IClock, Clock>(); var databaseConfig = Configuration.GetSection(nameof(DatabaseConfig)).Get<DatabaseConfig>(); @@ -83,12 +84,7 @@ namespace Timeline app.UseAuthentication(); - app.UseMvc(routes => - { - routes.MapRoute( - name: "default", - template: "{controller}/{action=Index}/{id?}"); - }); + app.UseMvcWithDefaultRoute(); } } } |