diff options
26 files changed, 667 insertions, 507 deletions
diff --git a/Timeline.Tests/AuthorizationUnitTest.cs b/Timeline.Tests/AuthorizationUnitTest.cs index 28715ada..ee3deac8 100644 --- a/Timeline.Tests/AuthorizationUnitTest.cs +++ b/Timeline.Tests/AuthorizationUnitTest.cs @@ -10,9 +10,9 @@ namespace Timeline.Tests { public class AuthorizationUnitTest : IClassFixture<WebApplicationFactory<Startup>> { - private const string NeedAuthorizeUrl = "Test/User/NeedAuthorize"; - private const string BothUserAndAdminUrl = "Test/User/BothUserAndAdmin"; - private const string OnlyAdminUrl = "Test/User/OnlyAdmin"; + private const string AuthorizeUrl = "Test/User/Authorize"; + private const string UserUrl = "Test/User/User"; + private const string AdminUrl = "Test/User/Admin"; private readonly WebApplicationFactory<Startup> _factory; @@ -26,7 +26,7 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var response = await client.GetAsync(NeedAuthorizeUrl); + var response = await client.GetAsync(AuthorizeUrl); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } @@ -36,7 +36,7 @@ namespace Timeline.Tests { using (var client = await _factory.CreateClientWithUser("user", "user")) { - var response = await client.GetAsync(NeedAuthorizeUrl); + var response = await client.GetAsync(AuthorizeUrl); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } @@ -47,9 +47,9 @@ namespace Timeline.Tests using (var client = _factory.CreateDefaultClient()) { var token = (await client.CreateUserTokenAsync("user", "user")).Token; - var response1 = await client.SendWithAuthenticationAsync(token, BothUserAndAdminUrl); + var response1 = await client.SendWithAuthenticationAsync(token, UserUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); - var response2 = await client.SendWithAuthenticationAsync(token, OnlyAdminUrl); + var response2 = await client.SendWithAuthenticationAsync(token, AdminUrl); Assert.Equal(HttpStatusCode.Forbidden, response2.StatusCode); } } @@ -59,9 +59,9 @@ namespace Timeline.Tests { using (var client = await _factory.CreateClientWithUser("admin", "admin")) { - var response1 = await client.GetAsync(BothUserAndAdminUrl); + var response1 = await client.GetAsync(UserUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); - var response2 = await client.GetAsync(OnlyAdminUrl); + var response2 = await client.GetAsync(AdminUrl); Assert.Equal(HttpStatusCode.OK, response2.StatusCode); } } diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs index cda9fe99..f4e2e45a 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -1,11 +1,9 @@ using Microsoft.AspNetCore.Mvc.Testing; using Newtonsoft.Json; using System; -using System.Net; using System.Net.Http; using System.Threading.Tasks; using Timeline.Entities.Http; -using Xunit; namespace Timeline.Tests.Helpers.Authentication { @@ -13,15 +11,10 @@ namespace Timeline.Tests.Helpers.Authentication { private const string CreateTokenUrl = "/token/create"; - public static async Task<CreateTokenResponse> CreateUserTokenAsync(this HttpClient client, string username, string password, bool assertSuccess = true) + public static async Task<CreateTokenResponse> CreateUserTokenAsync(this HttpClient client, string username, string password) { var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = username, Password = password }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = JsonConvert.DeserializeObject<CreateTokenResponse>(await response.Content.ReadAsStringAsync()); - if (assertSuccess) - Assert.True(result.Success); - return result; } diff --git a/Timeline.Tests/JwtTokenUnitTest.cs b/Timeline.Tests/JwtTokenUnitTest.cs index a4e5432f..6ab4e8a6 100644 --- a/Timeline.Tests/JwtTokenUnitTest.cs +++ b/Timeline.Tests/JwtTokenUnitTest.cs @@ -28,11 +28,7 @@ namespace Timeline.Tests using (var client = _factory.CreateDefaultClient()) { var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "???", Password = "???" }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = JsonConvert.DeserializeObject<CreateTokenResponse>(await response.Content.ReadAsStringAsync()); - Assert.False(result.Success); - Assert.Null(result.Token); - Assert.Null(result.UserInfo); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } @@ -44,9 +40,8 @@ namespace Timeline.Tests 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.True(result.Success); Assert.NotNull(result.Token); - Assert.NotNull(result.UserInfo); + Assert.NotNull(result.User); } } @@ -56,11 +51,7 @@ namespace Timeline.Tests using (var client = _factory.CreateDefaultClient()) { var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = "bad token hahaha" }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var validationInfo = JsonConvert.DeserializeObject<VerifyTokenResponse>(await response.Content.ReadAsStringAsync()); - Assert.False(validationInfo.IsValid); - Assert.Null(validationInfo.UserInfo); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } @@ -75,10 +66,9 @@ namespace Timeline.Tests Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = JsonConvert.DeserializeObject<VerifyTokenResponse>(await response.Content.ReadAsStringAsync()); - Assert.True(result.IsValid); - Assert.NotNull(result.UserInfo); - Assert.Equal(createTokenResult.UserInfo.Username, result.UserInfo.Username); - Assert.Equal(createTokenResult.UserInfo.IsAdmin, result.UserInfo.IsAdmin); + Assert.NotNull(result.User); + Assert.Equal(createTokenResult.User.Username, result.User.Username); + Assert.Equal(createTokenResult.User.IsAdmin, result.User.IsAdmin); } } } diff --git a/Timeline.Tests/QCloudCosServiceUnitTest.cs b/Timeline.Tests/QCloudCosServiceUnitTest.cs index 0940c70d..c330cce0 100644 --- a/Timeline.Tests/QCloudCosServiceUnitTest.cs +++ b/Timeline.Tests/QCloudCosServiceUnitTest.cs @@ -1,10 +1,6 @@ using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using Timeline.Services; using Timeline.Tests.Helpers; using Xunit; @@ -65,40 +61,40 @@ namespace Timeline.Tests Assert.Equal("q-sign-algorithm=sha1&q-ak=AKIDQjz3ltompVjBni5LitkWHFlFpwkn9U5q&q-sign-time=1417773892;1417853898&q-key-time=1417773892;1417853898&q-header-list=host;x-cos-content-sha1;x-cos-storage-class&q-url-param-list=&q-signature=0ab12f43e74cbe148d705cd9fae8adc9a6d39cc1", QCloudCosService.GenerateSign(credential, request, signValidTime)); } -/* -// Tests in this part need secret configs in cos. -#region SecretTests - [Fact] - public async Task ObjectExistsTest() - { - using (var serviceScope = _factory.Server.Host.Services.CreateScope()) - { - var services = serviceScope.ServiceProvider; - var service = services.GetRequiredService<IQCloudCosService>(); - Assert.True(await service.IsObjectExists("avatar", "__default")); - Assert.False(await service.IsObjectExists("avatar", "haha")); - Assert.False(await service.IsObjectExists("haha", "haha")); - } - } + /* + // Tests in this part need secret configs in cos. + #region SecretTests + [Fact] + public async Task ObjectExistsTest() + { + using (var serviceScope = _factory.Server.Host.Services.CreateScope()) + { + var services = serviceScope.ServiceProvider; + var service = services.GetRequiredService<IQCloudCosService>(); + Assert.True(await service.IsObjectExists("avatar", "__default")); + Assert.False(await service.IsObjectExists("avatar", "haha")); + Assert.False(await service.IsObjectExists("haha", "haha")); + } + } - [Fact] - public async Task GenerateObjectGetUrlTest() - { - using (var serviceScope = _factory.Server.Host.Services.CreateScope()) - { - var services = serviceScope.ServiceProvider; - var service = services.GetRequiredService<IQCloudCosService>(); - var url = service.GenerateObjectGetUrl("avatar", "__default"); - // never use the following line! Because client created by factory can't access Internet. - //using (var client = _factory.CreateClient()) - using (var client = services.GetRequiredService<IHttpClientFactory>().CreateClient()) + [Fact] + public async Task GenerateObjectGetUrlTest() { - var res = await client.GetAsync(url); - Assert.Equal(HttpStatusCode.OK, res.StatusCode); + using (var serviceScope = _factory.Server.Host.Services.CreateScope()) + { + var services = serviceScope.ServiceProvider; + var service = services.GetRequiredService<IQCloudCosService>(); + var url = service.GenerateObjectGetUrl("avatar", "__default"); + // never use the following line! Because client created by factory can't access Internet. + //using (var client = _factory.CreateClient()) + using (var client = services.GetRequiredService<IHttpClientFactory>().CreateClient()) + { + var res = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + } } - } - } -#endregion -*/ + #endregion + */ } } diff --git a/Timeline/Authenticate/Attribute.cs b/Timeline/Authenticate/Attribute.cs new file mode 100644 index 00000000..50b2681d --- /dev/null +++ b/Timeline/Authenticate/Attribute.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authorization; +using Timeline.Models; + +namespace Timeline.Authenticate +{ + public class AdminAuthorizeAttribute : AuthorizeAttribute + { + public AdminAuthorizeAttribute() + { + Roles = UserRoles.Admin; + } + } + + public class UserAuthorizeAttribute : AuthorizeAttribute + { + public UserAuthorizeAttribute() + { + Roles = UserRoles.User; + } + } +} diff --git a/Timeline/Authenticate/AuthHandler.cs b/Timeline/Authenticate/AuthHandler.cs new file mode 100644 index 00000000..80860edf --- /dev/null +++ b/Timeline/Authenticate/AuthHandler.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using System; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Timeline.Services; + +namespace Timeline.Authenticate +{ + static class AuthConstants + { + public const string Scheme = "Bearer"; + public const string DisplayName = "My Jwt Auth Scheme"; + } + + class AuthOptions : AuthenticationSchemeOptions + { + /// <summary> + /// The query param key to search for token. If null then query params are not searched for token. Default to <c>"token"</c>. + /// </summary> + public string TokenQueryParamKey { get; set; } = "token"; + } + + class AuthHandler : AuthenticationHandler<AuthOptions> + { + private readonly ILogger<AuthHandler> _logger; + private readonly IUserService _userService; + + public AuthHandler(IOptionsMonitor<AuthOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IUserService userService) + : base(options, logger, encoder, clock) + { + _logger = logger.CreateLogger<AuthHandler>(); + _userService = userService; + } + + // return null if no token is found + private string ExtractToken() + { + // check the authorization header + string header = Request.Headers[HeaderNames.Authorization]; + if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + var token = header.Substring("Bearer ".Length).Trim(); + _logger.LogInformation("Token is found in authorization header. Token is {} .", token); + return token; + } + + // check the query params + var paramQueryKey = Options.TokenQueryParamKey; + if (!string.IsNullOrEmpty(paramQueryKey)) + { + string token = Request.Query[paramQueryKey]; + if (!string.IsNullOrEmpty(token)) + { + _logger.LogInformation("Token is found in query param with key \"{}\". Token is {} .", paramQueryKey, token); + return token; + } + } + + // not found anywhere then return null + return null; + } + + protected override async Task<AuthenticateResult> HandleAuthenticateAsync() + { + var token = ExtractToken(); + if (string.IsNullOrEmpty(token)) + { + _logger.LogInformation("No jwt token is found."); + return AuthenticateResult.NoResult(); + } + + try + { + var userInfo = await _userService.VerifyToken(token); + + var identity = new ClaimsIdentity(AuthConstants.Scheme); + identity.AddClaim(new Claim(identity.NameClaimType, userInfo.Username, ClaimValueTypes.String)); + identity.AddClaims(Entities.UserUtility.IsAdminToRoleArray(userInfo.IsAdmin).Select(role => new Claim(identity.RoleClaimType, role, ClaimValueTypes.String))); + + var principal = new ClaimsPrincipal(); + principal.AddIdentity(identity); + + return AuthenticateResult.Success(new AuthenticationTicket(principal, AuthConstants.Scheme)); + } + catch (Exception e) + { + _logger.LogInformation(e, "A jwt token validation failed."); + return AuthenticateResult.Fail(e); + } + } + } +} diff --git a/Timeline/Configs/JwtConfig.cs b/Timeline/Configs/JwtConfig.cs index 9550424e..4d5ef97f 100644 --- a/Timeline/Configs/JwtConfig.cs +++ b/Timeline/Configs/JwtConfig.cs @@ -5,5 +5,11 @@ namespace Timeline.Configs public string Issuer { get; set; } public string Audience { get; set; } public string SigningKey { get; set; } + + /// <summary> + /// Set the default value of expire offset of jwt token. + /// Unit is second. Default is 3600 * 24 seconds, aka 1 day. + /// </summary> + public long DefaultExpireOffset { get; set; } = 3600 * 24; } } diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs index 0be5fb2f..023bd53f 100644 --- a/Timeline/Controllers/TokenController.cs +++ b/Timeline/Controllers/TokenController.cs @@ -12,8 +12,21 @@ namespace Timeline.Controllers { private static class LoggingEventIds { - public const int LogInSucceeded = 4000; - public const int LogInFailed = 4001; + public const int LogInSucceeded = 1000; + public const int LogInFailed = 1001; + + public const int VerifySucceeded = 2000; + public const int VerifyFailed = 2001; + } + + private static class ErrorCodes + { + public const int Create_UserNotExist = -1001; + public const int Create_BadPassword = -1002; + + public const int Verify_BadToken = -2001; + public const int Verify_UserNotExist = -2002; + public const int Verify_BadVersion = -2003; } private readonly IUserService _userService; @@ -27,48 +40,63 @@ namespace Timeline.Controllers [HttpPost("create")] [AllowAnonymous] - public async Task<ActionResult<CreateTokenResponse>> Create([FromBody] CreateTokenRequest request) + public async Task<IActionResult> Create([FromBody] CreateTokenRequest request) { - var result = await _userService.CreateToken(request.Username, request.Password); - - if (result == null) + try { - _logger.LogInformation(LoggingEventIds.LogInFailed, "Attemp to login with username: {} and password: {} failed.", request.Username, request.Password); + var result = await _userService.CreateToken(request.Username, request.Password); + _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login succeeded. Username: {} .", request.Username); return Ok(new CreateTokenResponse { - Success = false + Token = result.Token, + User = result.User }); } - - _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login with username: {} succeeded.", request.Username); - - return Ok(new CreateTokenResponse + 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); + return BadRequest(new CommonResponse(code, "Bad username or password.")); + } + catch (BadPasswordException e) { - Success = true, - Token = result.Token, - UserInfo = result.UserInfo - }); + var code = ErrorCodes.Create_BadPassword; + _logger.LogInformation(LoggingEventIds.LogInFailed, e, "Attemp to login failed because password is wrong. Code: {} Username: {} Password: {} .", code, request.Username, request.Password); + return BadRequest(new CommonResponse(code, "Bad username or password.")); + } } [HttpPost("verify")] [AllowAnonymous] - public async Task<ActionResult<VerifyTokenResponse>> Verify([FromBody] VerifyTokenRequest request) + public async Task<IActionResult> Verify([FromBody] VerifyTokenRequest request) { - var result = await _userService.VerifyToken(request.Token); - - if (result == null) + try { + var result = await _userService.VerifyToken(request.Token); + _logger.LogInformation(LoggingEventIds.VerifySucceeded, "Verify token succeeded. Username: {} Token: {} .", result.Username, request.Token); return Ok(new VerifyTokenResponse { - IsValid = false, + User = result }); } - - return Ok(new VerifyTokenResponse + catch (JwtTokenVerifyException e) { - IsValid = true, - UserInfo = result - }); + 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) + { + var code = ErrorCodes.Verify_UserNotExist; + _logger.LogInformation(LoggingEventIds.VerifyFailed, e, "Attemp to verify a bad token because user does not exist. Code: {} Token: {}.", code, request.Token); + return BadRequest(new CommonResponse(code, "The user does not exist. Administrator might have deleted this user.")); + } + catch (BadTokenVersionException e) + { + var code = ErrorCodes.Verify_BadToken; + _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/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 6f708e8a..413999ce 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using System; -using System.IO; using System.Threading.Tasks; +using Timeline.Authenticate; using Timeline.Entities; using Timeline.Entities.Http; using Timeline.Services; @@ -12,125 +12,113 @@ namespace Timeline.Controllers { public class UserController : Controller { + private static class ErrorCodes + { + public const int Get_NotExists = -1001; + + public const int Put_NoPassword = -2001; + + public const int Patch_NotExists = -3001; + + public const int ChangePassword_BadOldPassword = -4001; + } + + private readonly ILogger<UserController> _logger; private readonly IUserService _userService; - public UserController(IUserService userService) + public UserController(ILogger<UserController> logger, IUserService userService) { + _logger = logger; _userService = userService; } - [HttpGet("users"), Authorize(Roles = "admin")] + [HttpGet("users"), AdminAuthorize] public async Task<ActionResult<UserInfo[]>> List() { return Ok(await _userService.ListUsers()); } - [HttpGet("user/{username}"), Authorize] + [HttpGet("user/{username}"), AdminAuthorize] public async Task<IActionResult> Get([FromRoute] string username) { var user = await _userService.GetUser(username); if (user == null) { - return NotFound(); + _logger.LogInformation("Attempt to get a non-existent user. Username: {} .", username); + return NotFound(new CommonResponse(ErrorCodes.Get_NotExists, "The user does not exist.")); } return Ok(user); } - [HttpPut("user/{username}"), Authorize(Roles = "admin")] + [HttpPut("user/{username}"), AdminAuthorize] public async Task<IActionResult> Put([FromBody] UserPutRequest request, [FromRoute] string username) { + if (request.Password == null) + { + _logger.LogInformation("Attempt to put a user without a password. Username: {} .", username); + return BadRequest(); + } + var result = await _userService.PutUser(username, request.Password, request.IsAdmin); switch (result) { - case PutUserResult.Created: - return CreatedAtAction("Get", new { username }, UserPutResponse.Created); - case PutUserResult.Modified: - return Ok(UserPutResponse.Modified); + case PutResult.Created: + _logger.LogInformation("Created a user. Username: {} .", username); + return CreatedAtAction("Get", new { username }, CommonPutResponse.Created); + case PutResult.Modified: + _logger.LogInformation("Modified a user. Username: {} .", username); + return Ok(CommonPutResponse.Modified); default: throw new Exception("Unreachable code."); } } - [HttpPatch("user/{username}"), Authorize(Roles = "admin")] + [HttpPatch("user/{username}"), AdminAuthorize] public async Task<IActionResult> Patch([FromBody] UserPatchRequest request, [FromRoute] string username) { - var result = await _userService.PatchUser(username, request.Password, request.IsAdmin); - switch (result) + try { - case PatchUserResult.Success: - return Ok(); - case PatchUserResult.NotExists: - return NotFound(); - default: - throw new Exception("Unreachable code."); + await _userService.PatchUser(username, request.Password, request.IsAdmin); + return Ok(); } - } - - [HttpDelete("user/{username}"), Authorize(Roles = "admin")] - public async Task<IActionResult> Delete([FromRoute] string username) - { - var result = await _userService.DeleteUser(username); - switch (result) + catch (UserNotExistException e) { - case DeleteUserResult.Deleted: - return Ok(UserDeleteResponse.Deleted); - case DeleteUserResult.NotExists: - return Ok(UserDeleteResponse.NotExists); - default: - throw new Exception("Uncreachable code."); + _logger.LogInformation(e, "Attempt to patch a non-existent user. Username: {} .", username); + return BadRequest(new CommonResponse(ErrorCodes.Patch_NotExists, "The user does not exist.")); } } - [HttpGet("user/{username}/avatar"), Authorize] - public async Task<IActionResult> GetAvatar([FromRoute] string username) - { - var url = await _userService.GetAvatarUrl(username); - if (url == null) - return NotFound(); - return Redirect(url); - } - - [HttpPut("user/{username}/avatar"), Authorize] - [Consumes("image/png", "image/gif", "image/jpeg", "image/svg+xml")] - public async Task<IActionResult> PutAvatar([FromRoute] string username, [FromHeader(Name="Content-Type")] string contentType) + [HttpDelete("user/{username}"), AdminAuthorize] + public async Task<IActionResult> Delete([FromRoute] string username) { - bool isAdmin = User.IsInRole("admin"); - if (!isAdmin) + try { - if (username != User.Identity.Name) - return StatusCode(StatusCodes.Status403Forbidden, PutAvatarResponse.Forbidden); + await _userService.DeleteUser(username); + _logger.LogInformation("A user is deleted. Username: {} .", username); + return Ok(CommonDeleteResponse.Deleted); } - - var stream = new MemoryStream(); - await Request.Body.CopyToAsync(stream); - var result = await _userService.PutAvatar(username, stream.ToArray(), contentType); - switch (result) + catch (UserNotExistException e) { - case PutAvatarResult.Success: - return Ok(PutAvatarResponse.Success); - case PutAvatarResult.UserNotExists: - return BadRequest(PutAvatarResponse.NotExists); - default: - throw new Exception("Unknown put avatar result."); + _logger.LogInformation(e, "Attempt to delete a non-existent user. Username: {} .", username); + return Ok(CommonDeleteResponse.NotExists); } } - [HttpPost("userop/changepassword"), Authorize] public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request) { - var result = await _userService.ChangePassword(User.Identity.Name, request.OldPassword, request.NewPassword); - switch (result) + try { - case ChangePasswordResult.Success: - return Ok(ChangePasswordResponse.Success); - case ChangePasswordResult.BadOldPassword: - return Ok(ChangePasswordResponse.BadOldPassword); - case ChangePasswordResult.NotExists: - return Ok(ChangePasswordResponse.NotExists); - default: - throw new Exception("Uncreachable code."); + await _userService.ChangePassword(User.Identity.Name, request.OldPassword, request.NewPassword); + _logger.LogInformation("A user changed password. Username: {} .", User.Identity.Name); + return Ok(); + } + catch (BadPasswordException e) + { + _logger.LogInformation(e, "A user attempt to change password but old password is wrong. Username: {} .", User.Identity.Name); + return BadRequest(new CommonResponse(ErrorCodes.ChangePassword_BadOldPassword, "Old password is wrong.")); } + // User can't be non-existent or the token is bad. } } } diff --git a/Timeline/Controllers/UserTestController.cs b/Timeline/Controllers/UserTestController.cs index f1edb0d5..21686b81 100644 --- a/Timeline/Controllers/UserTestController.cs +++ b/Timeline/Controllers/UserTestController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Timeline.Authenticate; namespace Timeline.Controllers { @@ -8,21 +9,21 @@ namespace Timeline.Controllers { [HttpGet("[action]")] [Authorize] - public ActionResult NeedAuthorize() + public ActionResult Authorize() { return Ok(); } [HttpGet("[action]")] - [Authorize(Roles = "user,admin")] - public ActionResult BothUserAndAdmin() + [UserAuthorize] + public new ActionResult User() { return Ok(); } [HttpGet("[action]")] - [Authorize(Roles = "admin")] - public ActionResult OnlyAdmin() + [AdminAuthorize] + public ActionResult Admin() { return Ok(); } diff --git a/Timeline/Entities/Http/Common.cs b/Timeline/Entities/Http/Common.cs index 9575e6fa..3a45a0ae 100644 --- a/Timeline/Entities/Http/Common.cs +++ b/Timeline/Entities/Http/Common.cs @@ -1,29 +1,37 @@ namespace Timeline.Entities.Http { - public class ReturnCodeMessageResponse + public class CommonResponse { - public ReturnCodeMessageResponse() + public CommonResponse() { } - public ReturnCodeMessageResponse(int code) - { - ReturnCode = code; - } - - public ReturnCodeMessageResponse(string message) + public CommonResponse(int code, string message) { + Code = code; Message = message; } - public ReturnCodeMessageResponse(int code, string message) - { - ReturnCode = code; - Message = message; - } + public int Code { get; set; } + public string Message { get; set; } + } + + public static class CommonPutResponse + { + public const int CreatedCode = 0; + public const int ModifiedCode = 1; + + public static CommonResponse Created { get; } = new CommonResponse(CreatedCode, "A new item is created."); + public static CommonResponse Modified { get; } = new CommonResponse(ModifiedCode, "An existent item is modified."); + } + + public static class CommonDeleteResponse + { + public const int DeletedCode = 0; + public const int NotExistsCode = 1; - public int? ReturnCode { get; set; } = null; - public string Message { get; set; } = null; + public static CommonResponse Deleted { get; } = new CommonResponse(DeletedCode, "An existent item is deleted."); + public static CommonResponse NotExists { get; } = new CommonResponse(NotExistsCode, "The item does not exist."); } } diff --git a/Timeline/Entities/Http/Token.cs b/Timeline/Entities/Http/Token.cs index 45ee0fc5..aeb9fbf2 100644 --- a/Timeline/Entities/Http/Token.cs +++ b/Timeline/Entities/Http/Token.cs @@ -8,9 +8,8 @@ public class CreateTokenResponse { - public bool Success { get; set; } public string Token { get; set; } - public UserInfo UserInfo { get; set; } + public UserInfo User { get; set; } } public class VerifyTokenRequest @@ -20,7 +19,6 @@ public class VerifyTokenResponse { - public bool IsValid { get; set; } - public UserInfo UserInfo { get; set; } + public UserInfo User { get; set; } } } diff --git a/Timeline/Entities/Http/User.cs b/Timeline/Entities/Http/User.cs index db3d5071..91423c7b 100644 --- a/Timeline/Entities/Http/User.cs +++ b/Timeline/Entities/Http/User.cs @@ -12,49 +12,9 @@ public bool? IsAdmin { get; set; } } - public static class UserPutResponse - { - public const int CreatedCode = 0; - public const int ModifiedCode = 1; - - public static ReturnCodeMessageResponse Created { get; } = new ReturnCodeMessageResponse(CreatedCode, "A new user is created."); - public static ReturnCodeMessageResponse Modified { get; } = new ReturnCodeMessageResponse(ModifiedCode, "A existing user is modified."); - } - - public static class UserDeleteResponse - { - public const int DeletedCode = 0; - public const int NotExistsCode = 1; - - public static ReturnCodeMessageResponse Deleted { get; } = new ReturnCodeMessageResponse(DeletedCode, "A existing user is deleted."); - public static ReturnCodeMessageResponse NotExists { get; } = new ReturnCodeMessageResponse(NotExistsCode, "User with given name does not exists."); - } - public class ChangePasswordRequest { public string OldPassword { get; set; } public string NewPassword { get; set; } } - - public static class ChangePasswordResponse - { - public const int SuccessCode = 0; - public const int BadOldPasswordCode = 1; - public const int NotExistsCode = 2; - - public static ReturnCodeMessageResponse Success { get; } = new ReturnCodeMessageResponse(SuccessCode, "Success to change password."); - public static ReturnCodeMessageResponse BadOldPassword { get; } = new ReturnCodeMessageResponse(BadOldPasswordCode, "Old password is wrong."); - public static ReturnCodeMessageResponse NotExists { get; } = new ReturnCodeMessageResponse(NotExistsCode, "Username does not exists, please update token."); - } - - public static class PutAvatarResponse - { - public const int SuccessCode = 0; - public const int ForbiddenCode = 1; - public const int NotExistsCode = 2; - - public static ReturnCodeMessageResponse Success { get; } = new ReturnCodeMessageResponse(SuccessCode, "Success to upload avatar."); - public static ReturnCodeMessageResponse Forbidden { get; } = new ReturnCodeMessageResponse(ForbiddenCode, "You are not allowed to upload the user's avatar."); - public static ReturnCodeMessageResponse NotExists { get; } = new ReturnCodeMessageResponse(NotExistsCode, "The username does not exists. If you are a user, try update your token."); - } } diff --git a/Timeline/Entities/PutResult.cs b/Timeline/Entities/PutResult.cs new file mode 100644 index 00000000..4ed48572 --- /dev/null +++ b/Timeline/Entities/PutResult.cs @@ -0,0 +1,17 @@ +namespace Timeline.Entities +{ + /// <summary> + /// Represents the result of a "put" operation. + /// </summary> + public enum PutResult + { + /// <summary> + /// Indicates the item did not exist and now is created. + /// </summary> + Created, + /// <summary> + /// Indicates the item exists already and is modified. + /// </summary> + Modified + } +} diff --git a/Timeline/Entities/UserInfo.cs b/Timeline/Entities/UserInfo.cs index bb56df9d..9a82c991 100644 --- a/Timeline/Entities/UserInfo.cs +++ b/Timeline/Entities/UserInfo.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; - namespace Timeline.Entities { public sealed class UserInfo diff --git a/Timeline/Entities/UserUtility.cs b/Timeline/Entities/UserUtility.cs index 9a272948..cbbd391c 100644 --- a/Timeline/Entities/UserUtility.cs +++ b/Timeline/Entities/UserUtility.cs @@ -1,14 +1,14 @@ using System; using System.Linq; -using Timeline.Entities; using Timeline.Models; +using Timeline.Services; namespace Timeline.Entities { public static class UserUtility { - public const string UserRole = "user"; - public const string AdminRole = "admin"; + public const string UserRole = UserRoles.User; + public const string AdminRole = UserRoles.Admin; public static string[] UserRoleArray { get; } = new string[] { UserRole }; public static string[] AdminRoleArray { get; } = new string[] { UserRole, AdminRole }; @@ -38,12 +38,23 @@ namespace Timeline.Entities return RoleArrayToRoleString(IsAdminToRoleArray(isAdmin)); } + public static bool RoleStringToIsAdmin(string roleString) + { + return RoleArrayToIsAdmin(RoleStringToRoleArray(roleString)); + } + public static UserInfo CreateUserInfo(User user) { if (user == null) throw new ArgumentNullException(nameof(user)); - return new UserInfo(user.Name, RoleArrayToIsAdmin(RoleStringToRoleArray(user.RoleString))); + return new UserInfo(user.Name, RoleStringToIsAdmin(user.RoleString)); } + internal static UserCache CreateUserCache(User user) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + return new UserCache { Username = user.Name, IsAdmin = RoleStringToIsAdmin(user.RoleString), Version = user.Version }; + } } } diff --git a/Timeline/Formatters/StringInputFormatter.cs b/Timeline/Formatters/StringInputFormatter.cs deleted file mode 100644 index ca9216d7..00000000 --- a/Timeline/Formatters/StringInputFormatter.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Net.Http.Headers; -using System.IO; -using System.Text; -using System.Threading.Tasks; - -namespace Timeline.Formatters -{ - public class StringInputFormatter : TextInputFormatter - { - public StringInputFormatter() - { - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); - - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } - - public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding effectiveEncoding) - { - var request = context.HttpContext.Request; - using (var reader = new StreamReader(request.Body, effectiveEncoding)) - { - var stringContent = reader.ReadToEnd(); - return InputFormatterResult.SuccessAsync(stringContent); - } - } - } -} diff --git a/Timeline/Migrations/20190719115321_Add-User-Version.Designer.cs b/Timeline/Migrations/20190719115321_Add-User-Version.Designer.cs new file mode 100644 index 00000000..42eeeb40 --- /dev/null +++ b/Timeline/Migrations/20190719115321_Add-User-Version.Designer.cs @@ -0,0 +1,49 @@ +// <auto-generated /> +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Timeline.Models; + +namespace Timeline.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20190719115321_Add-User-Version")] + partial class AddUserVersion + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Timeline.Models.User", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property<string>("EncryptedPassword") + .IsRequired() + .HasColumnName("password"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnName("name"); + + b.Property<string>("RoleString") + .IsRequired() + .HasColumnName("roles"); + + b.Property<long>("Version") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("user"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Timeline/Migrations/20190719115321_Add-User-Version.cs b/Timeline/Migrations/20190719115321_Add-User-Version.cs new file mode 100644 index 00000000..715af909 --- /dev/null +++ b/Timeline/Migrations/20190719115321_Add-User-Version.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Timeline.Migrations +{ + public partial class AddUserVersion : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<long>( + name: "version", + table: "user", + nullable: false, + defaultValue: 0L); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "version", + table: "user"); + } + } +} diff --git a/Timeline/Migrations/DatabaseContextModelSnapshot.cs b/Timeline/Migrations/DatabaseContextModelSnapshot.cs index a833d2dc..7d244969 100644 --- a/Timeline/Migrations/DatabaseContextModelSnapshot.cs +++ b/Timeline/Migrations/DatabaseContextModelSnapshot.cs @@ -13,7 +13,7 @@ namespace Timeline.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "2.2.3-servicing-35854") + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("Timeline.Models.User", b => @@ -34,6 +34,9 @@ namespace Timeline.Migrations .IsRequired() .HasColumnName("roles"); + b.Property<long>("Version") + .HasColumnName("version"); + b.HasKey("Id"); b.ToTable("user"); diff --git a/Timeline/Models/DatabaseContext.cs b/Timeline/Models/DatabaseContext.cs index 1e89ea82..afd5a333 100644 --- a/Timeline/Models/DatabaseContext.cs +++ b/Timeline/Models/DatabaseContext.cs @@ -4,6 +4,12 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Timeline.Models { + public static class UserRoles + { + public const string Admin = "admin"; + public const string User = "user"; + } + [Table("user")] public class User { @@ -18,6 +24,9 @@ namespace Timeline.Models [Column("roles"), Required] public string RoleString { get; set; } + + [Column("version"), Required] + public long Version { get; set; } } public class DatabaseContext : DbContext diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs index f5df59a5..e970bbd4 100644 --- a/Timeline/Services/JwtService.cs +++ b/Timeline/Services/JwtService.cs @@ -1,77 +1,85 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using System; using System.IdentityModel.Tokens.Jwt; -using System.Linq; using System.Security.Claims; using System.Text; using Timeline.Configs; -using Timeline.Entities; namespace Timeline.Services { public class TokenInfo { - public string Name { get; set; } - public string[] Roles { get; set; } + 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 user info. + /// 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> - string GenerateJwtToken(TokenInfo tokenInfo); + /// <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 null if <paramref name="token"/> is null or token is invalid. Return the saved info otherwise.</returns> + /// <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 ILogger<JwtService> _logger; - public JwtService(IOptionsMonitor<JwtConfig> jwtConfig, ILogger<JwtService> logger) + public JwtService(IOptionsMonitor<JwtConfig> jwtConfig) { _jwtConfig = jwtConfig; - _logger = logger; } - public string GenerateJwtToken(TokenInfo tokenInfo) + public string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null) { if (tokenInfo == null) throw new ArgumentNullException(nameof(tokenInfo)); - if (tokenInfo.Name == null) - throw new ArgumentException("Name is null.", nameof(tokenInfo)); - if (tokenInfo.Roles == null) - throw new ArgumentException("Roles is null.", nameof(tokenInfo)); - var jwtConfig = _jwtConfig.CurrentValue; + var config = _jwtConfig.CurrentValue; var identity = new ClaimsIdentity(); - identity.AddClaim(new Claim(identity.NameClaimType, tokenInfo.Name)); - identity.AddClaims(tokenInfo.Roles.Select(role => new Claim(identity.RoleClaimType, role))); + 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 = jwtConfig.Issuer, - Audience = jwtConfig.Audience, + Issuer = config.Issuer, + Audience = config.Audience, SigningCredentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.SigningKey)), SecurityAlgorithms.HmacSha384), + new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)), SecurityAlgorithms.HmacSha384), IssuedAt = DateTime.Now, - Expires = DateTime.Now.AddDays(1) + Expires = expires.GetValueOrDefault(DateTime.Now.AddSeconds(config.DefaultExpireOffset)) }; var token = _tokenHandler.CreateToken(tokenDescriptor); @@ -84,7 +92,7 @@ namespace Timeline.Services public TokenInfo VerifyJwtToken(string token) { if (token == null) - return null; + throw new ArgumentNullException(nameof(token)); var config = _jwtConfig.CurrentValue; try @@ -98,18 +106,29 @@ namespace Timeline.Services ValidIssuer = config.Issuer, ValidAudience = config.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)) - }, out SecurityToken validatedToken); + }, 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 { - Name = principal.Identity.Name, - Roles = principal.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray() + Id = id, + Version = version }; } catch (Exception e) { - _logger.LogInformation(e, "Token validation failed! Token is {} .", token); - return null; + throw new JwtTokenVerifyException("Validate token failed caused by a SecurityTokenException. See inner exception.", e); } } } diff --git a/Timeline/Services/PasswordService.cs b/Timeline/Services/PasswordService.cs index 8eab526e..106080f1 100644 --- a/Timeline/Services/PasswordService.cs +++ b/Timeline/Services/PasswordService.cs @@ -24,6 +24,8 @@ namespace Timeline.Services 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. diff --git a/Timeline/Services/QCloudCosService.cs b/Timeline/Services/QCloudCosService.cs index b37631e5..748173c4 100644 --- a/Timeline/Services/QCloudCosService.cs +++ b/Timeline/Services/QCloudCosService.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 9fe9e08f..01d05903 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System; using System.Linq; @@ -12,71 +13,41 @@ namespace Timeline.Services public class CreateTokenResult { public string Token { get; set; } - public UserInfo UserInfo { get; set; } + public UserInfo User { get; set; } } - public enum PutUserResult + [Serializable] + public class UserNotExistException : Exception { - /// <summary> - /// A new user is created. - /// </summary> - Created, - /// <summary> - /// A existing user is modified. - /// </summary> - Modified + 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( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } } - public enum PatchUserResult + [Serializable] + public class BadPasswordException : Exception { - /// <summary> - /// Succeed to modify user. - /// </summary> - Success, - /// <summary> - /// A user of given username does not exist. - /// </summary> - NotExists + public BadPasswordException(): base("Password is wrong.") { } + public BadPasswordException(string message) : base(message) { } + public BadPasswordException(string message, Exception inner) : base(message, inner) { } + protected BadPasswordException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } } - public enum DeleteUserResult - { - /// <summary> - /// A existing user is deleted. - /// </summary> - Deleted, - /// <summary> - /// A user of given username does not exist. - /// </summary> - NotExists - } - public enum ChangePasswordResult + [Serializable] + public class BadTokenVersionException : Exception { - /// <summary> - /// Success to change password. - /// </summary> - Success, - /// <summary> - /// The user does not exists. - /// </summary> - NotExists, - /// <summary> - /// Old password is wrong. - /// </summary> - BadOldPassword - } - - public enum PutAvatarResult - { - /// <summary> - /// Success to upload avatar. - /// </summary> - Success, - /// <summary> - /// The user does not exists. - /// </summary> - UserNotExists + public BadTokenVersionException(): base("Token version is expired.") { } + public BadTokenVersionException(string message) : base(message) { } + public BadTokenVersionException(string message, Exception inner) : base(message, inner) { } + protected BadTokenVersionException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } } public interface IUserService @@ -85,9 +56,12 @@ namespace Timeline.Services /// Try to anthenticate with the given username and password. /// If success, create a token and return the user info. /// </summary> - /// <param name="username">The username of the user to be anthenticated.</param> - /// <param name="password">The password of the user to be anthenticated.</param> - /// <returns>Return null if anthentication failed. An <see cref="CreateTokenResult"/> containing the created token and user info if anthentication succeeded.</returns> + /// <param name="username">The username of the user to anthenticate.</param> + /// <param name="password">The password of the user to anthenticate.</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); /// <summary> @@ -95,7 +69,11 @@ namespace Timeline.Services /// If success, return the user info. /// </summary> /// <param name="token">The token to verify.</param> - /// <returns>Return null if verification failed. The user info if verification succeeded.</returns> + /// <returns>The user info specified by the token.</returns> + /// <exception cref="ArgumentNullException">Thrown when <paramref name="token"/> is null.</exception> + /// <exception cref="JwtTokenVerifyException">Thrown when the token is of bad format. Thrown by <see cref="JwtService.VerifyJwtToken(string)"/>.</exception> + /// <exception cref="UserNotExistException">Thrown when the user specified by the token does not exist. Usually it has been deleted after the token was issued.</exception> + /// <exception cref="BadTokenVersionException">Thrown when the version in the token is expired. User needs to recreate the token.</exception> Task<UserInfo> VerifyToken(string token); /// <summary> @@ -118,31 +96,29 @@ namespace Timeline.Services /// </summary> /// <param name="username">Username of user.</param> /// <param name="password">Password of user.</param> - /// <param name="roles">Array of roles of user.</param> - /// <returns>Return <see cref="PutUserResult.Created"/> if a new user is created. - /// Return <see cref="PutUserResult.Modified"/> if a existing user is modified.</returns> - Task<PutUserResult> PutUser(string username, string password, bool isAdmin); + /// <param name="isAdmin">Whether the user is administrator.</param> + /// <returns>Return <see cref="PutResult.Created"/> if a new user is created. + /// Return <see cref="PutResult.Modified"/> if a existing user is modified.</returns> + /// <exception cref="ArgumentNullException">Thrown when <paramref name="username"/> or <paramref name="password"/> is null.</exception> + Task<PutResult> PutUser(string username, string password, bool isAdmin); /// <summary> - /// Partially modify a use of given username. + /// Partially modify a user of given username. /// </summary> - /// <param name="username">Username of the user to modify.</param> - /// <param name="password">New password. If not modify, then null.</param> - /// <param name="roles">New roles. If not modify, then null.</param> - /// <returns>Return <see cref="PatchUserResult.Success"/> if modification succeeds. - /// Return <see cref="PatchUserResult.NotExists"/> if the user of given username doesn't exist.</returns> - Task<PatchUserResult> PatchUser(string username, string password, bool? isAdmin); + /// <param name="username">Username of the user to modify. Can't be null.</param> + /// <param name="password">New password. Null if not modify.</param> + /// <param name="isAdmin">Whether the user is administrator. Null if not modify.</param> + /// <exception cref="ArgumentNullException">Thrown if <paramref name="username"/> is null.</exception> + /// <exception cref="UserNotExistException">Thrown if the user with given username does not exist.</exception> + Task PatchUser(string username, string password, bool? isAdmin); /// <summary> /// Delete a user of given username. - /// Return <see cref="DeleteUserResult.Deleted"/> if the user is deleted. - /// Return <see cref="DeleteUserResult.NotExists"/> if the user of given username - /// does not exist. /// </summary> - /// <param name="username">Username of thet user to delete.</param> - /// <returns><see cref="DeleteUserResult.Deleted"/> if the user is deleted. - /// <see cref="DeleteUserResult.NotExists"/> if the user doesn't exist.</returns> - Task<DeleteUserResult> DeleteUser(string username); + /// <param name="username">Username of thet user to delete. Can't be null.</param> + /// <exception cref="ArgumentNullException">Thrown if <paramref name="username"/> is null.</exception> + /// <exception cref="UserNotExistException">Thrown if the user with given username does not exist.</exception> + Task DeleteUser(string username); /// <summary> /// Try to change a user's password with old password. @@ -150,90 +126,126 @@ namespace Timeline.Services /// <param name="username">The name of user to change password of.</param> /// <param name="oldPassword">The user's old password.</param> /// <param name="newPassword">The user's new password.</param> - /// <returns><see cref="ChangePasswordResult.Success"/> if success. - /// <see cref="ChangePasswordResult.NotExists"/> if user does not exist. - /// <see cref="ChangePasswordResult.BadOldPassword"/> if old password is wrong.</returns> - Task<ChangePasswordResult> ChangePassword(string username, string oldPassword, string newPassword); + /// <exception cref="ArgumentNullException">Thrown if <paramref name="username"/> or <paramref name="oldPassword"/> or <paramref name="newPassword"/> is null.</exception> + /// <exception cref="UserNotExistException">Thrown if the user with given username does not exist.</exception> + /// <exception cref="BadPasswordException">Thrown if the old password is wrong.</exception> + Task ChangePassword(string username, string oldPassword, string newPassword); + } - /// <summary> - /// Get the true avatar url of a user. - /// </summary> - /// <param name="username">The name of user.</param> - /// <returns>The url if user exists. Null if user does not exist.</returns> - Task<string> GetAvatarUrl(string username); + internal class UserCache + { + public string Username { get; set; } + public bool IsAdmin { get; set; } + public long Version { get; set; } - /// <summary> - /// Put a avatar of a user. - /// </summary> - /// <param name="username">The name of user.</param> - /// <param name="data">The data of avatar image.</param> - /// <param name="mimeType">The mime type of the image.</param> - /// <returns>Return <see cref="PutAvatarResult.Success"/> if success. - /// Return <see cref="PutAvatarResult.UserNotExists"/> if user does not exist.</returns> - Task<PutAvatarResult> PutAvatar(string username, byte[] data, string mimeType); + public UserInfo ToUserInfo() + { + return new UserInfo(Username, IsAdmin); + } } public class UserService : IUserService { private readonly ILogger<UserService> _logger; + + private readonly IMemoryCache _memoryCache; private readonly DatabaseContext _databaseContext; + private readonly IJwtService _jwtService; private readonly IPasswordService _passwordService; - private readonly IQCloudCosService _cosService; - public UserService(ILogger<UserService> logger, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService, IQCloudCosService cosService) + public UserService(ILogger<UserService> logger, IMemoryCache memoryCache, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService) { _logger = logger; + _memoryCache = memoryCache; _databaseContext = databaseContext; _jwtService = jwtService; _passwordService = passwordService; - _cosService = cosService; + } + + private string GenerateCacheKeyByUserId(long id) => $"user:{id}"; + + private void RemoveCache(long id) + { + _memoryCache.Remove(GenerateCacheKeyByUserId(id)); } public async Task<CreateTokenResult> CreateToken(string username, string password) { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + + // We need password info, so always check the database. var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) { - _logger.LogInformation($"Create token failed with invalid username. Username = {username} Password = {password} ."); - return null; + var e = new UserNotExistException(); + _logger.LogInformation(e, $"Create token failed. Reason: invalid username. Username = {username} Password = {password} ."); + throw e; } - var verifyResult = _passwordService.VerifyPassword(user.EncryptedPassword, password); - - if (verifyResult) + if (!_passwordService.VerifyPassword(user.EncryptedPassword, password)) { - var roles = RoleStringToRoleArray(user.RoleString); - var token = _jwtService.GenerateJwtToken(new TokenInfo - { - Name = username, - Roles = roles - }); - return new CreateTokenResult - { - Token = token, - UserInfo = new UserInfo(username, RoleArrayToIsAdmin(roles)) - }; + var e = new BadPasswordException(); + _logger.LogInformation(e, $"Create token failed. Reason: invalid password. Username = {username} Password = {password} ."); + throw e; } - else + + var token = _jwtService.GenerateJwtToken(new TokenInfo { - _logger.LogInformation($"Create token failed with invalid password. Username = {username} Password = {password} ."); - return null; - } + Id = user.Id, + Version = user.Version + }); + return new CreateTokenResult + { + Token = token, + User = CreateUserInfo(user) + }; } public async Task<UserInfo> VerifyToken(string token) { - var tokenInfo = _jwtService.VerifyJwtToken(token); + TokenInfo tokenInfo; + try + { + tokenInfo = _jwtService.VerifyJwtToken(token); + } + catch (JwtTokenVerifyException e) + { + _logger.LogInformation(e, $"Verify token falied. Reason: invalid token. Token: {token} ."); + throw e; + } - if (tokenInfo == null) + var id = tokenInfo.Id; + var key = GenerateCacheKeyByUserId(id); + if (!_memoryCache.TryGetValue<UserCache>(key, out var cache)) { - _logger.LogInformation($"Verify token falied. Reason: invalid token. Token: {token} ."); - return null; + // no cache, check the database + var user = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); + + if (user == null) + { + var e = new UserNotExistException(); + _logger.LogInformation(e, $"Verify token falied. Reason: invalid id. Token: {token} Id: {id}."); + throw e; + } + + // create cache + cache = CreateUserCache(user); + _memoryCache.CreateEntry(key).SetValue(cache); } - return await Task.FromResult(new UserInfo(tokenInfo.Name, RoleArrayToIsAdmin(tokenInfo.Roles))); + if (tokenInfo.Version != cache.Version) + { + var e = new BadTokenVersionException(); + _logger.LogInformation(e, $"Verify token falied. Reason: invalid version. Token: {token} Id: {id} Username: {cache.Username} Version: {tokenInfo.Version} Version in cache: {cache.Version}."); + throw e; + } + + return cache.ToUserInfo(); } public async Task<UserInfo> GetUser(string username) @@ -251,8 +263,13 @@ namespace Timeline.Services .ToArrayAsync(); } - public async Task<PutUserResult> PutUser(string username, string password, bool isAdmin) + public async Task<PutResult> PutUser(string username, string password, bool isAdmin) { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) @@ -261,25 +278,32 @@ namespace Timeline.Services { Name = username, EncryptedPassword = _passwordService.HashPassword(password), - RoleString = IsAdminToRoleString(isAdmin) + RoleString = IsAdminToRoleString(isAdmin), + Version = 0 }); await _databaseContext.SaveChangesAsync(); - return PutUserResult.Created; + return PutResult.Created; } user.EncryptedPassword = _passwordService.HashPassword(password); user.RoleString = IsAdminToRoleString(isAdmin); + user.Version += 1; await _databaseContext.SaveChangesAsync(); - return PutUserResult.Modified; + //clear cache + RemoveCache(user.Id); + + return PutResult.Modified; } - public async Task<PatchUserResult> PatchUser(string username, string password, bool? isAdmin) + public async Task PatchUser(string username, string password, bool? isAdmin) { - var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (username == null) + throw new ArgumentNullException(nameof(username)); + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) - return PatchUserResult.NotExists; + throw new UserNotExistException(); bool modified = false; @@ -297,70 +321,50 @@ namespace Timeline.Services if (modified) { + user.Version += 1; await _databaseContext.SaveChangesAsync(); + //clear cache + RemoveCache(user.Id); } - - return PatchUserResult.Success; } - public async Task<DeleteUserResult> DeleteUser(string username) + public async Task DeleteUser(string username) { - var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (username == null) + throw new ArgumentNullException(nameof(username)); + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) - { - return DeleteUserResult.NotExists; - } + throw new UserNotExistException(); _databaseContext.Users.Remove(user); await _databaseContext.SaveChangesAsync(); - return DeleteUserResult.Deleted; + //clear cache + RemoveCache(user.Id); } - public async Task<ChangePasswordResult> ChangePassword(string username, string oldPassword, string newPassword) + public async Task ChangePassword(string username, string oldPassword, string newPassword) { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (oldPassword == null) + throw new ArgumentNullException(nameof(oldPassword)); + if (newPassword == null) + throw new ArgumentNullException(nameof(newPassword)); + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) - return ChangePasswordResult.NotExists; + throw new UserNotExistException(); var verifyResult = _passwordService.VerifyPassword(user.EncryptedPassword, oldPassword); if (!verifyResult) - return ChangePasswordResult.BadOldPassword; + throw new BadPasswordException(); user.EncryptedPassword = _passwordService.HashPassword(newPassword); + user.Version += 1; await _databaseContext.SaveChangesAsync(); - return ChangePasswordResult.Success; - } - - public async Task<string> GetAvatarUrl(string username) - { - if (username == null) - throw new ArgumentNullException(nameof(username)); - - if ((await GetUser(username)) == null) - return null; - - var exists = await _cosService.IsObjectExists("avatar", username); - if (exists) - return _cosService.GenerateObjectGetUrl("avatar", username); - else - return _cosService.GenerateObjectGetUrl("avatar", "__default"); - } - - public async Task<PutAvatarResult> PutAvatar(string username, byte[] data, string mimeType) - { - if (username == null) - throw new ArgumentNullException(nameof(username)); - if (data == null) - throw new ArgumentNullException(nameof(data)); - if (mimeType == null) - throw new ArgumentNullException(nameof(mimeType)); - - if ((await GetUser(username)) == null) - return PutAvatarResult.UserNotExists; - - await _cosService.PutObject("avatar", username, data, mimeType); - return PutAvatarResult.Success; + //clear cache + RemoveCache(user.Id); } } } diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index acabe55c..a6965190 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpOverrides; @@ -7,11 +6,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; -using System.Text; -using System.Threading.Tasks; +using Timeline.Authenticate; using Timeline.Configs; -using Timeline.Formatters; using Timeline.Models; using Timeline.Services; @@ -33,10 +29,7 @@ namespace Timeline // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddMvc(options => - { - options.InputFormatters.Add(new StringInputFormatter()); - }).SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); services.AddCors(options => { @@ -52,29 +45,8 @@ namespace Timeline services.Configure<JwtConfig>(Configuration.GetSection(nameof(JwtConfig))); var jwtConfig = Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>(); - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(o => - { - o.Events = new JwtBearerEvents - { - OnMessageReceived = delegate (MessageReceivedContext context) - { - context.Request.Query.TryGetValue("token", out var value); - if (value.Count == 1) - { - context.Token = value[0]; - } - return Task.CompletedTask; - } - }; - o.TokenValidationParameters.ValidateIssuer = true; - o.TokenValidationParameters.ValidateAudience = true; - o.TokenValidationParameters.ValidateIssuerSigningKey = true; - o.TokenValidationParameters.ValidateLifetime = true; - o.TokenValidationParameters.ValidIssuer = jwtConfig.Issuer; - o.TokenValidationParameters.ValidAudience = jwtConfig.Audience; - o.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.SigningKey)); - }); + services.AddAuthentication(AuthConstants.Scheme) + .AddScheme<AuthOptions, AuthHandler>(AuthConstants.Scheme, AuthConstants.DisplayName, o => { }); services.AddScoped<IUserService, UserService>(); services.AddScoped<IJwtService, JwtService>(); @@ -96,8 +68,7 @@ namespace Timeline services.AddHttpClient(); - services.Configure<QCloudCosConfig>(Configuration.GetSection(nameof(QCloudCosConfig))); - services.AddSingleton<IQCloudCosService, QCloudCosService>(); + services.AddMemoryCache(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. |