From ec7dfb73ace61a1aba5156cc1048cbe32ee1cee6 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 21 Oct 2019 20:47:31 +0800 Subject: ... --- Timeline/Resources/Services/Exception.Designer.cs | 189 ++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 Timeline/Resources/Services/Exception.Designer.cs (limited to 'Timeline/Resources/Services/Exception.Designer.cs') diff --git a/Timeline/Resources/Services/Exception.Designer.cs b/Timeline/Resources/Services/Exception.Designer.cs new file mode 100644 index 00000000..15a8169e --- /dev/null +++ b/Timeline/Resources/Services/Exception.Designer.cs @@ -0,0 +1,189 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Resources.Services { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Exception { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Exception() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Resources.Services.Exception", typeof(Exception).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The password is wrong.. + /// + internal static string BadPasswordException { + get { + return ResourceManager.GetString("BadPasswordException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The version of the jwt token is old.. + /// + internal static string JwtBadVersionException { + get { + return ResourceManager.GetString("JwtBadVersionException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The token didn't pass verification because {0}, see inner exception for information.. + /// + internal static string JwtVerifyException { + get { + return ResourceManager.GetString("JwtVerifyException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to token is expired.. + /// + internal static string JwtVerifyExceptionExpired { + get { + return ResourceManager.GetString("JwtVerifyExceptionExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to id claim is not a number.. + /// + internal static string JwtVerifyExceptionIdClaimBadFormat { + get { + return ResourceManager.GetString("JwtVerifyExceptionIdClaimBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to id claim does not exist.. + /// + internal static string JwtVerifyExceptionNoIdClaim { + get { + return ResourceManager.GetString("JwtVerifyExceptionNoIdClaim", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to version claim does not exist.. + /// + internal static string JwtVerifyExceptionNoVersionClaim { + get { + return ResourceManager.GetString("JwtVerifyExceptionNoVersionClaim", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to version of token is old.. + /// + internal static string JwtVerifyExceptionOldVersion { + get { + return ResourceManager.GetString("JwtVerifyExceptionOldVersion", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to uncommon error.. + /// + internal static string JwtVerifyExceptionOthers { + get { + return ResourceManager.GetString("JwtVerifyExceptionOthers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to unknown error code.. + /// + internal static string JwtVerifyExceptionUnknown { + get { + return ResourceManager.GetString("JwtVerifyExceptionUnknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to version claim is not a number.. + /// + internal static string JwtVerifyExceptionVersionClaimBadFormat { + get { + return ResourceManager.GetString("JwtVerifyExceptionVersionClaimBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The username is of bad format.. + /// + internal static string UsernameBadFormatException { + get { + return ResourceManager.GetString("UsernameBadFormatException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The username already exists.. + /// + internal static string UsernameConfictException { + get { + return ResourceManager.GetString("UsernameConfictException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user does not exist.. + /// + internal static string UserNotExistException { + get { + return ResourceManager.GetString("UserNotExistException", resourceCulture); + } + } + } +} -- cgit v1.2.3 From 9a163719b76958374d1c27616393368e54e8b8a5 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Wed, 23 Oct 2019 20:41:19 +0800 Subject: ... --- Timeline.Tests/Controllers/UserControllerTest.cs | 17 -- Timeline.Tests/DatabaseTest.cs | 16 -- .../IntegratedTests/AuthorizationTest.cs | 69 +++++ .../IntegratedTests/AuthorizationUnitTest.cs | 69 ----- Timeline.Tests/IntegratedTests/TokenTest.cs | 176 +++++++++++++ Timeline.Tests/IntegratedTests/TokenUnitTest.cs | 176 ------------- Timeline.Tests/IntegratedTests/UserTest.cs | 277 +++++++++++++++++++++ Timeline.Tests/IntegratedTests/UserUnitTest.cs | 264 -------------------- Timeline.Tests/Mock/Data/TestUsers.cs | 2 +- Timeline/Authenticate/Attribute.cs | 21 -- Timeline/Authenticate/AuthHandler.cs | 102 -------- Timeline/Authenticate/PrincipalExtensions.cs | 13 - Timeline/Authentication/Attribute.cs | 21 ++ Timeline/Authentication/AuthHandler.cs | 98 ++++++++ Timeline/Authentication/PrincipalExtensions.cs | 13 + Timeline/Configs/DatabaseConfig.cs | 2 +- Timeline/Configs/JwtConfig.cs | 6 +- .../Controllers/Testing/TestingAuthController.cs | 2 +- Timeline/Controllers/UserAvatarController.cs | 6 +- Timeline/Controllers/UserController.cs | 44 ++-- Timeline/Entities/DatabaseContext.cs | 37 +-- Timeline/Entities/User.cs | 32 +++ Timeline/Entities/UserDetail.cs | 29 --- Timeline/GlobalSuppressions.cs | 1 + .../Helpers/StringLocalizerFactoryExtensions.cs | 5 + Timeline/Models/Http/User.cs | 4 +- Timeline/Models/UserConvert.cs | 67 +++++ Timeline/Models/UserInfo.cs | 4 +- Timeline/Models/UserUtility.cs | 60 ----- Timeline/Models/Validation/UsernameValidator.cs | 14 +- Timeline/Models/Validation/Validator.cs | 2 +- Timeline/Program.cs | 1 + .../Authentication/AuthHandler.Designer.cs | 99 ++++++++ Timeline/Resources/Authentication/AuthHandler.resx | 132 ++++++++++ Timeline/Resources/Services/Exception.Designer.cs | 63 +++++ Timeline/Resources/Services/Exception.resx | 21 ++ .../Resources/Services/UserService.Designer.cs | 126 ++++++++++ Timeline/Resources/Services/UserService.resx | 141 +++++++++++ Timeline/Services/PasswordService.cs | 38 +-- Timeline/Services/UserService.cs | 95 ++++--- Timeline/Startup.cs | 2 +- Timeline/Timeline.csproj | 18 ++ 42 files changed, 1488 insertions(+), 897 deletions(-) create mode 100644 Timeline.Tests/IntegratedTests/AuthorizationTest.cs delete mode 100644 Timeline.Tests/IntegratedTests/AuthorizationUnitTest.cs create mode 100644 Timeline.Tests/IntegratedTests/TokenTest.cs delete mode 100644 Timeline.Tests/IntegratedTests/TokenUnitTest.cs create mode 100644 Timeline.Tests/IntegratedTests/UserTest.cs delete mode 100644 Timeline.Tests/IntegratedTests/UserUnitTest.cs delete mode 100644 Timeline/Authenticate/Attribute.cs delete mode 100644 Timeline/Authenticate/AuthHandler.cs delete mode 100644 Timeline/Authenticate/PrincipalExtensions.cs create mode 100644 Timeline/Authentication/Attribute.cs create mode 100644 Timeline/Authentication/AuthHandler.cs create mode 100644 Timeline/Authentication/PrincipalExtensions.cs create mode 100644 Timeline/Entities/User.cs delete mode 100644 Timeline/Entities/UserDetail.cs create mode 100644 Timeline/Models/UserConvert.cs delete mode 100644 Timeline/Models/UserUtility.cs create mode 100644 Timeline/Resources/Authentication/AuthHandler.Designer.cs create mode 100644 Timeline/Resources/Authentication/AuthHandler.resx create mode 100644 Timeline/Resources/Services/UserService.Designer.cs create mode 100644 Timeline/Resources/Services/UserService.resx (limited to 'Timeline/Resources/Services/Exception.Designer.cs') diff --git a/Timeline.Tests/Controllers/UserControllerTest.cs b/Timeline.Tests/Controllers/UserControllerTest.cs index 471ed851..781ec111 100644 --- a/Timeline.Tests/Controllers/UserControllerTest.cs +++ b/Timeline.Tests/Controllers/UserControllerTest.cs @@ -89,23 +89,6 @@ namespace Timeline.Tests.Controllers response.Data.Create.Should().Be(create); } - [Fact] - public async Task Put_BadUsername() - { - const string username = "aaa"; - const string password = "ppp"; - const bool administrator = true; - _mockUserService.Setup(s => s.PutUser(username, password, administrator)).ThrowsAsync(new UsernameBadFormatException()); - var action = await _controller.Put(new UserPutRequest - { - Password = password, - Administrator = administrator - }, username); - action.Result.Should().BeAssignableTo() - .Which.Value.Should().BeAssignableTo() - .Which.Code.Should().Be(Put.BadUsername); - } - [Fact] public async Task Patch_Success() { diff --git a/Timeline.Tests/DatabaseTest.cs b/Timeline.Tests/DatabaseTest.cs index f75ab71b..c45c0f66 100644 --- a/Timeline.Tests/DatabaseTest.cs +++ b/Timeline.Tests/DatabaseTest.cs @@ -32,21 +32,5 @@ namespace Timeline.Tests _context.SaveChanges(); _context.UserAvatars.Count().Should().Be(1); } - - [Fact] - public void DeleteUserShouldAlsoDeleteDetail() - { - var user = _context.Users.First(); - _context.UserDetails.Add(new UserDetailEntity - { - UserId = user.Id - }); - _context.SaveChanges(); - _context.UserDetails.Count().Should().Be(1); - - _context.Users.Remove(user); - _context.SaveChanges(); - _context.UserDetails.Count().Should().Be(0); - } } } diff --git a/Timeline.Tests/IntegratedTests/AuthorizationTest.cs b/Timeline.Tests/IntegratedTests/AuthorizationTest.cs new file mode 100644 index 00000000..a31d98f5 --- /dev/null +++ b/Timeline.Tests/IntegratedTests/AuthorizationTest.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System; +using System.Net; +using System.Threading.Tasks; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Xunit; + +namespace Timeline.Tests.IntegratedTests +{ + public class AuthorizationTest : IClassFixture>, IDisposable + { + private readonly TestApplication _testApp; + private readonly WebApplicationFactory _factory; + + public AuthorizationTest(WebApplicationFactory factory) + { + _testApp = new TestApplication(factory); + _factory = _testApp.Factory; + } + + public void Dispose() + { + _testApp.Dispose(); + } + + private const string BaseUrl = "testing/auth/"; + private const string AuthorizeUrl = BaseUrl + "Authorize"; + private const string UserUrl = BaseUrl + "User"; + private const string AdminUrl = BaseUrl + "Admin"; + + [Fact] + public async Task UnauthenticationTest() + { + using var client = _factory.CreateDefaultClient(); + var response = await client.GetAsync(AuthorizeUrl); + response.Should().HaveStatusCode(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task AuthenticationTest() + { + using var client = await _factory.CreateClientAsUser(); + var response = await client.GetAsync(AuthorizeUrl); + response.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task UserAuthorizationTest() + { + using var client = await _factory.CreateClientAsUser(); + var response1 = await client.GetAsync(UserUrl); + response1.Should().HaveStatusCode(HttpStatusCode.OK); + var response2 = await client.GetAsync(AdminUrl); + response2.Should().HaveStatusCode(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task AdminAuthorizationTest() + { + using var client = await _factory.CreateClientAsAdmin(); + var response1 = await client.GetAsync(UserUrl); + response1.Should().HaveStatusCode(HttpStatusCode.OK); + var response2 = await client.GetAsync(AdminUrl); + response2.Should().HaveStatusCode(HttpStatusCode.OK); + } + } +} diff --git a/Timeline.Tests/IntegratedTests/AuthorizationUnitTest.cs b/Timeline.Tests/IntegratedTests/AuthorizationUnitTest.cs deleted file mode 100644 index 588e4349..00000000 --- a/Timeline.Tests/IntegratedTests/AuthorizationUnitTest.cs +++ /dev/null @@ -1,69 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; -using System; -using System.Net; -using System.Threading.Tasks; -using Timeline.Tests.Helpers; -using Timeline.Tests.Helpers.Authentication; -using Xunit; - -namespace Timeline.Tests.IntegratedTests -{ - public class AuthorizationUnitTest : IClassFixture>, IDisposable - { - private readonly TestApplication _testApp; - private readonly WebApplicationFactory _factory; - - public AuthorizationUnitTest(WebApplicationFactory factory) - { - _testApp = new TestApplication(factory); - _factory = _testApp.Factory; - } - - public void Dispose() - { - _testApp.Dispose(); - } - - private const string BaseUrl = "testing/auth/"; - private const string AuthorizeUrl = BaseUrl + "Authorize"; - private const string UserUrl = BaseUrl + "User"; - private const string AdminUrl = BaseUrl + "Admin"; - - [Fact] - public async Task UnauthenticationTest() - { - using var client = _factory.CreateDefaultClient(); - var response = await client.GetAsync(AuthorizeUrl); - response.Should().HaveStatusCode(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task AuthenticationTest() - { - using var client = await _factory.CreateClientAsUser(); - var response = await client.GetAsync(AuthorizeUrl); - response.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task UserAuthorizationTest() - { - using var client = await _factory.CreateClientAsUser(); - var response1 = await client.GetAsync(UserUrl); - response1.Should().HaveStatusCode(HttpStatusCode.OK); - var response2 = await client.GetAsync(AdminUrl); - response2.Should().HaveStatusCode(HttpStatusCode.Forbidden); - } - - [Fact] - public async Task AdminAuthorizationTest() - { - using var client = await _factory.CreateClientAsAdmin(); - var response1 = await client.GetAsync(UserUrl); - response1.Should().HaveStatusCode(HttpStatusCode.OK); - var response2 = await client.GetAsync(AdminUrl); - response2.Should().HaveStatusCode(HttpStatusCode.OK); - } - } -} diff --git a/Timeline.Tests/IntegratedTests/TokenTest.cs b/Timeline.Tests/IntegratedTests/TokenTest.cs new file mode 100644 index 00000000..e9b6e1e9 --- /dev/null +++ b/Timeline.Tests/IntegratedTests/TokenTest.cs @@ -0,0 +1,176 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Timeline.Models.Http; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Timeline.Tests.Mock.Data; +using Xunit; +using static Timeline.ErrorCodes.Http.Token; + +namespace Timeline.Tests.IntegratedTests +{ + public class TokenTest : IClassFixture>, IDisposable + { + private const string CreateTokenUrl = "token/create"; + private const string VerifyTokenUrl = "token/verify"; + + private readonly TestApplication _testApp; + private readonly WebApplicationFactory _factory; + + public TokenTest(WebApplicationFactory factory) + { + _testApp = new TestApplication(factory); + _factory = _testApp.Factory; + } + + public void Dispose() + { + _testApp.Dispose(); + } + + public static IEnumerable CreateToken_InvalidModel_Data() + { + yield return new[] { null, "p", null }; + yield return new[] { "u", null, null }; + yield return new object[] { "u", "p", 2000 }; + yield return new object[] { "u", "p", -1 }; + } + + [Theory] + [MemberData(nameof(CreateToken_InvalidModel_Data))] + public async Task CreateToken_InvalidModel(string username, string password, int expire) + { + using var client = _factory.CreateDefaultClient(); + (await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest + { + Username = username, + Password = password, + Expire = expire + })).Should().BeInvalidModel(); + } + + public static IEnumerable CreateToken_UserCredential_Data() + { + yield return new[] { "usernotexist", "p" }; + yield return new[] { MockUser.User.Username, "???" }; + } + + [Theory] + [MemberData(nameof(CreateToken_UserCredential_Data))] + public async void CreateToken_UserCredential(string username, string password) + { + using var client = _factory.CreateDefaultClient(); + var response = await client.PostAsJsonAsync(CreateTokenUrl, + new CreateTokenRequest { Username = username, Password = password }); + response.Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Create.BadCredential); + } + + [Fact] + public async Task CreateToken_Success() + { + using var client = _factory.CreateDefaultClient(); + var response = await client.PostAsJsonAsync(CreateTokenUrl, + new CreateTokenRequest { Username = MockUser.User.Username, Password = MockUser.User.Password }); + var body = response.Should().HaveStatusCode(200) + .And.Should().HaveJsonBody().Which; + body.Token.Should().NotBeNullOrWhiteSpace(); + body.User.Should().BeEquivalentTo(MockUser.User.Info); + } + + [Fact] + public async Task VerifyToken_InvalidModel() + { + using var client = _factory.CreateDefaultClient(); + (await client.PostAsJsonAsync(VerifyTokenUrl, + new VerifyTokenRequest { Token = null })).Should().BeInvalidModel(); + } + + [Fact] + public async Task VerifyToken_BadFormat() + { + using var client = _factory.CreateDefaultClient(); + var response = await client.PostAsJsonAsync(VerifyTokenUrl, + new VerifyTokenRequest { Token = "bad token hahaha" }); + response.Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Verify.BadFormat); + } + + [Fact] + public async Task VerifyToken_OldVersion() + { + using var client = _factory.CreateDefaultClient(); + var token = (await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password)).Token; + + using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped. + { + // create a user for test + var userService = scope.ServiceProvider.GetRequiredService(); + await userService.PatchUser(MockUser.User.Username, null, null); + } + + (await client.PostAsJsonAsync(VerifyTokenUrl, + new VerifyTokenRequest { Token = token })) + .Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Verify.OldVersion); + } + + [Fact] + public async Task VerifyToken_UserNotExist() + { + using var client = _factory.CreateDefaultClient(); + var token = (await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password)).Token; + + using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped. + { + var userService = scope.ServiceProvider.GetRequiredService(); + await userService.DeleteUser(MockUser.User.Username); + } + + (await client.PostAsJsonAsync(VerifyTokenUrl, + new VerifyTokenRequest { Token = token })) + .Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Verify.UserNotExist); + } + + //[Fact] + //public async Task VerifyToken_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(MockUsers.UserUsername, MockUsers.UserPassword, 1)).Token; + // var response = await client.PostAsJsonAsync(VerifyTokenUrl, + // new VerifyTokenRequest { Token = token }); + // response.Should().HaveStatusCodeBadRequest() + // .And.Should().HaveBodyAsCommonResponseWithCode(TokenController.ErrorCodes.Verify_Expired); + // mockClock.MockCurrentTime = null; + // } + //} + + [Fact] + public async Task VerifyToken_Success() + { + using var client = _factory.CreateDefaultClient(); + var createTokenResult = await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password); + var response = await client.PostAsJsonAsync(VerifyTokenUrl, + new VerifyTokenRequest { Token = createTokenResult.Token }); + response.Should().HaveStatusCode(200) + .And.Should().HaveJsonBody() + .Which.User.Should().BeEquivalentTo(MockUser.User.Info); + } + } +} diff --git a/Timeline.Tests/IntegratedTests/TokenUnitTest.cs b/Timeline.Tests/IntegratedTests/TokenUnitTest.cs deleted file mode 100644 index d30b9311..00000000 --- a/Timeline.Tests/IntegratedTests/TokenUnitTest.cs +++ /dev/null @@ -1,176 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; -using Timeline.Models.Http; -using Timeline.Services; -using Timeline.Tests.Helpers; -using Timeline.Tests.Helpers.Authentication; -using Timeline.Tests.Mock.Data; -using Xunit; -using static Timeline.ErrorCodes.Http.Token; - -namespace Timeline.Tests.IntegratedTests -{ - public class TokenUnitTest : IClassFixture>, IDisposable - { - private const string CreateTokenUrl = "token/create"; - private const string VerifyTokenUrl = "token/verify"; - - private readonly TestApplication _testApp; - private readonly WebApplicationFactory _factory; - - public TokenUnitTest(WebApplicationFactory factory) - { - _testApp = new TestApplication(factory); - _factory = _testApp.Factory; - } - - public void Dispose() - { - _testApp.Dispose(); - } - - public static IEnumerable CreateToken_InvalidModel_Data() - { - yield return new[] { null, "p", null }; - yield return new[] { "u", null, null }; - yield return new object[] { "u", "p", 2000 }; - yield return new object[] { "u", "p", -1 }; - } - - [Theory] - [MemberData(nameof(CreateToken_InvalidModel_Data))] - public async Task CreateToken_InvalidModel(string username, string password, int expire) - { - using var client = _factory.CreateDefaultClient(); - (await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest - { - Username = username, - Password = password, - Expire = expire - })).Should().BeInvalidModel(); - } - - public static IEnumerable CreateToken_UserCredential_Data() - { - yield return new[] { "usernotexist", "p" }; - yield return new[] { MockUser.User.Username, "???" }; - } - - [Theory] - [MemberData(nameof(CreateToken_UserCredential_Data))] - public async void CreateToken_UserCredential(string username, string password) - { - using var client = _factory.CreateDefaultClient(); - var response = await client.PostAsJsonAsync(CreateTokenUrl, - new CreateTokenRequest { Username = username, Password = password }); - response.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Create.BadCredential); - } - - [Fact] - public async Task CreateToken_Success() - { - using var client = _factory.CreateDefaultClient(); - var response = await client.PostAsJsonAsync(CreateTokenUrl, - new CreateTokenRequest { Username = MockUser.User.Username, Password = MockUser.User.Password }); - var body = response.Should().HaveStatusCode(200) - .And.Should().HaveJsonBody().Which; - body.Token.Should().NotBeNullOrWhiteSpace(); - body.User.Should().BeEquivalentTo(MockUser.User.Info); - } - - [Fact] - public async Task VerifyToken_InvalidModel() - { - using var client = _factory.CreateDefaultClient(); - (await client.PostAsJsonAsync(VerifyTokenUrl, - new VerifyTokenRequest { Token = null })).Should().BeInvalidModel(); - } - - [Fact] - public async Task VerifyToken_BadFormat() - { - using var client = _factory.CreateDefaultClient(); - var response = await client.PostAsJsonAsync(VerifyTokenUrl, - new VerifyTokenRequest { Token = "bad token hahaha" }); - response.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Verify.BadFormat); - } - - [Fact] - public async Task VerifyToken_OldVersion() - { - using var client = _factory.CreateDefaultClient(); - var token = (await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password)).Token; - - using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped. - { - // create a user for test - var userService = scope.ServiceProvider.GetRequiredService(); - await userService.PatchUser(MockUser.User.Username, null, null); - } - - (await client.PostAsJsonAsync(VerifyTokenUrl, - new VerifyTokenRequest { Token = token })) - .Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Verify.OldVersion); - } - - [Fact] - public async Task VerifyToken_UserNotExist() - { - using var client = _factory.CreateDefaultClient(); - var token = (await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password)).Token; - - using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped. - { - var userService = scope.ServiceProvider.GetRequiredService(); - await userService.DeleteUser(MockUser.User.Username); - } - - (await client.PostAsJsonAsync(VerifyTokenUrl, - new VerifyTokenRequest { Token = token })) - .Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Verify.UserNotExist); - } - - //[Fact] - //public async Task VerifyToken_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(MockUsers.UserUsername, MockUsers.UserPassword, 1)).Token; - // var response = await client.PostAsJsonAsync(VerifyTokenUrl, - // new VerifyTokenRequest { Token = token }); - // response.Should().HaveStatusCodeBadRequest() - // .And.Should().HaveBodyAsCommonResponseWithCode(TokenController.ErrorCodes.Verify_Expired); - // mockClock.MockCurrentTime = null; - // } - //} - - [Fact] - public async Task VerifyToken_Success() - { - using var client = _factory.CreateDefaultClient(); - var createTokenResult = await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password); - var response = await client.PostAsJsonAsync(VerifyTokenUrl, - new VerifyTokenRequest { Token = createTokenResult.Token }); - response.Should().HaveStatusCode(200) - .And.Should().HaveJsonBody() - .Which.User.Should().BeEquivalentTo(MockUser.User.Info); - } - } -} diff --git a/Timeline.Tests/IntegratedTests/UserTest.cs b/Timeline.Tests/IntegratedTests/UserTest.cs new file mode 100644 index 00000000..ec70b7e8 --- /dev/null +++ b/Timeline.Tests/IntegratedTests/UserTest.cs @@ -0,0 +1,277 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Timeline.Models; +using Timeline.Models.Http; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Timeline.Tests.Mock.Data; +using Xunit; +using static Timeline.ErrorCodes.Http.User; + +namespace Timeline.Tests.IntegratedTests +{ + public class UserTest : IClassFixture>, IDisposable + { + private readonly TestApplication _testApp; + private readonly WebApplicationFactory _factory; + + public UserTest(WebApplicationFactory factory) + { + _testApp = new TestApplication(factory); + _factory = _testApp.Factory; + } + + public void Dispose() + { + _testApp.Dispose(); + } + + [Fact] + public async Task Get_List_Success() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.GetAsync("users"); + res.Should().HaveStatusCode(200) + .And.Should().HaveJsonBody() + .Which.Should().BeEquivalentTo(MockUser.UserInfoList); + } + + [Fact] + public async Task Get_Single_Success() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.GetAsync("users/" + MockUser.User.Username); + res.Should().HaveStatusCode(200) + .And.Should().HaveJsonBody() + .Which.Should().BeEquivalentTo(MockUser.User.Info); + } + + [Fact] + public async Task Get_InvalidModel() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.GetAsync("users/aaa!a"); + res.Should().BeInvalidModel(); + } + + [Fact] + public async Task Get_Users_404() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.GetAsync("users/usernotexist"); + res.Should().HaveStatusCode(404) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Get.NotExist); + } + + public static IEnumerable Put_InvalidModel_Data() + { + yield return new object[] { "aaa", null, false }; + yield return new object[] { "aaa", "p", null }; + yield return new object[] { "aa!a", "p", false }; + } + + [Theory] + [MemberData(nameof(Put_InvalidModel_Data))] + public async Task Put_InvalidModel(string username, string password, bool? administrator) + { + using var client = await _factory.CreateClientAsAdmin(); + (await client.PutAsJsonAsync("users/" + username, + new UserPutRequest { Password = password, Administrator = administrator })) + .Should().BeInvalidModel(); + } + + private async Task CheckAdministrator(HttpClient client, string username, bool administrator) + { + var res = await client.GetAsync("users/" + username); + res.Should().HaveStatusCode(200) + .And.Should().HaveJsonBody() + .Which.Administrator.Should().Be(administrator); + } + + [Fact] + public async Task Put_Modiefied() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.PutAsJsonAsync("users/" + MockUser.User.Username, new UserPutRequest + { + Password = "password", + Administrator = false + }); + res.Should().BePut(false); + await CheckAdministrator(client, MockUser.User.Username, false); + } + + [Fact] + public async Task Put_Created() + { + using var client = await _factory.CreateClientAsAdmin(); + const string username = "puttest"; + const string url = "users/" + username; + + var res = await client.PutAsJsonAsync(url, new UserPutRequest + { + Password = "password", + Administrator = false + }); + res.Should().BePut(true); + await CheckAdministrator(client, username, false); + } + + [Fact] + public async Task Patch_NotExist() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.PatchAsJsonAsync("users/usernotexist", new UserPatchRequest { }); + res.Should().HaveStatusCode(404) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Patch.NotExist); + } + + [Fact] + public async Task Patch_InvalidModel() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.PatchAsJsonAsync("users/aaa!a", new UserPatchRequest { }); + res.Should().BeInvalidModel(); + } + + [Fact] + public async Task Patch_Success() + { + using var client = await _factory.CreateClientAsAdmin(); + { + var res = await client.PatchAsJsonAsync("users/" + MockUser.User.Username, + new UserPatchRequest { Administrator = false }); + res.Should().HaveStatusCode(200); + await CheckAdministrator(client, MockUser.User.Username, false); + } + } + + [Fact] + public async Task Delete_InvalidModel() + { + using var client = await _factory.CreateClientAsAdmin(); + var url = "users/aaa!a"; + var res = await client.DeleteAsync(url); + res.Should().BeInvalidModel(); + } + + [Fact] + public async Task Delete_Deleted() + { + using var client = await _factory.CreateClientAsAdmin(); + var url = "users/" + MockUser.User.Username; + var res = await client.DeleteAsync(url); + res.Should().BeDelete(true); + + var res2 = await client.GetAsync(url); + res2.Should().HaveStatusCode(404); + } + + [Fact] + public async Task Delete_NotExist() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.DeleteAsync("users/usernotexist"); + res.Should().BeDelete(false); + } + + private const string changeUsernameUrl = "userop/changeusername"; + + public static IEnumerable Op_ChangeUsername_InvalidModel_Data() + { + yield return new[] { null, "uuu" }; + yield return new[] { "uuu", null }; + yield return new[] { "a!a", "uuu" }; + yield return new[] { "uuu", "a!a" }; + } + + [Theory] + [MemberData(nameof(Op_ChangeUsername_InvalidModel_Data))] + public async Task Op_ChangeUsername_InvalidModel(string oldUsername, string newUsername) + { + using var client = await _factory.CreateClientAsAdmin(); + (await client.PostAsJsonAsync(changeUsernameUrl, + new ChangeUsernameRequest { OldUsername = oldUsername, NewUsername = newUsername })) + .Should().BeInvalidModel(); + } + + [Fact] + public async Task Op_ChangeUsername_UserNotExist() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.PostAsJsonAsync(changeUsernameUrl, + new ChangeUsernameRequest { OldUsername = "usernotexist", NewUsername = "newUsername" }); + res.Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Op.ChangeUsername.NotExist); + } + + [Fact] + public async Task Op_ChangeUsername_UserAlreadyExist() + { + using var client = await _factory.CreateClientAsAdmin(); + var res = await client.PostAsJsonAsync(changeUsernameUrl, + new ChangeUsernameRequest { OldUsername = MockUser.User.Username, NewUsername = MockUser.Admin.Username }); + res.Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Op.ChangeUsername.AlreadyExist); + } + + [Fact] + public async Task Op_ChangeUsername_Success() + { + using var client = await _factory.CreateClientAsAdmin(); + const string newUsername = "hahaha"; + var res = await client.PostAsJsonAsync(changeUsernameUrl, + new ChangeUsernameRequest { OldUsername = MockUser.User.Username, NewUsername = newUsername }); + res.Should().HaveStatusCode(200); + await client.CreateUserTokenAsync(newUsername, MockUser.User.Password); + } + + private const string changePasswordUrl = "userop/changepassword"; + + public static IEnumerable Op_ChangePassword_InvalidModel_Data() + { + yield return new[] { null, "ppp" }; + yield return new[] { "ppp", null }; + } + + [Theory] + [MemberData(nameof(Op_ChangePassword_InvalidModel_Data))] + public async Task Op_ChangePassword_InvalidModel(string oldPassword, string newPassword) + { + using var client = await _factory.CreateClientAsUser(); + (await client.PostAsJsonAsync(changePasswordUrl, + new ChangePasswordRequest { OldPassword = oldPassword, NewPassword = newPassword })) + .Should().BeInvalidModel(); + } + + [Fact] + public async Task Op_ChangePassword_BadOldPassword() + { + using var client = await _factory.CreateClientAsUser(); + var res = await client.PostAsJsonAsync(changePasswordUrl, new ChangePasswordRequest { OldPassword = "???", NewPassword = "???" }); + res.Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Op.ChangePassword.BadOldPassword); + } + + [Fact] + public async Task Op_ChangePassword_Success() + { + using var client = await _factory.CreateClientAsUser(); + const string newPassword = "new"; + var res = await client.PostAsJsonAsync(changePasswordUrl, + new ChangePasswordRequest { OldPassword = MockUser.User.Password, NewPassword = newPassword }); + res.Should().HaveStatusCode(200); + await _factory.CreateDefaultClient() // don't use client above, because it sets authorization header + .CreateUserTokenAsync(MockUser.User.Username, newPassword); + } + } +} diff --git a/Timeline.Tests/IntegratedTests/UserUnitTest.cs b/Timeline.Tests/IntegratedTests/UserUnitTest.cs deleted file mode 100644 index b00648de..00000000 --- a/Timeline.Tests/IntegratedTests/UserUnitTest.cs +++ /dev/null @@ -1,264 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; -using Timeline.Models; -using Timeline.Models.Http; -using Timeline.Tests.Helpers; -using Timeline.Tests.Helpers.Authentication; -using Timeline.Tests.Mock.Data; -using Xunit; -using static Timeline.ErrorCodes.Http.User; - -namespace Timeline.Tests.IntegratedTests -{ - public class UserUnitTest : IClassFixture>, IDisposable - { - private readonly TestApplication _testApp; - private readonly WebApplicationFactory _factory; - - public UserUnitTest(WebApplicationFactory factory) - { - _testApp = new TestApplication(factory); - _factory = _testApp.Factory; - } - - public void Dispose() - { - _testApp.Dispose(); - } - - [Fact] - public async Task Get_Users_List() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.GetAsync("users"); - res.Should().HaveStatusCode(200) - .And.Should().HaveJsonBody() - .Which.Should().BeEquivalentTo(MockUser.UserInfoList); - } - - [Fact] - public async Task Get_Users_User() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.GetAsync("users/" + MockUser.User.Username); - res.Should().HaveStatusCode(200) - .And.Should().HaveJsonBody() - .Which.Should().BeEquivalentTo(MockUser.User.Info); - } - - [Fact] - public async Task Get_Users_404() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.GetAsync("users/usernotexist"); - res.Should().HaveStatusCode(404) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Get.NotExist); - } - - public static IEnumerable Put_InvalidModel_Data() - { - yield return new object[] { null, false }; - yield return new object[] { "p", null }; - } - - [Theory] - [MemberData(nameof(Put_InvalidModel_Data))] - public async Task Put_InvalidModel(string password, bool? administrator) - { - using var client = await _factory.CreateClientAsAdmin(); - const string url = "users/aaaaaaaa"; - (await client.PutAsJsonAsync(url, - new UserPutRequest { Password = password, Administrator = administrator })) - .Should().BeInvalidModel(); - } - - [Fact] - public async Task Put_BadUsername() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.PutAsJsonAsync("users/dsf fddf", new UserPutRequest - { - Password = "???", - Administrator = false - }); - res.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Put.BadUsername); - } - - private async Task CheckAdministrator(HttpClient client, string username, bool administrator) - { - var res = await client.GetAsync("users/" + username); - res.Should().HaveStatusCode(200) - .And.Should().HaveJsonBody() - .Which.Administrator.Should().Be(administrator); - } - - [Fact] - public async Task Put_Modiefied() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.PutAsJsonAsync("users/" + MockUser.User.Username, new UserPutRequest - { - Password = "password", - Administrator = false - }); - res.Should().BePut(false); - await CheckAdministrator(client, MockUser.User.Username, false); - } - - [Fact] - public async Task Put_Created() - { - using var client = await _factory.CreateClientAsAdmin(); - const string username = "puttest"; - const string url = "users/" + username; - - var res = await client.PutAsJsonAsync(url, new UserPutRequest - { - Password = "password", - Administrator = false - }); - res.Should().BePut(true); - await CheckAdministrator(client, username, false); - } - - [Fact] - public async Task Patch_NotExist() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.PatchAsJsonAsync("users/usernotexist", new UserPatchRequest { }); - res.Should().HaveStatusCode(404) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Patch.NotExist); - } - - [Fact] - public async Task Patch_Success() - { - using var client = await _factory.CreateClientAsAdmin(); - { - var res = await client.PatchAsJsonAsync("users/" + MockUser.User.Username, - new UserPatchRequest { Administrator = false }); - res.Should().HaveStatusCode(200); - await CheckAdministrator(client, MockUser.User.Username, false); - } - } - - [Fact] - public async Task Delete_Deleted() - { - using var client = await _factory.CreateClientAsAdmin(); - var url = "users/" + MockUser.User.Username; - var res = await client.DeleteAsync(url); - res.Should().BeDelete(true); - - var res2 = await client.GetAsync(url); - res2.Should().HaveStatusCode(404); - } - - [Fact] - public async Task Delete_NotExist() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.DeleteAsync("users/usernotexist"); - res.Should().BeDelete(false); - } - - private const string changeUsernameUrl = "userop/changeusername"; - - public static IEnumerable Op_ChangeUsername_InvalidModel_Data() - { - yield return new[] { null, "uuu" }; - yield return new[] { "uuu", null }; - yield return new[] { "uuu", "???" }; - } - - [Theory] - [MemberData(nameof(Op_ChangeUsername_InvalidModel_Data))] - public async Task Op_ChangeUsername_InvalidModel(string oldUsername, string newUsername) - { - using var client = await _factory.CreateClientAsAdmin(); - (await client.PostAsJsonAsync(changeUsernameUrl, - new ChangeUsernameRequest { OldUsername = oldUsername, NewUsername = newUsername })) - .Should().BeInvalidModel(); - } - - [Fact] - public async Task Op_ChangeUsername_UserNotExist() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.PostAsJsonAsync(changeUsernameUrl, - new ChangeUsernameRequest { OldUsername = "usernotexist", NewUsername = "newUsername" }); - res.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Op.ChangeUsername.NotExist); - } - - [Fact] - public async Task Op_ChangeUsername_UserAlreadyExist() - { - using var client = await _factory.CreateClientAsAdmin(); - var res = await client.PostAsJsonAsync(changeUsernameUrl, - new ChangeUsernameRequest { OldUsername = MockUser.User.Username, NewUsername = MockUser.Admin.Username }); - res.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Op.ChangeUsername.AlreadyExist); - } - - [Fact] - public async Task Op_ChangeUsername_Success() - { - using var client = await _factory.CreateClientAsAdmin(); - const string newUsername = "hahaha"; - var res = await client.PostAsJsonAsync(changeUsernameUrl, - new ChangeUsernameRequest { OldUsername = MockUser.User.Username, NewUsername = newUsername }); - res.Should().HaveStatusCode(200); - await client.CreateUserTokenAsync(newUsername, MockUser.User.Password); - } - - private const string changePasswordUrl = "userop/changepassword"; - - public static IEnumerable Op_ChangePassword_InvalidModel_Data() - { - yield return new[] { null, "ppp" }; - yield return new[] { "ppp", null }; - } - - [Theory] - [MemberData(nameof(Op_ChangePassword_InvalidModel_Data))] - public async Task Op_ChangePassword_InvalidModel(string oldPassword, string newPassword) - { - using var client = await _factory.CreateClientAsUser(); - (await client.PostAsJsonAsync(changePasswordUrl, - new ChangePasswordRequest { OldPassword = oldPassword, NewPassword = newPassword })) - .Should().BeInvalidModel(); - } - - [Fact] - public async Task Op_ChangePassword_BadOldPassword() - { - using var client = await _factory.CreateClientAsUser(); - var res = await client.PostAsJsonAsync(changePasswordUrl, new ChangePasswordRequest { OldPassword = "???", NewPassword = "???" }); - res.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(Op.ChangePassword.BadOldPassword); - } - - [Fact] - public async Task Op_ChangePassword_Success() - { - using var client = await _factory.CreateClientAsUser(); - const string newPassword = "new"; - var res = await client.PostAsJsonAsync(changePasswordUrl, - new ChangePasswordRequest { OldPassword = MockUser.User.Password, NewPassword = newPassword }); - res.Should().HaveStatusCode(200); - await client.CreateUserTokenAsync(MockUser.User.Username, newPassword); - } - } -} diff --git a/Timeline.Tests/Mock/Data/TestUsers.cs b/Timeline.Tests/Mock/Data/TestUsers.cs index bc2df469..6b0a9997 100644 --- a/Timeline.Tests/Mock/Data/TestUsers.cs +++ b/Timeline.Tests/Mock/Data/TestUsers.cs @@ -35,7 +35,7 @@ namespace Timeline.Tests.Mock.Data { Name = user.Username, EncryptedPassword = passwordService.HashPassword(user.Password), - RoleString = UserUtility.IsAdminToRoleString(user.Administrator), + RoleString = UserRoleConvert.ToString(user.Administrator), Avatar = UserAvatar.Create(DateTime.Now) }; } diff --git a/Timeline/Authenticate/Attribute.cs b/Timeline/Authenticate/Attribute.cs deleted file mode 100644 index 239a2a1c..00000000 --- a/Timeline/Authenticate/Attribute.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Timeline.Entities; - -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 deleted file mode 100644 index f9409c1a..00000000 --- a/Timeline/Authenticate/AuthHandler.cs +++ /dev/null @@ -1,102 +0,0 @@ -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.Models; -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 - { - /// - /// The query param key to search for token. If null then query params are not searched for token. Default to "token". - /// - public string TokenQueryParamKey { get; set; } = "token"; - } - - class AuthHandler : AuthenticationHandler - { - private readonly ILogger _logger; - private readonly IUserService _userService; - - public AuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IUserService userService) - : base(options, logger, encoder, clock) - { - _logger = logger.CreateLogger(); - _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 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(UserUtility.IsAdminToRoleArray(userInfo.Administrator).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 (ArgumentException) - { - throw; // this exception usually means server error. - } - catch (Exception e) - { - _logger.LogInformation(e, "A jwt token validation failed."); - return AuthenticateResult.Fail(e); - } - } - } -} diff --git a/Timeline/Authenticate/PrincipalExtensions.cs b/Timeline/Authenticate/PrincipalExtensions.cs deleted file mode 100644 index fa39ea89..00000000 --- a/Timeline/Authenticate/PrincipalExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Security.Principal; -using Timeline.Entities; - -namespace Timeline.Authenticate -{ - public static class PrincipalExtensions - { - public static bool IsAdmin(this IPrincipal principal) - { - return principal.IsInRole(UserRoles.Admin); - } - } -} diff --git a/Timeline/Authentication/Attribute.cs b/Timeline/Authentication/Attribute.cs new file mode 100644 index 00000000..370b37e1 --- /dev/null +++ b/Timeline/Authentication/Attribute.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authorization; +using Timeline.Entities; + +namespace Timeline.Authentication +{ + public class AdminAuthorizeAttribute : AuthorizeAttribute + { + public AdminAuthorizeAttribute() + { + Roles = UserRoles.Admin; + } + } + + public class UserAuthorizeAttribute : AuthorizeAttribute + { + public UserAuthorizeAttribute() + { + Roles = UserRoles.User; + } + } +} diff --git a/Timeline/Authentication/AuthHandler.cs b/Timeline/Authentication/AuthHandler.cs new file mode 100644 index 00000000..47ed1d71 --- /dev/null +++ b/Timeline/Authentication/AuthHandler.cs @@ -0,0 +1,98 @@ +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.Models; +using Timeline.Services; + +namespace Timeline.Authentication +{ + static class AuthConstants + { + public const string Scheme = "Bearer"; + public const string DisplayName = "My Jwt Auth Scheme"; + } + + public class AuthOptions : AuthenticationSchemeOptions + { + /// + /// The query param key to search for token. If null then query params are not searched for token. Default to "token". + /// + public string TokenQueryParamKey { get; set; } = "token"; + } + + public class AuthHandler : AuthenticationHandler + { + private readonly ILogger _logger; + private readonly IUserService _userService; + + public AuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IUserService userService) + : base(options, logger, encoder, clock) + { + _logger = logger.CreateLogger(); + _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.InvariantCultureIgnoreCase)) + { + var token = header.Substring("Bearer ".Length).Trim(); + _logger.LogInformation(Resources.Authentication.AuthHandler.LogTokenFoundInHeader, 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(Resources.Authentication.AuthHandler.LogTokenFoundInQuery, paramQueryKey, token); + return token; + } + } + + // not found anywhere then return null + return null; + } + + protected override async Task HandleAuthenticateAsync() + { + var token = ExtractToken(); + if (string.IsNullOrEmpty(token)) + { + _logger.LogInformation(Resources.Authentication.AuthHandler.LogTokenNotFound); + 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(UserRoleConvert.ToArray(userInfo.Administrator).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) when (e! is ArgumentException) + { + _logger.LogInformation(e, Resources.Authentication.AuthHandler.LogTokenValidationFail); + return AuthenticateResult.Fail(e); + } + } + } +} diff --git a/Timeline/Authentication/PrincipalExtensions.cs b/Timeline/Authentication/PrincipalExtensions.cs new file mode 100644 index 00000000..8d77ab62 --- /dev/null +++ b/Timeline/Authentication/PrincipalExtensions.cs @@ -0,0 +1,13 @@ +using System.Security.Principal; +using Timeline.Entities; + +namespace Timeline.Authentication +{ + internal static class PrincipalExtensions + { + internal static bool IsAdministrator(this IPrincipal principal) + { + return principal.IsInRole(UserRoles.Admin); + } + } +} diff --git a/Timeline/Configs/DatabaseConfig.cs b/Timeline/Configs/DatabaseConfig.cs index e24ecdfb..c9309b08 100644 --- a/Timeline/Configs/DatabaseConfig.cs +++ b/Timeline/Configs/DatabaseConfig.cs @@ -2,6 +2,6 @@ namespace Timeline.Configs { public class DatabaseConfig { - public string ConnectionString { get; set; } + public string ConnectionString { get; set; } = default!; } } diff --git a/Timeline/Configs/JwtConfig.cs b/Timeline/Configs/JwtConfig.cs index 8c61d7bc..8a17825e 100644 --- a/Timeline/Configs/JwtConfig.cs +++ b/Timeline/Configs/JwtConfig.cs @@ -2,9 +2,9 @@ namespace Timeline.Configs { public class JwtConfig { - public string Issuer { get; set; } - public string Audience { get; set; } - public string SigningKey { get; set; } + public string Issuer { get; set; } = default!; + public string Audience { get; set; } = default!; + public string SigningKey { get; set; } = default!; /// /// Set the default value of expire offset of jwt token. diff --git a/Timeline/Controllers/Testing/TestingAuthController.cs b/Timeline/Controllers/Testing/TestingAuthController.cs index 488a3cff..67b5b2ef 100644 --- a/Timeline/Controllers/Testing/TestingAuthController.cs +++ b/Timeline/Controllers/Testing/TestingAuthController.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Timeline.Authenticate; +using Timeline.Authentication; namespace Timeline.Controllers.Testing { diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index e77076ca..5cba1d93 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -6,7 +6,7 @@ using Microsoft.Net.Http.Headers; using System; using System.Linq; using System.Threading.Tasks; -using Timeline.Authenticate; +using Timeline.Authentication; using Timeline.Filters; using Timeline.Models.Http; using Timeline.Services; @@ -106,7 +106,7 @@ namespace Timeline.Controllers return BadRequest(new CommonResponse(ErrorCodes.Put_Content_TooBig, "Content can't be bigger than 10MB.")); - if (!User.IsAdmin() && User.Identity.Name != username) + if (!User.IsAdministrator() && User.Identity.Name != username) { _logger.LogInformation($"Attempt to put a avatar of other user as a non-admin failed. Operator Username: {User.Identity.Name} ; Username To Put Avatar: {username} ."); return StatusCode(StatusCodes.Status403Forbidden, @@ -152,7 +152,7 @@ namespace Timeline.Controllers [Authorize] public async Task Delete([FromRoute] string username) { - if (!User.IsAdmin() && User.Identity.Name != username) + if (!User.IsAdministrator() && User.Identity.Name != username) { _logger.LogInformation($"Attempt to delete a avatar of other user as a non-admin failed. Operator Username: {User.Identity.Name} ; Username To Put Avatar: {username} ."); return StatusCode(StatusCodes.Status403Forbidden, diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index b8d1d659..1771dc85 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -3,10 +3,11 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using System.Threading.Tasks; -using Timeline.Authenticate; +using Timeline.Authentication; using Timeline.Helpers; using Timeline.Models; using Timeline.Models.Http; +using Timeline.Models.Validation; using Timeline.Services; using static Timeline.Resources.Controllers.UserController; @@ -23,11 +24,6 @@ namespace Timeline public const int NotExist = 10020101; // dd = 01 } - public static class Put // cc = 02 - { - public const int BadUsername = 10020201; // dd = 01 - } - public static class Patch // cc = 03 { public const int NotExist = 10020301; // dd = 01 @@ -78,7 +74,7 @@ namespace Timeline.Controllers } [HttpGet("users/{username}"), AdminAuthorize] - public async Task> Get([FromRoute] string username) + public async Task> Get([FromRoute][Username] string username) { var user = await _userService.GetUser(username); if (user == null) @@ -90,32 +86,24 @@ namespace Timeline.Controllers } [HttpPut("users/{username}"), AdminAuthorize] - public async Task> Put([FromBody] UserPutRequest request, [FromRoute] string username) + public async Task> Put([FromBody] UserPutRequest request, [FromRoute][Username] string username) { - try - { - var result = await _userService.PutUser(username, request.Password, request.Administrator!.Value); - switch (result) - { - case PutResult.Create: - _logger.LogInformation(Log.Format(LogPutCreate, ("Username", username))); - return CreatedAtAction("Get", new { username }, CommonPutResponse.Create(_localizerFactory)); - case PutResult.Modify: - _logger.LogInformation(Log.Format(LogPutModify, ("Username", username))); - return Ok(CommonPutResponse.Modify(_localizerFactory)); - default: - throw new InvalidBranchException(); - } - } - catch (UsernameBadFormatException e) + var result = await _userService.PutUser(username, request.Password, request.Administrator!.Value); + switch (result) { - _logger.LogInformation(e, Log.Format(LogPutBadUsername, ("Username", username))); - return BadRequest(new CommonResponse(ErrorCodes.Http.User.Put.BadUsername, _localizer["ErrorPutBadUsername"])); + case PutResult.Create: + _logger.LogInformation(Log.Format(LogPutCreate, ("Username", username))); + return CreatedAtAction("Get", new { username }, CommonPutResponse.Create(_localizerFactory)); + case PutResult.Modify: + _logger.LogInformation(Log.Format(LogPutModify, ("Username", username))); + return Ok(CommonPutResponse.Modify(_localizerFactory)); + default: + throw new InvalidBranchException(); } } [HttpPatch("users/{username}"), AdminAuthorize] - public async Task Patch([FromBody] UserPatchRequest request, [FromRoute] string username) + public async Task Patch([FromBody] UserPatchRequest request, [FromRoute][Username] string username) { try { @@ -130,7 +118,7 @@ namespace Timeline.Controllers } [HttpDelete("users/{username}"), AdminAuthorize] - public async Task> Delete([FromRoute] string username) + public async Task> Delete([FromRoute][Username] string username) { try { diff --git a/Timeline/Entities/DatabaseContext.cs b/Timeline/Entities/DatabaseContext.cs index 550db216..e1b98e7d 100644 --- a/Timeline/Entities/DatabaseContext.cs +++ b/Timeline/Entities/DatabaseContext.cs @@ -1,38 +1,7 @@ using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; namespace Timeline.Entities { - public static class UserRoles - { - public const string Admin = "admin"; - public const string User = "user"; - } - - [Table("users")] - public class User - { - [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long Id { get; set; } - - [Column("name"), MaxLength(26), Required] - public string Name { get; set; } = default!; - - [Column("password"), Required] - public string EncryptedPassword { get; set; } = default!; - - [Column("roles"), Required] - public string RoleString { get; set; } = default!; - - [Column("version"), Required] - public long Version { get; set; } - - public UserAvatar? Avatar { get; set; } - - public UserDetailEntity? Detail { get; set; } - } - public class DatabaseContext : DbContext { public DatabaseContext(DbContextOptions options) @@ -41,14 +10,14 @@ namespace Timeline.Entities } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().Property(e => e.Version).HasDefaultValue(0); modelBuilder.Entity().HasIndex(e => e.Name).IsUnique(); } - public DbSet Users { get; set; } - public DbSet UserAvatars { get; set; } - public DbSet UserDetails { get; set; } + public DbSet Users { get; set; } = default!; + public DbSet UserAvatars { get; set; } = default!; } } diff --git a/Timeline/Entities/User.cs b/Timeline/Entities/User.cs new file mode 100644 index 00000000..6e8e4967 --- /dev/null +++ b/Timeline/Entities/User.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + public static class UserRoles + { + public const string Admin = "admin"; + public const string User = "user"; + } + + [Table("users")] + public class User + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("name"), MaxLength(26), Required] + public string Name { get; set; } = default!; + + [Column("password"), Required] + public string EncryptedPassword { get; set; } = default!; + + [Column("roles"), Required] + public string RoleString { get; set; } = default!; + + [Column("version"), Required] + public long Version { get; set; } + + public UserAvatar? Avatar { get; set; } + } +} diff --git a/Timeline/Entities/UserDetail.cs b/Timeline/Entities/UserDetail.cs deleted file mode 100644 index e02d15c4..00000000 --- a/Timeline/Entities/UserDetail.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Timeline.Entities -{ - [Table("user_details")] - public class UserDetailEntity - { - [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long Id { get; set; } - - [Column("nickname"), MaxLength(15)] - public string? Nickname { get; set; } - - [Column("qq"), MaxLength(15)] - public string? QQ { get; set; } - - [Column("email"), MaxLength(50)] - public string? Email { get; set; } - - [Column("phone_number"), MaxLength(15)] - public string? PhoneNumber { get; set; } - - [Column("description")] - public string? Description { get; set; } - - public long UserId { get; set; } - } -} diff --git a/Timeline/GlobalSuppressions.cs b/Timeline/GlobalSuppressions.cs index 6c89b230..44ad3af5 100644 --- a/Timeline/GlobalSuppressions.cs +++ b/Timeline/GlobalSuppressions.cs @@ -6,5 +6,6 @@ [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "This is not a UI application.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "This is not bad.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "No need to check the null because it's ASP.Net's duty.", Scope = "namespaceanddescendants", Target = "Timeline.Controllers")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "Migrations code are auto generated.", Scope = "namespaceanddescendants", Target = "Timeline.Migrations")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Error code constant identifiers.", Scope = "type", Target = "Timeline.ErrorCodes")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Error code constant identifiers.", Scope = "type", Target = "Timeline.ErrorCodes")] diff --git a/Timeline/Helpers/StringLocalizerFactoryExtensions.cs b/Timeline/Helpers/StringLocalizerFactoryExtensions.cs index 3cb561f5..c2252b2c 100644 --- a/Timeline/Helpers/StringLocalizerFactoryExtensions.cs +++ b/Timeline/Helpers/StringLocalizerFactoryExtensions.cs @@ -10,5 +10,10 @@ namespace Timeline.Helpers { return factory.Create(basename, new AssemblyName(typeof(StringLocalizerFactoryExtensions).Assembly.FullName!).Name); } + + internal static StringLocalizer Create(this IStringLocalizerFactory factory) + { + return new StringLocalizer(factory); + } } } \ No newline at end of file diff --git a/Timeline/Models/Http/User.cs b/Timeline/Models/Http/User.cs index 98406fec..516c1329 100644 --- a/Timeline/Models/Http/User.cs +++ b/Timeline/Models/Http/User.cs @@ -20,9 +20,11 @@ namespace Timeline.Models.Http public class ChangeUsernameRequest { [Required] + [Username] public string OldUsername { get; set; } = default!; - [Required, ValidateWith(typeof(UsernameValidator))] + [Required] + [Username] public string NewUsername { get; set; } = default!; } diff --git a/Timeline/Models/UserConvert.cs b/Timeline/Models/UserConvert.cs new file mode 100644 index 00000000..5b132421 --- /dev/null +++ b/Timeline/Models/UserConvert.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Timeline.Entities; +using Timeline.Services; + +namespace Timeline.Models +{ + public static class UserConvert + { + public static UserInfo CreateUserInfo(User user) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + return new UserInfo(user.Name, UserRoleConvert.ToBool(user.RoleString)); + } + + internal static UserCache CreateUserCache(User user) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + return new UserCache + { + Username = user.Name, + Administrator = UserRoleConvert.ToBool(user.RoleString), + Version = user.Version + }; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "No need.")] + public static class UserRoleConvert + { + public const string UserRole = UserRoles.User; + public const string AdminRole = UserRoles.Admin; + + public static string[] ToArray(bool administrator) + { + return administrator ? new string[] { UserRole, AdminRole } : new string[] { UserRole }; + } + + public static string[] ToArray(string s) + { + return s.Split(',').ToArray(); + } + + public static bool ToBool(IReadOnlyCollection roles) + { + return roles.Contains(AdminRole); + } + + public static string ToString(IReadOnlyCollection roles) + { + return string.Join(',', roles); + } + + public static string ToString(bool administrator) + { + return administrator ? UserRole + "," + AdminRole : UserRole; + } + + public static bool ToBool(string s) + { + return s.Contains("admin", StringComparison.InvariantCulture); + } + } +} diff --git a/Timeline/Models/UserInfo.cs b/Timeline/Models/UserInfo.cs index e502855b..b60bdfa2 100644 --- a/Timeline/Models/UserInfo.cs +++ b/Timeline/Models/UserInfo.cs @@ -12,8 +12,8 @@ namespace Timeline.Models Administrator = administrator; } - public string Username { get; set; } - public bool Administrator { get; set; } + public string Username { get; set; } = default!; + public bool Administrator { get; set; } = default!; public override string ToString() { diff --git a/Timeline/Models/UserUtility.cs b/Timeline/Models/UserUtility.cs deleted file mode 100644 index 405987b5..00000000 --- a/Timeline/Models/UserUtility.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Linq; -using Timeline.Entities; -using Timeline.Services; - -namespace Timeline.Models -{ - public static class UserUtility - { - 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 }; - - public static string[] IsAdminToRoleArray(bool isAdmin) - { - return isAdmin ? AdminRoleArray : UserRoleArray; - } - - public static bool RoleArrayToIsAdmin(string[] roles) - { - return roles.Contains(AdminRole); - } - - public static string[] RoleStringToRoleArray(string roleString) - { - return roleString.Split(',').ToArray(); - } - - public static string RoleArrayToRoleString(string[] roles) - { - return string.Join(',', roles); - } - - public static string IsAdminToRoleString(bool isAdmin) - { - 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, RoleStringToIsAdmin(user.RoleString)); - } - - internal static UserCache CreateUserCache(User user) - { - if (user == null) - throw new ArgumentNullException(nameof(user)); - return new UserCache { Username = user.Name, Administrator = RoleStringToIsAdmin(user.RoleString), Version = user.Version }; - } - } -} diff --git a/Timeline/Models/Validation/UsernameValidator.cs b/Timeline/Models/Validation/UsernameValidator.cs index 65d4da71..dc237add 100644 --- a/Timeline/Models/Validation/UsernameValidator.cs +++ b/Timeline/Models/Validation/UsernameValidator.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; namespace Timeline.Models.Validation { @@ -36,4 +37,15 @@ namespace Timeline.Models.Validation return (true, SuccessMessageGenerator); } } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, + AllowMultiple = false)] + public class UsernameAttribute : ValidateWithAttribute + { + public UsernameAttribute() + : base(typeof(UsernameValidator)) + { + + } + } } diff --git a/Timeline/Models/Validation/Validator.cs b/Timeline/Models/Validation/Validator.cs index 606ba7b4..d2c7c377 100644 --- a/Timeline/Models/Validation/Validator.cs +++ b/Timeline/Models/Validation/Validator.cs @@ -8,7 +8,7 @@ namespace Timeline.Models.Validation { /// /// Generate a message from a localizer factory. - /// If localizerFactory is null, it should return a neutral-cultural message. + /// If localizerFactory is null, it should return a culture-invariant message. /// /// The localizer factory. Could be null. /// The message. diff --git a/Timeline/Program.cs b/Timeline/Program.cs index 7474fe2f..4a098adf 100644 --- a/Timeline/Program.cs +++ b/Timeline/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; +using System.Resources; namespace Timeline { diff --git a/Timeline/Resources/Authentication/AuthHandler.Designer.cs b/Timeline/Resources/Authentication/AuthHandler.Designer.cs new file mode 100644 index 00000000..fd4540ea --- /dev/null +++ b/Timeline/Resources/Authentication/AuthHandler.Designer.cs @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Resources.Authentication { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class AuthHandler { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal AuthHandler() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Resources.Authentication.AuthHandler", typeof(AuthHandler).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Token is found in authorization header. Token is {0} .. + /// + internal static string LogTokenFoundInHeader { + get { + return ResourceManager.GetString("LogTokenFoundInHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Token is found in query param with key "{0}". Token is {1} .. + /// + internal static string LogTokenFoundInQuery { + get { + return ResourceManager.GetString("LogTokenFoundInQuery", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No jwt token is found.. + /// + internal static string LogTokenNotFound { + get { + return ResourceManager.GetString("LogTokenNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A jwt token validation failed.. + /// + internal static string LogTokenValidationFail { + get { + return ResourceManager.GetString("LogTokenValidationFail", resourceCulture); + } + } + } +} diff --git a/Timeline/Resources/Authentication/AuthHandler.resx b/Timeline/Resources/Authentication/AuthHandler.resx new file mode 100644 index 00000000..4cddc8ce --- /dev/null +++ b/Timeline/Resources/Authentication/AuthHandler.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Token is found in authorization header. Token is {0} . + + + Token is found in query param with key "{0}". Token is {1} . + + + No jwt token is found. + + + A jwt token validation failed. + + \ No newline at end of file diff --git a/Timeline/Resources/Services/Exception.Designer.cs b/Timeline/Resources/Services/Exception.Designer.cs index 15a8169e..24f6b8e6 100644 --- a/Timeline/Resources/Services/Exception.Designer.cs +++ b/Timeline/Resources/Services/Exception.Designer.cs @@ -69,6 +69,69 @@ namespace Timeline.Resources.Services { } } + /// + /// Looks up a localized string similar to The hashes password is of bad format. It might not be created by server.. + /// + internal static string HashedPasswordBadFromatException { + get { + return ResourceManager.GetString("HashedPasswordBadFromatException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not of valid base64 format. See inner exception.. + /// + internal static string HashedPasswordBadFromatExceptionNotBase64 { + get { + return ResourceManager.GetString("HashedPasswordBadFromatExceptionNotBase64", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Decoded hashed password is of length 0.. + /// + internal static string HashedPasswordBadFromatExceptionNotLength0 { + get { + return ResourceManager.GetString("HashedPasswordBadFromatExceptionNotLength0", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to See inner exception.. + /// + internal static string HashedPasswordBadFromatExceptionNotOthers { + get { + return ResourceManager.GetString("HashedPasswordBadFromatExceptionNotOthers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Salt length < 128 bits.. + /// + internal static string HashedPasswordBadFromatExceptionNotSaltTooShort { + get { + return ResourceManager.GetString("HashedPasswordBadFromatExceptionNotSaltTooShort", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Subkey length < 128 bits.. + /// + internal static string HashedPasswordBadFromatExceptionNotSubkeyTooShort { + get { + return ResourceManager.GetString("HashedPasswordBadFromatExceptionNotSubkeyTooShort", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown format marker.. + /// + internal static string HashedPasswordBadFromatExceptionNotUnknownMarker { + get { + return ResourceManager.GetString("HashedPasswordBadFromatExceptionNotUnknownMarker", resourceCulture); + } + } + /// /// Looks up a localized string similar to The version of the jwt token is old.. /// diff --git a/Timeline/Resources/Services/Exception.resx b/Timeline/Resources/Services/Exception.resx index af771393..408c45a1 100644 --- a/Timeline/Resources/Services/Exception.resx +++ b/Timeline/Resources/Services/Exception.resx @@ -120,6 +120,27 @@ The password is wrong. + + The hashes password is of bad format. It might not be created by server. + + + Not of valid base64 format. See inner exception. + + + Decoded hashed password is of length 0. + + + See inner exception. + + + Salt length < 128 bits. + + + Subkey length < 128 bits. + + + Unknown format marker. + The version of the jwt token is old. diff --git a/Timeline/Resources/Services/UserService.Designer.cs b/Timeline/Resources/Services/UserService.Designer.cs new file mode 100644 index 00000000..2a04dded --- /dev/null +++ b/Timeline/Resources/Services/UserService.Designer.cs @@ -0,0 +1,126 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Resources.Services { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class UserService { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal UserService() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Resources.Services.UserService", typeof(UserService).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to New username is of bad format.. + /// + internal static string ExceptionNewUsernameBadFormat { + get { + return ResourceManager.GetString("ExceptionNewUsernameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Old username is of bad format.. + /// + internal static string ExceptionOldUsernameBadFormat { + get { + return ResourceManager.GetString("ExceptionOldUsernameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A cache entry is created.. + /// + internal static string LogCacheCreate { + get { + return ResourceManager.GetString("LogCacheCreate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A cache entry is removed.. + /// + internal static string LogCacheRemove { + get { + return ResourceManager.GetString("LogCacheRemove", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A new user entry is added to the database.. + /// + internal static string LogDatabaseCreate { + get { + return ResourceManager.GetString("LogDatabaseCreate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A user entry is removed from the database.. + /// + internal static string LogDatabaseRemove { + get { + return ResourceManager.GetString("LogDatabaseRemove", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A user entry is updated to the database.. + /// + internal static string LogDatabaseUpdate { + get { + return ResourceManager.GetString("LogDatabaseUpdate", resourceCulture); + } + } + } +} diff --git a/Timeline/Resources/Services/UserService.resx b/Timeline/Resources/Services/UserService.resx new file mode 100644 index 00000000..3670d8f9 --- /dev/null +++ b/Timeline/Resources/Services/UserService.resx @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + New username is of bad format. + + + Old username is of bad format. + + + A cache entry is created. + + + A cache entry is removed. + + + A new user entry is added to the database. + + + A user entry is removed from the database. + + + A user entry is updated to the database. + + \ No newline at end of file diff --git a/Timeline/Services/PasswordService.cs b/Timeline/Services/PasswordService.cs index e09a1365..e04a861b 100644 --- a/Timeline/Services/PasswordService.cs +++ b/Timeline/Services/PasswordService.cs @@ -12,13 +12,23 @@ namespace Timeline.Services [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; } + private static string MakeMessage(string reason) + { + return Resources.Services.Exception.HashedPasswordBadFromatException + " Reason: " + reason; + } + + public HashedPasswordBadFromatException() : base(Resources.Services.Exception.HashedPasswordBadFromatException) { } + + public HashedPasswordBadFromatException(string message) : base(message) { } + public HashedPasswordBadFromatException(string message, Exception inner) : base(message, inner) { } + + public HashedPasswordBadFromatException(string hashedPassword, string reason) : base(MakeMessage(reason)) { HashedPassword = hashedPassword; } + public HashedPasswordBadFromatException(string hashedPassword, string reason, Exception inner) : base(MakeMessage(reason), 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 string? HashedPassword { get; set; } } public interface IPasswordService @@ -140,22 +150,20 @@ namespace Timeline.Services } catch (FormatException e) { - throw new HashedPasswordBadFromatException(hashedPassword, "Not of valid base64 format. See inner exception.", e); + throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotBase64, e); } // read the format marker from the hashed password if (decodedHashedPassword.Length == 0) { - throw new HashedPasswordBadFromatException(hashedPassword, "Decoded hashed password is of length 0."); + throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotLength0); } - switch (decodedHashedPassword[0]) - { - case 0x01: - return VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword); - default: - throw new HashedPasswordBadFromatException(hashedPassword, "Unknown format marker."); - } + return (decodedHashedPassword[0]) switch + { + 0x01 => VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword), + _ => throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotUnknownMarker), + }; } private bool VerifyHashedPasswordV3(byte[] hashedPassword, string password, string hashedPasswordString) @@ -170,7 +178,7 @@ namespace Timeline.Services // Read the salt: must be >= 128 bits if (saltLength < 128 / 8) { - throw new HashedPasswordBadFromatException(hashedPasswordString, "Salt length < 128 bits."); + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSaltTooShort); } byte[] salt = new byte[saltLength]; Buffer.BlockCopy(hashedPassword, 13, salt, 0, salt.Length); @@ -179,7 +187,7 @@ namespace Timeline.Services int subkeyLength = hashedPassword.Length - 13 - salt.Length; if (subkeyLength < 128 / 8) { - throw new HashedPasswordBadFromatException(hashedPasswordString, "Subkey length < 128 bits."); + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSubkeyTooShort); } byte[] expectedSubkey = new byte[subkeyLength]; Buffer.BlockCopy(hashedPassword, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); @@ -193,7 +201,7 @@ namespace Timeline.Services // 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); + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotOthers, e); } } diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 45ef8a5c..d706d05e 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -1,15 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using System; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; +using Timeline.Helpers; using Timeline.Models; using Timeline.Models.Validation; -using static Timeline.Helpers.MyLogHelper; -using static Timeline.Models.UserUtility; namespace Timeline.Services { @@ -30,6 +28,7 @@ namespace Timeline.Services /// The expired time point. Null then use default. See for what is default. /// An containing the created token and user info. /// Thrown when or is null. + /// Thrown when username is of bad format. /// Thrown when the user with given username does not exist. /// Thrown when password is wrong. Task CreateToken(string username, string password, DateTime? expires = null); @@ -50,6 +49,8 @@ namespace Timeline.Services /// /// Username of the user. /// The info of the user. Null if the user of given username does not exists. + /// Thrown when is null. + /// Thrown when is of bad format. Task GetUser(string username); /// @@ -82,6 +83,7 @@ namespace Timeline.Services /// New password. Null if not modify. /// Whether the user is administrator. Null if not modify. /// Thrown if is null. + /// Thrown when is of bad format. /// Thrown if the user with given username does not exist. Task PatchUser(string username, string? password, bool? administrator); @@ -90,6 +92,7 @@ namespace Timeline.Services /// /// Username of thet user to delete. Can't be null. /// Thrown if is null. + /// Thrown when is of bad format. /// Thrown if the user with given username does not exist. Task DeleteUser(string username); @@ -100,6 +103,7 @@ namespace Timeline.Services /// The user's old password. /// The user's new password. /// Thrown if or or is null. + /// Thrown when is of bad format. /// Thrown if the user with given username does not exist. /// Thrown if the old password is wrong. Task ChangePassword(string username, string oldPassword, string newPassword); @@ -109,9 +113,9 @@ namespace Timeline.Services /// /// The user's old username. /// The new username. - /// Thrown if or is null or empty. + /// Thrown if or is null. /// Thrown if the user with old username does not exist. - /// Thrown if the new username is not accepted because of bad format. + /// Thrown if the or is of bad format. /// Thrown if user with the new username already exists. Task ChangeUsername(string oldUsername, string newUsername); } @@ -157,7 +161,19 @@ namespace Timeline.Services { var key = GenerateCacheKeyByUserId(id); _memoryCache.Remove(key); - _logger.LogInformation(FormatLogMessage("A cache entry is removed.", Pair("Key", key))); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogCacheRemove, ("Key", key))); + } + + private void CheckUsernameFormat(string username, string? message = null) + { + var (result, messageGenerator) = _usernameValidator.Validate(username); + if (!result) + { + if (message == null) + throw new UsernameBadFormatException(username, messageGenerator(null)); + else + throw new UsernameBadFormatException(username, message + messageGenerator(null)); + } } public async Task CreateToken(string username, string password, DateTime? expires) @@ -166,6 +182,7 @@ namespace Timeline.Services throw new ArgumentNullException(nameof(username)); if (password == null) throw new ArgumentNullException(nameof(password)); + CheckUsernameFormat(username); // We need password info, so always check the database. var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); @@ -185,7 +202,7 @@ namespace Timeline.Services return new CreateTokenResult { Token = token, - User = CreateUserInfo(user) + User = UserConvert.CreateUserInfo(user) }; } @@ -208,9 +225,9 @@ namespace Timeline.Services throw new UserNotExistException(id); // create cache - cache = CreateUserCache(user); + cache = UserConvert.CreateUserCache(user); _memoryCache.CreateEntry(key).SetValue(cache); - _logger.LogInformation(FormatLogMessage("A cache entry is created.", Pair("Key", key))); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogCacheCreate, ("Key", key))); } if (tokenInfo.Version != cache.Version) @@ -221,16 +238,20 @@ namespace Timeline.Services public async Task GetUser(string username) { + if (username == null) + throw new ArgumentNullException(nameof(username)); + CheckUsernameFormat(username); + return await _databaseContext.Users .Where(user => user.Name == username) - .Select(user => CreateUserInfo(user)) + .Select(user => UserConvert.CreateUserInfo(user)) .SingleOrDefaultAsync(); } public async Task ListUsers() { return await _databaseContext.Users - .Select(user => CreateUserInfo(user)) + .Select(user => UserConvert.CreateUserInfo(user)) .ToArrayAsync(); } @@ -240,12 +261,7 @@ namespace Timeline.Services throw new ArgumentNullException(nameof(username)); if (password == null) throw new ArgumentNullException(nameof(password)); - - var (result, messageGenerator) = _usernameValidator.Validate(username); - if (!result) - { - throw new UsernameBadFormatException(username, messageGenerator(null)); - } + CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); @@ -255,20 +271,22 @@ namespace Timeline.Services { Name = username, EncryptedPassword = _passwordService.HashPassword(password), - RoleString = IsAdminToRoleString(administrator), + RoleString = UserRoleConvert.ToString(administrator), Avatar = UserAvatar.Create(DateTime.Now) }; await _databaseContext.AddAsync(newUser); await _databaseContext.SaveChangesAsync(); - _logger.LogInformation(FormatLogMessage("A new user entry is added to the database.", Pair("Id", newUser.Id))); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseCreate, + ("Id", newUser.Id), ("Username", username), ("Administrator", administrator))); return PutResult.Create; } user.EncryptedPassword = _passwordService.HashPassword(password); - user.RoleString = IsAdminToRoleString(administrator); + user.RoleString = UserRoleConvert.ToString(administrator); user.Version += 1; await _databaseContext.SaveChangesAsync(); - _logger.LogInformation(FormatLogMessage("A user entry is updated to the database.", Pair("Id", user.Id))); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, + ("Id", user.Id), ("Username", username), ("Administrator", administrator))); //clear cache RemoveCache(user.Id); @@ -276,10 +294,11 @@ namespace Timeline.Services return PutResult.Modify; } - public async Task PatchUser(string username, string password, bool? administrator) + public async Task PatchUser(string username, string? password, bool? administrator) { if (username == null) throw new ArgumentNullException(nameof(username)); + CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) @@ -292,12 +311,12 @@ namespace Timeline.Services if (administrator != null) { - user.RoleString = IsAdminToRoleString(administrator.Value); + user.RoleString = UserRoleConvert.ToString(administrator.Value); } user.Version += 1; await _databaseContext.SaveChangesAsync(); - _logger.LogInformation(FormatLogMessage("A user entry is updated to the database.", Pair("Id", user.Id))); + _logger.LogInformation(Resources.Services.UserService.LogDatabaseUpdate, ("Id", user.Id)); //clear cache RemoveCache(user.Id); @@ -307,6 +326,7 @@ namespace Timeline.Services { if (username == null) throw new ArgumentNullException(nameof(username)); + CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) @@ -314,7 +334,8 @@ namespace Timeline.Services _databaseContext.Users.Remove(user); await _databaseContext.SaveChangesAsync(); - _logger.LogInformation(FormatLogMessage("A user entry is removed from the database.", Pair("Id", user.Id))); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseRemove, + ("Id", user.Id))); //clear cache RemoveCache(user.Id); @@ -328,6 +349,7 @@ namespace Timeline.Services throw new ArgumentNullException(nameof(oldPassword)); if (newPassword == null) throw new ArgumentNullException(nameof(newPassword)); + CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) @@ -340,23 +362,20 @@ namespace Timeline.Services user.EncryptedPassword = _passwordService.HashPassword(newPassword); user.Version += 1; await _databaseContext.SaveChangesAsync(); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, + ("Id", user.Id), ("Operation", "Change password"))); //clear cache RemoveCache(user.Id); } public async Task ChangeUsername(string oldUsername, string newUsername) { - if (string.IsNullOrEmpty(oldUsername)) - throw new ArgumentException("Old username is null or empty", nameof(oldUsername)); - if (string.IsNullOrEmpty(newUsername)) - throw new ArgumentException("New username is null or empty", nameof(newUsername)); - - - var (result, messageGenerator) = _usernameValidator.Validate(newUsername); - if (!result) - { - throw new UsernameBadFormatException(newUsername, $"New username is of bad format. {messageGenerator(null)}"); - } + if (oldUsername == null) + throw new ArgumentNullException(nameof(oldUsername)); + if (newUsername == null) + throw new ArgumentNullException(nameof(newUsername)); + CheckUsernameFormat(oldUsername, Resources.Services.UserService.ExceptionOldUsernameBadFormat); + CheckUsernameFormat(newUsername, Resources.Services.UserService.ExceptionNewUsernameBadFormat); var user = await _databaseContext.Users.Where(u => u.Name == oldUsername).SingleOrDefaultAsync(); if (user == null) @@ -369,8 +388,8 @@ namespace Timeline.Services user.Name = newUsername; user.Version += 1; await _databaseContext.SaveChangesAsync(); - _logger.LogInformation(FormatLogMessage("A user entry changed name field.", - Pair("Id", user.Id), Pair("Old Username", oldUsername), Pair("New Username", newUsername))); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, + ("Id", user.Id), ("Old Username", oldUsername), ("New Username", newUsername))); RemoveCache(user.Id); } } diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index be5bce7c..d54ea6ca 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.Collections.Generic; using System.Globalization; -using Timeline.Authenticate; +using Timeline.Authentication; using Timeline.Configs; using Timeline.Entities; using Timeline.Helpers; diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index e29c4e4b..0ba34471 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -34,6 +34,11 @@ + + True + True + AuthHandler.resx + True True @@ -64,9 +69,18 @@ True Exception.resx + + True + True + UserService.resx + + + ResXFileCodeGenerator + AuthHandler.Designer.cs + ResXFileCodeGenerator Common.Designer.cs @@ -102,5 +116,9 @@ ResXFileCodeGenerator Exception.Designer.cs + + ResXFileCodeGenerator + UserService.Designer.cs + -- cgit v1.2.3 From c324d1dad0ffc1a1013b22792078415e7a50c470 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 24 Oct 2019 16:56:41 +0800 Subject: ... --- Timeline.Tests/IntegratedTests/UserAvatarTest.cs | 269 +++++++++++++++++++++ Timeline.Tests/IntegratedTests/UserAvatarTests.cs | 267 -------------------- Timeline.Tests/UserAvatarServiceTest.cs | 12 +- Timeline/Controllers/UserAvatarController.cs | 135 ++++++----- Timeline/Entities/UserAvatar.cs | 1 + Timeline/ErrorCodes.cs | 15 +- Timeline/Filters/ContentHeaderAttributes.cs | 13 +- Timeline/Helpers/LanguageHelper.cs | 12 + Timeline/Helpers/Log.cs | 20 -- Timeline/Models/Http/Common.cs | 66 +++-- .../Controllers/UserAvatarController.Designer.cs | 171 +++++++++++++ .../Controllers/UserAvatarController.en.resx | 144 +++++++++++ .../Controllers/UserAvatarController.resx | 156 ++++++++++++ .../Controllers/UserAvatarController.zh.resx | 144 +++++++++++ Timeline/Resources/Models/Http/Common.en.resx | 29 ++- Timeline/Resources/Models/Http/Common.zh.resx | 29 ++- Timeline/Resources/Services/Exception.Designer.cs | 45 ++++ Timeline/Resources/Services/Exception.resx | 15 ++ .../Services/UserAvatarService.Designer.cs | 108 +++++++++ Timeline/Resources/Services/UserAvatarService.resx | 135 +++++++++++ Timeline/Services/AvatarFormatException.cs | 51 ++++ Timeline/Services/DatabaseExtensions.cs | 15 +- Timeline/Services/ETagGenerator.cs | 17 +- Timeline/Services/UserAvatarService.cs | 187 +++++++------- Timeline/Services/UserService.cs | 2 +- Timeline/Timeline.csproj | 18 ++ 26 files changed, 1587 insertions(+), 489 deletions(-) create mode 100644 Timeline.Tests/IntegratedTests/UserAvatarTest.cs delete mode 100644 Timeline.Tests/IntegratedTests/UserAvatarTests.cs create mode 100644 Timeline/Helpers/LanguageHelper.cs create mode 100644 Timeline/Resources/Controllers/UserAvatarController.Designer.cs create mode 100644 Timeline/Resources/Controllers/UserAvatarController.en.resx create mode 100644 Timeline/Resources/Controllers/UserAvatarController.resx create mode 100644 Timeline/Resources/Controllers/UserAvatarController.zh.resx create mode 100644 Timeline/Resources/Services/UserAvatarService.Designer.cs create mode 100644 Timeline/Resources/Services/UserAvatarService.resx create mode 100644 Timeline/Services/AvatarFormatException.cs (limited to 'Timeline/Resources/Services/Exception.Designer.cs') diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs new file mode 100644 index 00000000..ba6d98e1 --- /dev/null +++ b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs @@ -0,0 +1,269 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Xunit; +using static Timeline.ErrorCodes.Http.Common; +using static Timeline.ErrorCodes.Http.UserAvatar; + +namespace Timeline.Tests.IntegratedTests +{ + public class UserAvatarUnitTest : IClassFixture>, IDisposable + { + private readonly TestApplication _testApp; + private readonly WebApplicationFactory _factory; + + public UserAvatarUnitTest(WebApplicationFactory factory) + { + _testApp = new TestApplication(factory); + _factory = _testApp.Factory; + } + + public void Dispose() + { + _testApp.Dispose(); + } + + [Fact] + public async Task Test() + { + Avatar mockAvatar = new Avatar + { + Data = ImageHelper.CreatePngWithSize(100, 100), + Type = PngFormat.Instance.DefaultMimeType + }; + + using (var client = await _factory.CreateClientAsUser()) + { + { + var res = await client.GetAsync("users/usernotexist/avatar"); + res.Should().HaveStatusCode(404) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Get.UserNotExist); + } + + var env = _factory.Server.Host.Services.GetRequiredService(); + var defaultAvatarData = await File.ReadAllBytesAsync(Path.Combine(env.ContentRootPath, "default-avatar.png")); + + async Task GetReturnDefault(string username = "user") + { + var res = await client.GetAsync($"users/{username}/avatar"); + res.Should().HaveStatusCode(200); + res.Content.Headers.ContentType.MediaType.Should().Be("image/png"); + var body = await res.Content.ReadAsByteArrayAsync(); + body.Should().Equal(defaultAvatarData); + } + + EntityTagHeaderValue eTag; + { + var res = await client.GetAsync($"users/user/avatar"); + res.Should().HaveStatusCode(200); + res.Content.Headers.ContentType.MediaType.Should().Be("image/png"); + var body = await res.Content.ReadAsByteArrayAsync(); + body.Should().Equal(defaultAvatarData); + var cacheControl = res.Headers.CacheControl; + cacheControl.NoCache.Should().BeTrue(); + cacheControl.NoStore.Should().BeFalse(); + cacheControl.MaxAge.Should().NotBeNull().And.Be(TimeSpan.Zero); + eTag = res.Headers.ETag; + } + + await GetReturnDefault("admin"); + + { + var request = new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress, "users/user/avatar"), + Method = HttpMethod.Get, + }; + request.Headers.TryAddWithoutValidation("If-None-Match", "\"dsdfd"); + var res = await client.SendAsync(request); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.BadFormat_IfNonMatch); + } + + { + var request = new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress, "users/user/avatar"), + Method = HttpMethod.Get, + }; + request.Headers.TryAddWithoutValidation("If-None-Match", "\"aaa\""); + var res = await client.SendAsync(request); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + + { + var request = new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress, "users/user/avatar"), + Method = HttpMethod.Get, + }; + request.Headers.Add("If-None-Match", eTag.ToString()); + var res = await client.SendAsync(request); + res.Should().HaveStatusCode(HttpStatusCode.NotModified); + } + + { + var content = new ByteArrayContent(new[] { (byte)0x00 }); + content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + var res = await client.PutAsync("users/user/avatar", content); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.Missing_ContentLength); + } + + { + var content = new ByteArrayContent(new[] { (byte)0x00 }); + content.Headers.ContentLength = 1; + var res = await client.PutAsync("users/user/avatar", content); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.Missing_ContentType); + } + + { + var content = new ByteArrayContent(new[] { (byte)0x00 }); + content.Headers.ContentLength = 0; + content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + var res = await client.PutAsync("users/user/avatar", content); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.Zero_ContentLength); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", new[] { (byte)0x00 }, "image/notaccept"); + res.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + } + + { + var content = new ByteArrayContent(new[] { (byte)0x00 }); + content.Headers.ContentLength = 1000 * 1000 * 11; + content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + var res = await client.PutAsync("users/user/avatar", content); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Content.TooBig); + } + + { + var content = new ByteArrayContent(new[] { (byte)0x00 }); + content.Headers.ContentLength = 2; + content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + var res = await client.PutAsync("users/user/avatar", content); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Content.UnmatchedLength_Smaller); + } + + { + var content = new ByteArrayContent(new[] { (byte)0x00, (byte)0x01 }); + content.Headers.ContentLength = 1; + content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + var res = await client.PutAsync("users/user/avatar", content); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Content.UnmatchedLength_Bigger); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", new[] { (byte)0x00 }, "image/png"); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Put.BadFormat_CantDecode); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, "image/jpeg"); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Put.BadFormat_UnmatchedFormat); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", ImageHelper.CreatePngWithSize(100, 200), "image/png"); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Put.BadFormat_BadSize); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, mockAvatar.Type); + res.Should().HaveStatusCode(HttpStatusCode.OK); + + var res2 = await client.GetAsync("users/user/avatar"); + res2.Should().HaveStatusCode(200); + res2.Content.Headers.ContentType.MediaType.Should().Be(mockAvatar.Type); + var body = await res2.Content.ReadAsByteArrayAsync(); + body.Should().Equal(mockAvatar.Data); + } + + IEnumerable<(string, IImageFormat)> formats = new (string, IImageFormat)[] + { + ("image/jpeg", JpegFormat.Instance), + ("image/gif", GifFormat.Instance), + ("image/png", PngFormat.Instance), + }; + + foreach ((var mimeType, var format) in formats) + { + var res = await client.PutByteArrayAsync("users/user/avatar", ImageHelper.CreateImageWithSize(100, 100, format), mimeType); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + + { + var res = await client.PutByteArrayAsync("users/admin/avatar", new[] { (byte)0x00 }, "image/png"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Put.Forbid); + } + + { + var res = await client.DeleteAsync("users/admin/avatar"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Delete.Forbid); + } + + for (int i = 0; i < 2; i++) // double delete should work. + { + var res = await client.DeleteAsync("users/user/avatar"); + res.Should().HaveStatusCode(200); + await GetReturnDefault(); + } + } + + // Authorization check. + using (var client = await _factory.CreateClientAsAdmin()) + { + { + var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, mockAvatar.Type); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + + { + var res = await client.DeleteAsync("users/user/avatar"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + + { + var res = await client.PutByteArrayAsync("users/usernotexist/avatar", new[] { (byte)0x00 }, "image/png"); + res.Should().HaveStatusCode(400) + .And.Should().HaveCommonBody() + .Which.Code.Should().Be(Put.UserNotExist); + } + + { + var res = await client.DeleteAsync("users/usernotexist/avatar"); + res.Should().HaveStatusCode(400) + .And.Should().HaveCommonBody().Which.Code.Should().Be(Delete.UserNotExist); + } + } + } + } +} \ No newline at end of file diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTests.cs b/Timeline.Tests/IntegratedTests/UserAvatarTests.cs deleted file mode 100644 index ad0e4221..00000000 --- a/Timeline.Tests/IntegratedTests/UserAvatarTests.cs +++ /dev/null @@ -1,267 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using SixLabors.ImageSharp.Formats; -using SixLabors.ImageSharp.Formats.Gif; -using SixLabors.ImageSharp.Formats.Jpeg; -using SixLabors.ImageSharp.Formats.Png; -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Timeline.Controllers; -using Timeline.Services; -using Timeline.Tests.Helpers; -using Timeline.Tests.Helpers.Authentication; -using Xunit; - -namespace Timeline.Tests.IntegratedTests -{ - public class UserAvatarUnitTest : IClassFixture>, IDisposable - { - private readonly TestApplication _testApp; - private readonly WebApplicationFactory _factory; - - public UserAvatarUnitTest(WebApplicationFactory factory) - { - _testApp = new TestApplication(factory); - _factory = _testApp.Factory; - } - - public void Dispose() - { - _testApp.Dispose(); - } - - [Fact] - public async Task Test() - { - Avatar mockAvatar = new Avatar - { - Data = ImageHelper.CreatePngWithSize(100, 100), - Type = PngFormat.Instance.DefaultMimeType - }; - - using (var client = await _factory.CreateClientAsUser()) - { - { - var res = await client.GetAsync("users/usernotexist/avatar"); - res.Should().HaveStatusCode(404) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(UserAvatarController.ErrorCodes.Get_UserNotExist); - } - - var env = _factory.Server.Host.Services.GetRequiredService(); - var defaultAvatarData = await File.ReadAllBytesAsync(Path.Combine(env.ContentRootPath, "default-avatar.png")); - - async Task GetReturnDefault(string username = "user") - { - var res = await client.GetAsync($"users/{username}/avatar"); - res.Should().HaveStatusCode(200); - res.Content.Headers.ContentType.MediaType.Should().Be("image/png"); - var body = await res.Content.ReadAsByteArrayAsync(); - body.Should().Equal(defaultAvatarData); - } - - EntityTagHeaderValue eTag; - { - var res = await client.GetAsync($"users/user/avatar"); - res.Should().HaveStatusCode(200); - res.Content.Headers.ContentType.MediaType.Should().Be("image/png"); - var body = await res.Content.ReadAsByteArrayAsync(); - body.Should().Equal(defaultAvatarData); - var cacheControl = res.Headers.CacheControl; - cacheControl.NoCache.Should().BeTrue(); - cacheControl.NoStore.Should().BeFalse(); - cacheControl.MaxAge.Should().NotBeNull().And.Be(TimeSpan.Zero); - eTag = res.Headers.ETag; - } - - await GetReturnDefault("admin"); - - { - var request = new HttpRequestMessage() - { - RequestUri = new Uri(client.BaseAddress, "users/user/avatar"), - Method = HttpMethod.Get, - }; - request.Headers.TryAddWithoutValidation("If-None-Match", "\"dsdfd"); - var res = await client.SendAsync(request); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.BadFormat_IfNonMatch); - } - - { - var request = new HttpRequestMessage() - { - RequestUri = new Uri(client.BaseAddress, "users/user/avatar"), - Method = HttpMethod.Get, - }; - request.Headers.TryAddWithoutValidation("If-None-Match", "\"aaa\""); - var res = await client.SendAsync(request); - res.Should().HaveStatusCode(HttpStatusCode.OK); - } - - { - var request = new HttpRequestMessage() - { - RequestUri = new Uri(client.BaseAddress, "users/user/avatar"), - Method = HttpMethod.Get, - }; - request.Headers.Add("If-None-Match", eTag.ToString()); - var res = await client.SendAsync(request); - res.Should().HaveStatusCode(HttpStatusCode.NotModified); - } - - { - var content = new ByteArrayContent(new[] { (byte)0x00 }); - content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); - var res = await client.PutAsync("users/user/avatar", content); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.Missing_ContentLength); - } - - { - var content = new ByteArrayContent(new[] { (byte)0x00 }); - content.Headers.ContentLength = 1; - var res = await client.PutAsync("users/user/avatar", content); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.Missing_ContentType); - } - - { - var content = new ByteArrayContent(new[] { (byte)0x00 }); - content.Headers.ContentLength = 0; - content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); - var res = await client.PutAsync("users/user/avatar", content); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Http.Common.Header.Zero_ContentLength); - } - - { - var res = await client.PutByteArrayAsync("users/user/avatar", new[] { (byte)0x00 }, "image/notaccept"); - res.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - } - - { - var content = new ByteArrayContent(new[] { (byte)0x00 }); - content.Headers.ContentLength = 1000 * 1000 * 11; - content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); - var res = await client.PutAsync("users/user/avatar", content); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_Content_TooBig); - } - - { - var content = new ByteArrayContent(new[] { (byte)0x00 }); - content.Headers.ContentLength = 2; - content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); - var res = await client.PutAsync("users/user/avatar", content); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_Content_UnmatchedLength_Less); - } - - { - var content = new ByteArrayContent(new[] { (byte)0x00, (byte)0x01 }); - content.Headers.ContentLength = 1; - content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); - var res = await client.PutAsync("users/user/avatar", content); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_Content_UnmatchedLength_Bigger); - } - - { - var res = await client.PutByteArrayAsync("users/user/avatar", new[] { (byte)0x00 }, "image/png"); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_BadFormat_CantDecode); - } - - { - var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, "image/jpeg"); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_BadFormat_UnmatchedFormat); - } - - { - var res = await client.PutByteArrayAsync("users/user/avatar", ImageHelper.CreatePngWithSize(100, 200), "image/png"); - res.Should().HaveStatusCode(HttpStatusCode.BadRequest) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_BadFormat_BadSize); - } - - { - var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, mockAvatar.Type); - res.Should().HaveStatusCode(HttpStatusCode.OK); - - var res2 = await client.GetAsync("users/user/avatar"); - res2.Should().HaveStatusCode(200); - res2.Content.Headers.ContentType.MediaType.Should().Be(mockAvatar.Type); - var body = await res2.Content.ReadAsByteArrayAsync(); - body.Should().Equal(mockAvatar.Data); - } - - IEnumerable<(string, IImageFormat)> formats = new (string, IImageFormat)[] - { - ("image/jpeg", JpegFormat.Instance), - ("image/gif", GifFormat.Instance), - ("image/png", PngFormat.Instance), - }; - - foreach ((var mimeType, var format) in formats) - { - var res = await client.PutByteArrayAsync("users/user/avatar", ImageHelper.CreateImageWithSize(100, 100, format), mimeType); - res.Should().HaveStatusCode(HttpStatusCode.OK); - } - - { - var res = await client.PutByteArrayAsync("users/admin/avatar", new[] { (byte)0x00 }, "image/png"); - res.Should().HaveStatusCode(HttpStatusCode.Forbidden) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_Forbid); - } - - { - var res = await client.DeleteAsync("users/admin/avatar"); - res.Should().HaveStatusCode(HttpStatusCode.Forbidden) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Delete_Forbid); - } - - for (int i = 0; i < 2; i++) // double delete should work. - { - var res = await client.DeleteAsync("users/user/avatar"); - res.Should().HaveStatusCode(200); - await GetReturnDefault(); - } - } - - // Authorization check. - using (var client = await _factory.CreateClientAsAdmin()) - { - { - var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, mockAvatar.Type); - res.Should().HaveStatusCode(HttpStatusCode.OK); - } - - { - var res = await client.DeleteAsync("users/user/avatar"); - res.Should().HaveStatusCode(HttpStatusCode.OK); - } - - { - var res = await client.PutByteArrayAsync("users/usernotexist/avatar", new[] { (byte)0x00 }, "image/png"); - res.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody() - .Which.Code.Should().Be(UserAvatarController.ErrorCodes.Put_UserNotExist); - } - - { - var res = await client.DeleteAsync("users/usernotexist/avatar"); - res.Should().HaveStatusCode(400) - .And.Should().HaveCommonBody().Which.Code.Should().Be(UserAvatarController.ErrorCodes.Delete_UserNotExist); - } - } - } - } -} \ No newline at end of file diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs index d22ad113..7489517b 100644 --- a/Timeline.Tests/UserAvatarServiceTest.cs +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -63,8 +63,8 @@ namespace Timeline.Tests Type = "image/png" }; _validator.Awaiting(v => v.Validate(avatar)) - .Should().Throw() - .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.CantDecode); + .Should().Throw() + .Where(e => e.Avatar == avatar && e.Error == AvatarFormatException.ErrorReason.CantDecode); } [Fact] @@ -76,8 +76,8 @@ namespace Timeline.Tests Type = "image/jpeg" }; _validator.Awaiting(v => v.Validate(avatar)) - .Should().Throw() - .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.UnmatchedFormat); + .Should().Throw() + .Where(e => e.Avatar == avatar && e.Error == AvatarFormatException.ErrorReason.UnmatchedFormat); } [Fact] @@ -89,8 +89,8 @@ namespace Timeline.Tests Type = PngFormat.Instance.DefaultMimeType }; _validator.Awaiting(v => v.Validate(avatar)) - .Should().Throw() - .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.BadSize); + .Should().Throw() + .Where(e => e.Avatar == avatar && e.Error == AvatarFormatException.ErrorReason.BadSize); } [Fact] diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index 5cba1d93..838a3928 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using System; @@ -8,61 +9,67 @@ using System.Linq; using System.Threading.Tasks; using Timeline.Authentication; using Timeline.Filters; +using Timeline.Helpers; using Timeline.Models.Http; +using Timeline.Models.Validation; using Timeline.Services; -namespace Timeline.Controllers +namespace Timeline { - [ApiController] - public class UserAvatarController : Controller + public static partial class ErrorCodes { - public static class ErrorCodes + public static partial class Http { - public const int Get_UserNotExist = -1001; - - public const int Put_UserNotExist = -2001; - public const int Put_Forbid = -2002; - public const int Put_BadFormat_CantDecode = -2011; - public const int Put_BadFormat_UnmatchedFormat = -2012; - public const int Put_BadFormat_BadSize = -2013; - public const int Put_Content_TooBig = -2021; - public const int Put_Content_UnmatchedLength_Less = -2022; - public const int Put_Content_UnmatchedLength_Bigger = -2023; + public static class UserAvatar // bbb = 003 + { + public static class Get // cc = 01 + { + public const int UserNotExist = 10030101; + } - public const int Delete_UserNotExist = -3001; - public const int Delete_Forbid = -3002; + public static class Put // cc = 02 + { + public const int UserNotExist = 10030201; + public const int Forbid = 10030202; + public const int BadFormat_CantDecode = 10030203; + public const int BadFormat_UnmatchedFormat = 10030204; + public const int BadFormat_BadSize = 10030205; + } - public static int From(AvatarDataException.ErrorReason error) - { - switch (error) + public static class Delete // cc = 03 { - case AvatarDataException.ErrorReason.CantDecode: - return Put_BadFormat_CantDecode; - case AvatarDataException.ErrorReason.UnmatchedFormat: - return Put_BadFormat_UnmatchedFormat; - case AvatarDataException.ErrorReason.BadSize: - return Put_BadFormat_BadSize; - default: - throw new Exception("Unknown AvatarDataException.ErrorReason value."); + public const int UserNotExist = 10030301; + public const int Forbid = 10030302; } } } + } +} +namespace Timeline.Controllers +{ + [ApiController] + public class UserAvatarController : Controller + { private readonly ILogger _logger; private readonly IUserAvatarService _service; - public UserAvatarController(ILogger logger, IUserAvatarService service) + private readonly IStringLocalizerFactory _localizerFactory; + private readonly IStringLocalizer _localizer; + + public UserAvatarController(ILogger logger, IUserAvatarService service, IStringLocalizerFactory localizerFactory) { _logger = logger; _service = service; + _localizerFactory = localizerFactory; + _localizer = new StringLocalizer(localizerFactory); } [HttpGet("users/{username}/avatar")] - [Authorize] [ResponseCache(NoStore = false, Location = ResponseCacheLocation.None, Duration = 0)] - public async Task Get([FromRoute] string username) + public async Task Get([FromRoute][Username] string username) { const string IfNonMatchHeaderKey = "If-None-Match"; @@ -74,11 +81,16 @@ namespace Timeline.Controllers if (Request.Headers.TryGetValue(IfNonMatchHeaderKey, out var value)) { if (!EntityTagHeaderValue.TryParseStrictList(value, out var eTagList)) - return BadRequest(CommonResponse.BadIfNonMatch()); + { + _logger.LogInformation(Log.Format(Resources.Controllers.UserAvatarController.LogGetBadIfNoneMatch, + ("Username", username), ("If-None-Match", value))); + return BadRequest(HeaderErrorResponse.BadIfNonMatch(_localizerFactory)); + } if (eTagList.FirstOrDefault(e => e.Equals(eTag)) != null) { Response.Headers.Add("ETag", eTagValue); + _logger.LogInformation(Log.Format(Resources.Controllers.UserAvatarController.LogGetReturnNotModify, ("Username", username))); return StatusCode(StatusCodes.Status304NotModified); } } @@ -86,12 +98,13 @@ namespace Timeline.Controllers var avatarInfo = await _service.GetAvatar(username); var avatar = avatarInfo.Avatar; + _logger.LogInformation(Log.Format(Resources.Controllers.UserAvatarController.LogGetReturnData, ("Username", username))); return File(avatar.Data, avatar.Type, new DateTimeOffset(avatarInfo.LastModified), eTag); } catch (UserNotExistException e) { - _logger.LogInformation(e, $"Attempt to get a avatar of a non-existent user failed. Username: {username} ."); - return NotFound(new CommonResponse(ErrorCodes.Get_UserNotExist, "User does not exist.")); + _logger.LogInformation(e, Log.Format(Resources.Controllers.UserAvatarController.LogGetUserNotExist, ("Username", username))); + return NotFound(new CommonResponse(ErrorCodes.Http.UserAvatar.Get.UserNotExist, _localizer["ErrorGetUserNotExist"])); } } @@ -99,18 +112,18 @@ namespace Timeline.Controllers [Authorize] [RequireContentType, RequireContentLength] [Consumes("image/png", "image/jpeg", "image/gif", "image/webp")] - public async Task Put(string username) + public async Task Put([FromRoute][Username] string username) { - var contentLength = Request.ContentLength.Value; + var contentLength = Request.ContentLength!.Value; if (contentLength > 1000 * 1000 * 10) - return BadRequest(new CommonResponse(ErrorCodes.Put_Content_TooBig, - "Content can't be bigger than 10MB.")); + return BadRequest(ContentErrorResponse.TooBig(_localizerFactory, "10MB")); if (!User.IsAdministrator() && User.Identity.Name != username) { - _logger.LogInformation($"Attempt to put a avatar of other user as a non-admin failed. Operator Username: {User.Identity.Name} ; Username To Put Avatar: {username} ."); + _logger.LogInformation(Log.Format(Resources.Controllers.UserAvatarController.LogPutForbid, + ("Operator Username", User.Identity.Name), ("Username To Put Avatar", username))); return StatusCode(StatusCodes.Status403Forbidden, - new CommonResponse(ErrorCodes.Put_Forbid, "Normal user can't change other's avatar.")); + new CommonResponse(ErrorCodes.Http.UserAvatar.Put.Forbid, _localizer["ErrorPutForbid"])); } try @@ -119,13 +132,11 @@ namespace Timeline.Controllers var bytesRead = await Request.Body.ReadAsync(data); if (bytesRead != contentLength) - return BadRequest(new CommonResponse(ErrorCodes.Put_Content_UnmatchedLength_Less, - $"Content length in header is {contentLength} but actual length is {bytesRead}.")); + return BadRequest(ContentErrorResponse.UnmatchedLength_Smaller(_localizerFactory)); var extraByte = new byte[1]; if (await Request.Body.ReadAsync(extraByte) != 0) - return BadRequest(new CommonResponse(ErrorCodes.Put_Content_UnmatchedLength_Bigger, - $"Content length in header is {contentLength} but actual length is bigger than that.")); + return BadRequest(ContentErrorResponse.UnmatchedLength_Bigger(_localizerFactory)); await _service.SetAvatar(username, new Avatar { @@ -133,43 +144,57 @@ namespace Timeline.Controllers Type = Request.ContentType }); - _logger.LogInformation($"Succeed to put a avatar of a user. Username: {username} ; Mime Type: {Request.ContentType} ."); + _logger.LogInformation(Log.Format(Resources.Controllers.UserAvatarController.LogPutSuccess, + ("Username", username), ("Mime Type", Request.ContentType))); return Ok(); } catch (UserNotExistException e) { - _logger.LogInformation(e, $"Attempt to put a avatar of a non-existent user failed. Username: {username} ."); - return BadRequest(new CommonResponse(ErrorCodes.Put_UserNotExist, "User does not exist.")); + _logger.LogInformation(e, Log.Format(Resources.Controllers.UserAvatarController.LogPutUserNotExist, ("Username", username))); + return BadRequest(new CommonResponse(ErrorCodes.Http.UserAvatar.Put.UserNotExist, _localizer["ErrorPutUserNotExist"])); } - catch (AvatarDataException e) + catch (AvatarFormatException e) { - _logger.LogInformation(e, $"Attempt to put a avatar of a bad format failed. Username: {username} ."); - return BadRequest(new CommonResponse(ErrorCodes.From(e.Error), "Bad format.")); + var (code, message) = e.Error switch + { + AvatarFormatException.ErrorReason.CantDecode => + (ErrorCodes.Http.UserAvatar.Put.BadFormat_CantDecode, _localizer["ErrorPutBadFormatCantDecode"]), + AvatarFormatException.ErrorReason.UnmatchedFormat => + (ErrorCodes.Http.UserAvatar.Put.BadFormat_UnmatchedFormat, _localizer["ErrorPutBadFormatUnmatchedFormat"]), + AvatarFormatException.ErrorReason.BadSize => + (ErrorCodes.Http.UserAvatar.Put.BadFormat_BadSize, _localizer["ErrorPutBadFormatBadSize"]), + _ => + throw new Exception(Resources.Controllers.UserAvatarController.ExceptionUnknownAvatarFormatError) + }; + + _logger.LogInformation(e, Log.Format(Resources.Controllers.UserAvatarController.LogPutUserBadFormat, ("Username", username))); + return BadRequest(new CommonResponse(code, message)); } } [HttpDelete("users/{username}/avatar")] [Authorize] - public async Task Delete([FromRoute] string username) + public async Task Delete([FromRoute][Username] string username) { if (!User.IsAdministrator() && User.Identity.Name != username) { - _logger.LogInformation($"Attempt to delete a avatar of other user as a non-admin failed. Operator Username: {User.Identity.Name} ; Username To Put Avatar: {username} ."); + _logger.LogInformation(Log.Format(Resources.Controllers.UserAvatarController.LogPutUserBadFormat, + ("Operator Username", User.Identity.Name), ("Username To Delete Avatar", username))); return StatusCode(StatusCodes.Status403Forbidden, - new CommonResponse(ErrorCodes.Delete_Forbid, "Normal user can't delete other's avatar.")); + new CommonResponse(ErrorCodes.Http.UserAvatar.Delete.Forbid, _localizer["ErrorDeleteForbid"])); } try { await _service.SetAvatar(username, null); - _logger.LogInformation($"Succeed to delete a avatar of a user. Username: {username} ."); + _logger.LogInformation(Log.Format(Resources.Controllers.UserAvatarController.LogDeleteSuccess, ("Username", username))); return Ok(); } catch (UserNotExistException e) { - _logger.LogInformation(e, $"Attempt to delete a avatar of a non-existent user failed. Username: {username} ."); - return BadRequest(new CommonResponse(ErrorCodes.Delete_UserNotExist, "User does not exist.")); + _logger.LogInformation(e, Log.Format(Resources.Controllers.UserAvatarController.LogDeleteNotExist, ("Username", username))); + return BadRequest(new CommonResponse(ErrorCodes.Http.UserAvatar.Delete.UserNotExist, _localizer["ErrorDeleteUserNotExist"])); } } } diff --git a/Timeline/Entities/UserAvatar.cs b/Timeline/Entities/UserAvatar.cs index d47bb28b..3b5388aa 100644 --- a/Timeline/Entities/UserAvatar.cs +++ b/Timeline/Entities/UserAvatar.cs @@ -11,6 +11,7 @@ namespace Timeline.Entities public long Id { get; set; } [Column("data")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "This is data base entity.")] public byte[]? Data { get; set; } [Column("type")] diff --git a/Timeline/ErrorCodes.cs b/Timeline/ErrorCodes.cs index 0b325e27..5e7f003a 100644 --- a/Timeline/ErrorCodes.cs +++ b/Timeline/ErrorCodes.cs @@ -17,10 +17,17 @@ public static class Header // cc = 01 { - public const int Missing_ContentType = 10010101; // dd = 01 - public const int Missing_ContentLength = 10010102; // dd = 02 - public const int Zero_ContentLength = 10010103; // dd = 03 - public const int BadFormat_IfNonMatch = 10010104; // dd = 04 + public const int Missing_ContentType = 10000101; // dd = 01 + public const int Missing_ContentLength = 10000102; // dd = 02 + public const int Zero_ContentLength = 10000103; // dd = 03 + public const int BadFormat_IfNonMatch = 10000104; // dd = 04 + } + + public static class Content // cc = 02 + { + public const int TooBig = 1000201; + public const int UnmatchedLength_Smaller = 10030202; + public const int UnmatchedLength_Bigger = 10030203; } } } diff --git a/Timeline/Filters/ContentHeaderAttributes.cs b/Timeline/Filters/ContentHeaderAttributes.cs index 14685a01..e3d4eeb2 100644 --- a/Timeline/Filters/ContentHeaderAttributes.cs +++ b/Timeline/Filters/ContentHeaderAttributes.cs @@ -1,16 +1,20 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; using Timeline.Models.Http; namespace Timeline.Filters { public class RequireContentTypeAttribute : ActionFilterAttribute { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] public override void OnActionExecuting(ActionExecutingContext context) { if (context.HttpContext.Request.ContentType == null) { - context.Result = new BadRequestObjectResult(CommonResponse.MissingContentType()); + var localizerFactory = context.HttpContext.RequestServices.GetRequiredService(); + context.Result = new BadRequestObjectResult(HeaderErrorResponse.MissingContentType(localizerFactory)); } } } @@ -30,17 +34,20 @@ namespace Timeline.Filters public bool RequireNonZero { get; set; } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] public override void OnActionExecuting(ActionExecutingContext context) { if (context.HttpContext.Request.ContentLength == null) { - context.Result = new BadRequestObjectResult(CommonResponse.MissingContentLength()); + var localizerFactory = context.HttpContext.RequestServices.GetRequiredService(); + context.Result = new BadRequestObjectResult(HeaderErrorResponse.MissingContentLength(localizerFactory)); return; } if (RequireNonZero && context.HttpContext.Request.ContentLength.Value == 0) { - context.Result = new BadRequestObjectResult(CommonResponse.ZeroContentLength()); + var localizerFactory = context.HttpContext.RequestServices.GetRequiredService(); + context.Result = new BadRequestObjectResult(HeaderErrorResponse.ZeroContentLength(localizerFactory)); return; } } diff --git a/Timeline/Helpers/LanguageHelper.cs b/Timeline/Helpers/LanguageHelper.cs new file mode 100644 index 00000000..b0156b8b --- /dev/null +++ b/Timeline/Helpers/LanguageHelper.cs @@ -0,0 +1,12 @@ +using System.Linq; + +namespace Timeline.Helpers +{ + public static class LanguageHelper + { + public static bool AreSame(this bool firstBool, params bool[] otherBools) + { + return otherBools.All(b => b == firstBool); + } + } +} diff --git a/Timeline/Helpers/Log.cs b/Timeline/Helpers/Log.cs index 8deebf1d..68c975fa 100644 --- a/Timeline/Helpers/Log.cs +++ b/Timeline/Helpers/Log.cs @@ -3,26 +3,6 @@ using System.Text; namespace Timeline.Helpers { - // TODO! Remember to remove this after refactor. - public static class MyLogHelper - { - public static KeyValuePair Pair(string key, object value) => new KeyValuePair(key, value); - - public static string FormatLogMessage(string summary, params KeyValuePair[] properties) - { - var builder = new StringBuilder(); - builder.Append(summary); - foreach (var property in properties) - { - builder.AppendLine(); - builder.Append(property.Key); - builder.Append(" : "); - builder.Append(property.Value); - } - return builder.ToString(); - } - } - public static class Log { public static string Format(string summary, params (string, object?)[] properties) diff --git a/Timeline/Models/Http/Common.cs b/Timeline/Models/Http/Common.cs index c741837a..39ddddd9 100644 --- a/Timeline/Models/Http/Common.cs +++ b/Timeline/Models/Http/Common.cs @@ -5,46 +5,74 @@ namespace Timeline.Models.Http { public class CommonResponse { - public static CommonResponse InvalidModel(string message) + internal static CommonResponse InvalidModel(string message) { return new CommonResponse(ErrorCodes.Http.Common.InvalidModel, message); } - public static CommonResponse MissingContentType() + public CommonResponse() { - return new CommonResponse(ErrorCodes.Http.Common.Header.Missing_ContentType, "Header Content-Type is required."); + } - public static CommonResponse MissingContentLength() + public CommonResponse(int code, string message) { - return new CommonResponse(ErrorCodes.Http.Common.Header.Missing_ContentLength, "Header Content-Length is missing or of bad format."); + Code = code; + Message = message; } - public static CommonResponse ZeroContentLength() + public int Code { get; set; } + public string? Message { get; set; } + } + + internal static class HeaderErrorResponse + { + internal static CommonResponse MissingContentType(IStringLocalizerFactory localizerFactory) { - return new CommonResponse(ErrorCodes.Http.Common.Header.Zero_ContentLength, "Header Content-Length must not be 0."); + var localizer = localizerFactory.Create("Models.Http.Common"); + return new CommonResponse(ErrorCodes.Http.Common.Header.Missing_ContentType, localizer["HeaderMissingContentType"]); } - public static CommonResponse BadIfNonMatch() + internal static CommonResponse MissingContentLength(IStringLocalizerFactory localizerFactory) { - return new CommonResponse(ErrorCodes.Http.Common.Header.BadFormat_IfNonMatch, "Header If-Non-Match is of bad format."); + var localizer = localizerFactory.Create("Models.Http.Common"); + return new CommonResponse(ErrorCodes.Http.Common.Header.Missing_ContentLength, localizer["HeaderMissingContentLength"]); } - public CommonResponse() + internal static CommonResponse ZeroContentLength(IStringLocalizerFactory localizerFactory) { + var localizer = localizerFactory.Create("Models.Http.Common"); + return new CommonResponse(ErrorCodes.Http.Common.Header.Zero_ContentLength, localizer["HeaderZeroContentLength"]); + } + internal static CommonResponse BadIfNonMatch(IStringLocalizerFactory localizerFactory) + { + var localizer = localizerFactory.Create("Models.Http.Common"); + return new CommonResponse(ErrorCodes.Http.Common.Header.BadFormat_IfNonMatch, localizer["HeaderBadIfNonMatch"]); } + } - public CommonResponse(int code, string message) + internal static class ContentErrorResponse + { + internal static CommonResponse TooBig(IStringLocalizerFactory localizerFactory, string maxLength) { - Code = code; - Message = message; + var localizer = localizerFactory.Create("Models.Http.Common"); + return new CommonResponse(ErrorCodes.Http.Common.Content.TooBig, localizer["ContentTooBig", maxLength]); } - public int Code { get; set; } - public string? Message { get; set; } + internal static CommonResponse UnmatchedLength_Smaller(IStringLocalizerFactory localizerFactory) + { + var localizer = localizerFactory.Create("Models.Http.Common"); + return new CommonResponse(ErrorCodes.Http.Common.Content.UnmatchedLength_Smaller, localizer["ContentUnmatchedLengthSmaller"]); + } + internal static CommonResponse UnmatchedLength_Bigger(IStringLocalizerFactory localizerFactory) + { + var localizer = localizerFactory.Create("Models.Http.Common"); + return new CommonResponse(ErrorCodes.Http.Common.Content.UnmatchedLength_Bigger, localizer["ContentUnmatchedLengthBigger"]); + } } + public class CommonDataResponse : CommonResponse { public CommonDataResponse() @@ -87,13 +115,13 @@ namespace Timeline.Models.Http internal static CommonPutResponse Create(IStringLocalizerFactory localizerFactory) { var localizer = localizerFactory.Create("Models.Http.Common"); - return new CommonPutResponse(0, localizer["ResponsePutCreate"], true); + return new CommonPutResponse(0, localizer["PutCreate"], true); } internal static CommonPutResponse Modify(IStringLocalizerFactory localizerFactory) { var localizer = localizerFactory.Create("Models.Http.Common"); - return new CommonPutResponse(0, localizer["ResponsePutModify"], false); + return new CommonPutResponse(0, localizer["PutModify"], false); } } @@ -124,13 +152,13 @@ namespace Timeline.Models.Http internal static CommonDeleteResponse Delete(IStringLocalizerFactory localizerFactory) { var localizer = localizerFactory.Create("Models.Http.Common"); - return new CommonDeleteResponse(0, localizer["ResponseDeleteDelete"], true); + return new CommonDeleteResponse(0, localizer["DeleteDelete"], true); } internal static CommonDeleteResponse NotExist(IStringLocalizerFactory localizerFactory) { var localizer = localizerFactory.Create("Models.Models.Http.Common"); - return new CommonDeleteResponse(0, localizer["ResponseDeleteNotExist"], false); + return new CommonDeleteResponse(0, localizer["DeleteNotExist"], false); } } } diff --git a/Timeline/Resources/Controllers/UserAvatarController.Designer.cs b/Timeline/Resources/Controllers/UserAvatarController.Designer.cs new file mode 100644 index 00000000..e6eeb1e8 --- /dev/null +++ b/Timeline/Resources/Controllers/UserAvatarController.Designer.cs @@ -0,0 +1,171 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Resources.Controllers { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class UserAvatarController { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal UserAvatarController() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Resources.Controllers.UserAvatarController", typeof(UserAvatarController).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Unknown AvatarDataException.ErrorReason value.. + /// + internal static string ExceptionUnknownAvatarFormatError { + get { + return ResourceManager.GetString("ExceptionUnknownAvatarFormatError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempt to delete a avatar of other user as a non-admin failed.. + /// + internal static string LogDeleteForbid { + get { + return ResourceManager.GetString("LogDeleteForbid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempt to delete a avatar of a non-existent user failed.. + /// + internal static string LogDeleteNotExist { + get { + return ResourceManager.GetString("LogDeleteNotExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Succeed to delete a avatar of a user.. + /// + internal static string LogDeleteSuccess { + get { + return ResourceManager.GetString("LogDeleteSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempt to get a avatar with If-None-Match in bad format.. + /// + internal static string LogGetBadIfNoneMatch { + get { + return ResourceManager.GetString("LogGetBadIfNoneMatch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Returned full data for a get avatar attempt.. + /// + internal static string LogGetReturnData { + get { + return ResourceManager.GetString("LogGetReturnData", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Returned NotModify for a get avatar attempt.. + /// + internal static string LogGetReturnNotModify { + get { + return ResourceManager.GetString("LogGetReturnNotModify", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempt to get a avatar of a non-existent user failed.. + /// + internal static string LogGetUserNotExist { + get { + return ResourceManager.GetString("LogGetUserNotExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempt to put a avatar of other user as a non-admin failed.. + /// + internal static string LogPutForbid { + get { + return ResourceManager.GetString("LogPutForbid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Succeed to put a avatar of a user.. + /// + internal static string LogPutSuccess { + get { + return ResourceManager.GetString("LogPutSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempt to put a avatar of a bad format failed.. + /// + internal static string LogPutUserBadFormat { + get { + return ResourceManager.GetString("LogPutUserBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempt to put a avatar of a non-existent user failed.. + /// + internal static string LogPutUserNotExist { + get { + return ResourceManager.GetString("LogPutUserNotExist", resourceCulture); + } + } + } +} diff --git a/Timeline/Resources/Controllers/UserAvatarController.en.resx b/Timeline/Resources/Controllers/UserAvatarController.en.resx new file mode 100644 index 00000000..cf92ae6d --- /dev/null +++ b/Timeline/Resources/Controllers/UserAvatarController.en.resx @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Normal user can't delete other's avatar. + + + User does not exist. + + + User does not exist. + + + Image is not a square. + + + Decoding image failed. + + + Image format is not the one in header. + + + Normal user can't change other's avatar. + + + User does not exist. + + \ No newline at end of file diff --git a/Timeline/Resources/Controllers/UserAvatarController.resx b/Timeline/Resources/Controllers/UserAvatarController.resx new file mode 100644 index 00000000..58860c83 --- /dev/null +++ b/Timeline/Resources/Controllers/UserAvatarController.resx @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Unknown AvatarDataException.ErrorReason value. + + + Attempt to delete a avatar of other user as a non-admin failed. + + + Attempt to delete a avatar of a non-existent user failed. + + + Succeed to delete a avatar of a user. + + + Attempt to get a avatar with If-None-Match in bad format. + + + Returned full data for a get avatar attempt. + + + Returned NotModify for a get avatar attempt. + + + Attempt to get a avatar of a non-existent user failed. + + + Attempt to put a avatar of other user as a non-admin failed. + + + Succeed to put a avatar of a user. + + + Attempt to put a avatar of a bad format failed. + + + Attempt to put a avatar of a non-existent user failed. + + \ No newline at end of file diff --git a/Timeline/Resources/Controllers/UserAvatarController.zh.resx b/Timeline/Resources/Controllers/UserAvatarController.zh.resx new file mode 100644 index 00000000..94de1606 --- /dev/null +++ b/Timeline/Resources/Controllers/UserAvatarController.zh.resx @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 普通用户不能删除其他用户的头像。 + + + 用户不存在。 + + + 用户不存在。 + + + 图片不是正方形。 + + + 解码图片失败。 + + + 图片格式与请求头中指示的不一样。 + + + 普通用户不能修改其他用户的头像。 + + + 用户不存在。 + + \ No newline at end of file diff --git a/Timeline/Resources/Models/Http/Common.en.resx b/Timeline/Resources/Models/Http/Common.en.resx index 40d44191..10407d76 100644 --- a/Timeline/Resources/Models/Http/Common.en.resx +++ b/Timeline/Resources/Models/Http/Common.en.resx @@ -117,16 +117,37 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + Body is too big. It can't be bigger than {0}. + + + Actual body length is bigger than it in header. + + + Actual body length is smaller than it in header. + + An existent item is deleted. - + The item does not exist, so nothing is changed. - + + Header If-Non-Match is of bad format. + + + Header Content-Length is missing or of bad format. + + + Header Content-Type is required. + + + Header Content-Length must not be 0. + + A new item is created. - + An existent item is modified. \ No newline at end of file diff --git a/Timeline/Resources/Models/Http/Common.zh.resx b/Timeline/Resources/Models/Http/Common.zh.resx index b6d955d9..528dc7ab 100644 --- a/Timeline/Resources/Models/Http/Common.zh.resx +++ b/Timeline/Resources/Models/Http/Common.zh.resx @@ -117,16 +117,37 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + 请求体太大。它不能超过{0}. + + + 实际的请求体长度比头中指示的大。 + + + 实际的请求体长度比头中指示的小。 + + 删除了一个项目。 - + 要删除的项目不存在,什么都没有修改。 - + + 头If-Non-Match格式不对。 + + + 头Content-Length缺失或者格式不对。 + + + 缺少必需的头Content-Type。 + + + 头Content-Length不能为0。 + + 创建了一个新项目。 - + 修改了一个已存在的项目。 \ No newline at end of file diff --git a/Timeline/Resources/Services/Exception.Designer.cs b/Timeline/Resources/Services/Exception.Designer.cs index 24f6b8e6..ddf60f45 100644 --- a/Timeline/Resources/Services/Exception.Designer.cs +++ b/Timeline/Resources/Services/Exception.Designer.cs @@ -60,6 +60,51 @@ namespace Timeline.Resources.Services { } } + /// + /// Looks up a localized string similar to Avartar is of bad format because {0}.. + /// + internal static string AvatarFormatException { + get { + return ResourceManager.GetString("AvatarFormatException", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to image is not a square, aka, width is not equal to height. + /// + internal static string AvatarFormatExceptionBadSize { + get { + return ResourceManager.GetString("AvatarFormatExceptionBadSize", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to failed to decode image, see inner exception. + /// + internal static string AvatarFormatExceptionCantDecode { + get { + return ResourceManager.GetString("AvatarFormatExceptionCantDecode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to unknown error. + /// + internal static string AvatarFormatExceptionUnknownError { + get { + return ResourceManager.GetString("AvatarFormatExceptionUnknownError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to image's actual mime type is not the specified one. + /// + internal static string AvatarFormatExceptionUnmatchedFormat { + get { + return ResourceManager.GetString("AvatarFormatExceptionUnmatchedFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to The password is wrong.. /// diff --git a/Timeline/Resources/Services/Exception.resx b/Timeline/Resources/Services/Exception.resx index 408c45a1..12bf9afb 100644 --- a/Timeline/Resources/Services/Exception.resx +++ b/Timeline/Resources/Services/Exception.resx @@ -117,6 +117,21 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Avartar is of bad format because {0}. + + + image is not a square, aka, width is not equal to height + + + failed to decode image, see inner exception + + + unknown error + + + image's actual mime type is not the specified one + The password is wrong. diff --git a/Timeline/Resources/Services/UserAvatarService.Designer.cs b/Timeline/Resources/Services/UserAvatarService.Designer.cs new file mode 100644 index 00000000..cabc9ede --- /dev/null +++ b/Timeline/Resources/Services/UserAvatarService.Designer.cs @@ -0,0 +1,108 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Resources.Services { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class UserAvatarService { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal UserAvatarService() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Resources.Services.UserAvatarService", typeof(UserAvatarService).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Data of avatar is null.. + /// + internal static string ArgumentAvatarDataNull { + get { + return ResourceManager.GetString("ArgumentAvatarDataNull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Type of avatar is null.. + /// + internal static string ArgumentAvatarTypeNull { + get { + return ResourceManager.GetString("ArgumentAvatarTypeNull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Database corupted! One of type and data of a avatar is null but the other is not.. + /// + internal static string DatabaseCorruptedDataAndTypeNotSame { + get { + return ResourceManager.GetString("DatabaseCorruptedDataAndTypeNotSame", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Created an entry in user_avatars.. + /// + internal static string LogCreateEntity { + get { + return ResourceManager.GetString("LogCreateEntity", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Updated an entry in user_avatars.. + /// + internal static string LogUpdateEntity { + get { + return ResourceManager.GetString("LogUpdateEntity", resourceCulture); + } + } + } +} diff --git a/Timeline/Resources/Services/UserAvatarService.resx b/Timeline/Resources/Services/UserAvatarService.resx new file mode 100644 index 00000000..ab6389ff --- /dev/null +++ b/Timeline/Resources/Services/UserAvatarService.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Data of avatar is null. + + + Type of avatar is null. + + + Database corupted! One of type and data of a avatar is null but the other is not. + + + Created an entry in user_avatars. + + + Updated an entry in user_avatars. + + \ No newline at end of file diff --git a/Timeline/Services/AvatarFormatException.cs b/Timeline/Services/AvatarFormatException.cs new file mode 100644 index 00000000..788eabb2 --- /dev/null +++ b/Timeline/Services/AvatarFormatException.cs @@ -0,0 +1,51 @@ +using System; +using System.Globalization; + +namespace Timeline.Services +{ + /// + /// Thrown when avatar is of bad format. + /// + [Serializable] + public class AvatarFormatException : Exception + { + public enum ErrorReason + { + /// + /// Decoding image failed. + /// + CantDecode, + /// + /// Decoding succeeded but the real type is not the specified type. + /// + UnmatchedFormat, + /// + /// Image is not a square. + /// + BadSize + } + + public AvatarFormatException() : base(MakeMessage(null)) { } + public AvatarFormatException(string message) : base(message) { } + public AvatarFormatException(string message, Exception inner) : base(message, inner) { } + + public AvatarFormatException(Avatar avatar, ErrorReason error) : base(MakeMessage(error)) { Avatar = avatar; Error = error; } + public AvatarFormatException(Avatar avatar, ErrorReason error, Exception inner) : base(MakeMessage(error), inner) { Avatar = avatar; Error = error; } + + protected AvatarFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + private static string MakeMessage(ErrorReason? reason) => + string.Format(CultureInfo.InvariantCulture, Resources.Services.Exception.AvatarFormatException, reason switch + { + ErrorReason.CantDecode => Resources.Services.Exception.AvatarFormatExceptionCantDecode, + ErrorReason.UnmatchedFormat => Resources.Services.Exception.AvatarFormatExceptionUnmatchedFormat, + ErrorReason.BadSize => Resources.Services.Exception.AvatarFormatExceptionBadSize, + _ => Resources.Services.Exception.AvatarFormatExceptionUnknownError + }); + + public ErrorReason? Error { get; set; } + public Avatar? Avatar { get; set; } + } +} diff --git a/Timeline/Services/DatabaseExtensions.cs b/Timeline/Services/DatabaseExtensions.cs index a37cf05b..62b22f00 100644 --- a/Timeline/Services/DatabaseExtensions.cs +++ b/Timeline/Services/DatabaseExtensions.cs @@ -4,22 +4,27 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; +using Timeline.Models.Validation; namespace Timeline.Services { - public static class DatabaseExtensions + internal static class DatabaseExtensions { /// /// Check the existence and get the id of the user. /// /// The username of the user. /// The user id. - /// Thrown if is null or empty. + /// Thrown if is null. + /// Thrown if is of bad format. /// Thrown if user does not exist. - public static async Task CheckAndGetUser(DbSet userDbSet, string username) + internal static async Task CheckAndGetUser(DbSet userDbSet, UsernameValidator validator, string username) { - if (string.IsNullOrEmpty(username)) - throw new ArgumentException("Username is null or empty.", nameof(username)); + if (username == null) + throw new ArgumentNullException(nameof(username)); + var (result, messageGenerator) = validator.Validate(username); + if (!result) + throw new UsernameBadFormatException(username, messageGenerator(null)); var userId = await userDbSet.Where(u => u.Name == username).Select(u => u.Id).SingleOrDefaultAsync(); if (userId == 0) diff --git a/Timeline/Services/ETagGenerator.cs b/Timeline/Services/ETagGenerator.cs index e2abebdc..e518f01f 100644 --- a/Timeline/Services/ETagGenerator.cs +++ b/Timeline/Services/ETagGenerator.cs @@ -5,13 +5,20 @@ namespace Timeline.Services { public interface IETagGenerator { + /// + /// Generate a etag for given source. + /// + /// The source data. + /// The generated etag. + /// Thrown if is null. string Generate(byte[] source); } - public class ETagGenerator : IETagGenerator, IDisposable + public sealed class ETagGenerator : IETagGenerator, IDisposable { private readonly SHA1 _sha1; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", Justification = "Sha1 is enough ??? I don't know.")] public ETagGenerator() { _sha1 = SHA1.Create(); @@ -19,15 +26,19 @@ namespace Timeline.Services public string Generate(byte[] source) { - if (source == null || source.Length == 0) - throw new ArgumentException("Source is null or empty.", nameof(source)); + if (source == null) + throw new ArgumentNullException(nameof(source)); return Convert.ToBase64String(_sha1.ComputeHash(source)); } + private bool _disposed = false; // To detect redundant calls + public void Dispose() { + if (_disposed) return; _sha1.Dispose(); + _disposed = true; } } } diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index ecec5a31..4c65a0fa 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -10,53 +10,24 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; +using Timeline.Helpers; +using Timeline.Models.Validation; namespace Timeline.Services { public class Avatar { - public string Type { get; set; } - public byte[] Data { get; set; } + public string Type { get; set; } = default!; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "DTO Object")] + public byte[] Data { get; set; } = default!; } public class AvatarInfo { - public Avatar Avatar { get; set; } + public Avatar Avatar { get; set; } = default!; public DateTime LastModified { get; set; } } - /// - /// Thrown when avatar is of bad format. - /// - [Serializable] - public class AvatarDataException : Exception - { - public enum ErrorReason - { - /// - /// Decoding image failed. - /// - CantDecode, - /// - /// Decoding succeeded but the real type is not the specified type. - /// - UnmatchedFormat, - /// - /// Image is not a square. - /// - BadSize - } - - public AvatarDataException(Avatar avatar, ErrorReason error, string message) : base(message) { Avatar = avatar; Error = error; } - public AvatarDataException(Avatar avatar, ErrorReason error, string message, Exception inner) : base(message, inner) { Avatar = avatar; Error = error; } - protected AvatarDataException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public ErrorReason Error { get; set; } - public Avatar Avatar { get; set; } - } - /// /// Provider for default user avatar. /// @@ -83,7 +54,7 @@ namespace Timeline.Services /// Validate a avatar's format and size info. /// /// The avatar to validate. - /// Thrown when validation failed. + /// Thrown when validation failed. Task Validate(Avatar avatar); } @@ -94,16 +65,18 @@ namespace Timeline.Services /// /// The username of the user to get avatar etag of. /// The etag. - /// Thrown if is null or empty. + /// Thrown if is null. + /// Thrown if the is of bad format. /// Thrown if the user does not exist. Task GetAvatarETag(string username); /// - /// Get avatar of a user. If the user has no avatar, a default one is returned. + /// Get avatar of a user. If the user has no avatar set, a default one is returned. /// /// The username of the user to get avatar of. /// The avatar info. - /// Thrown if is null or empty. + /// Thrown if is null. + /// Thrown if the is of bad format. /// Thrown if the user does not exist. Task GetAvatar(string username); @@ -112,38 +85,41 @@ namespace Timeline.Services /// /// The username of the user to set avatar for. /// The avatar. Can be null to delete the saved avatar. - /// Throw if is null or empty. - /// Or thrown if is not null but is null or empty or is null. + /// Throw if is null. + /// Thrown if any field in is null when is not null. + /// Thrown if the is of bad format. /// Thrown if the user does not exist. - /// Thrown if avatar is of bad format. - Task SetAvatar(string username, Avatar avatar); + /// Thrown if avatar is of bad format. + Task SetAvatar(string username, Avatar? avatar); } + // TODO! : Make this configurable. public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider { - private readonly IWebHostEnvironment _environment; - private readonly IETagGenerator _eTagGenerator; - private byte[] _cacheData; + private readonly string _avatarPath; + + private byte[] _cacheData = default!; private DateTime _cacheLastModified; - private string _cacheETag; + private string _cacheETag = default!; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "DI.")] public DefaultUserAvatarProvider(IWebHostEnvironment environment, IETagGenerator eTagGenerator) { - _environment = environment; + _avatarPath = Path.Combine(environment.ContentRootPath, "default-avatar.png"); _eTagGenerator = eTagGenerator; } private async Task CheckAndInit() { - if (_cacheData != null) - return; - - var path = Path.Combine(_environment.ContentRootPath, "default-avatar.png"); - _cacheData = await File.ReadAllBytesAsync(path); - _cacheLastModified = File.GetLastWriteTime(path); - _cacheETag = _eTagGenerator.Generate(_cacheData); + var path = _avatarPath; + if (_cacheData == null || File.GetLastWriteTime(path) > _cacheLastModified) + { + _cacheData = await File.ReadAllBytesAsync(path); + _cacheLastModified = File.GetLastWriteTime(path); + _cacheETag = _eTagGenerator.Generate(_cacheData); + } } public async Task GetDefaultAvatarETag() @@ -175,17 +151,15 @@ namespace Timeline.Services { try { - using (var image = Image.Load(avatar.Data, out IImageFormat format)) - { - if (!format.MimeTypes.Contains(avatar.Type)) - throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.UnmatchedFormat, "Image's actual mime type is not the specified one."); - if (image.Width != image.Height) - throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.BadSize, "Image is not a square, aka, width is not equal to height."); - } + using var image = Image.Load(avatar.Data, out IImageFormat format); + if (!format.MimeTypes.Contains(avatar.Type)) + throw new AvatarFormatException(avatar, AvatarFormatException.ErrorReason.UnmatchedFormat); + if (image.Width != image.Height) + throw new AvatarFormatException(avatar, AvatarFormatException.ErrorReason.BadSize); } catch (UnknownImageFormatException e) { - throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.CantDecode, "Failed to decode image. See inner exception.", e); + throw new AvatarFormatException(avatar, AvatarFormatException.ErrorReason.CantDecode, e); } }); } @@ -203,6 +177,8 @@ namespace Timeline.Services private readonly IETagGenerator _eTagGenerator; + private readonly UsernameValidator _usernameValidator; + public UserAvatarService( ILogger logger, DatabaseContext database, @@ -215,13 +191,14 @@ namespace Timeline.Services _defaultUserAvatarProvider = defaultUserAvatarProvider; _avatarValidator = avatarValidator; _eTagGenerator = eTagGenerator; + _usernameValidator = new UsernameValidator(); } public async Task GetAvatarETag(string username) { - var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, username); + var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, _usernameValidator, username); - var eTag = (await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.ETag }).SingleAsync()).ETag; + var eTag = (await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.ETag }).SingleOrDefaultAsync())?.ETag; if (eTag == null) return await _defaultUserAvatarProvider.GetDefaultAvatarETag(); else @@ -230,54 +207,57 @@ namespace Timeline.Services public async Task GetAvatar(string username) { - var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, username); + var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, _usernameValidator, username); - var avatar = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.Type, a.Data, a.LastModified }).SingleAsync(); + var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.Type, a.Data, a.LastModified }).SingleOrDefaultAsync(); - if ((avatar.Type == null) != (avatar.Data == null)) + if (avatarEntity != null) { - _logger.LogCritical("Database corupted! One of type and data of a avatar is null but the other is not."); - throw new DatabaseCorruptedException(); - } + if (!LanguageHelper.AreSame(avatarEntity.Data == null, avatarEntity.Type == null)) + { + var message = Resources.Services.UserAvatarService.DatabaseCorruptedDataAndTypeNotSame; + _logger.LogCritical(message); + throw new DatabaseCorruptedException(message); + } - if (avatar.Data == null) - { - var defaultAvatar = await _defaultUserAvatarProvider.GetDefaultAvatar(); - defaultAvatar.LastModified = defaultAvatar.LastModified > avatar.LastModified ? defaultAvatar.LastModified : avatar.LastModified; - return defaultAvatar; - } - else - { - return new AvatarInfo + if (avatarEntity.Data != null) { - Avatar = new Avatar + return new AvatarInfo { - Type = avatar.Type, - Data = avatar.Data - }, - LastModified = avatar.LastModified - }; + Avatar = new Avatar + { + Type = avatarEntity.Type!, + Data = avatarEntity.Data + }, + LastModified = avatarEntity.LastModified + }; + } } + var defaultAvatar = await _defaultUserAvatarProvider.GetDefaultAvatar(); + if (avatarEntity != null) + defaultAvatar.LastModified = defaultAvatar.LastModified > avatarEntity.LastModified ? defaultAvatar.LastModified : avatarEntity.LastModified; + return defaultAvatar; } - public async Task SetAvatar(string username, Avatar avatar) + public async Task SetAvatar(string username, Avatar? avatar) { if (avatar != null) { - if (string.IsNullOrEmpty(avatar.Type)) - throw new ArgumentException("Type of avatar is null or empty.", nameof(avatar)); if (avatar.Data == null) - throw new ArgumentException("Data of avatar is null.", nameof(avatar)); + throw new ArgumentException(Resources.Services.UserAvatarService.ArgumentAvatarDataNull, nameof(avatar)); + if (avatar.Type == null) + throw new ArgumentException(Resources.Services.UserAvatarService.ArgumentAvatarTypeNull, nameof(avatar)); } - var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, username); - - var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleAsync(); + var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, _usernameValidator, username); + var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); if (avatar == null) { - if (avatarEntity.Data == null) + if (avatarEntity == null || avatarEntity.Data == null) + { return; + } else { avatarEntity.Data = null; @@ -285,18 +265,29 @@ namespace Timeline.Services avatarEntity.ETag = null; avatarEntity.LastModified = DateTime.Now; await _database.SaveChangesAsync(); - _logger.LogInformation("Updated an entry in user_avatars."); + _logger.LogInformation(Resources.Services.UserAvatarService.LogUpdateEntity); } } else { await _avatarValidator.Validate(avatar); - avatarEntity.Type = avatar.Type; + var create = avatarEntity == null; + if (create) + { + avatarEntity = new UserAvatar(); + } + avatarEntity!.Type = avatar.Type; avatarEntity.Data = avatar.Data; avatarEntity.ETag = _eTagGenerator.Generate(avatar.Data); avatarEntity.LastModified = DateTime.Now; + if (create) + { + _database.UserAvatars.Add(avatarEntity); + } await _database.SaveChangesAsync(); - _logger.LogInformation("Updated an entry in user_avatars."); + _logger.LogInformation(create ? + Resources.Services.UserAvatarService.LogCreateEntity + : Resources.Services.UserAvatarService.LogUpdateEntity); } } } @@ -308,7 +299,7 @@ namespace Timeline.Services services.TryAddTransient(); services.AddScoped(); services.AddSingleton(); - services.AddSingleton(); + services.AddTransient(); } } } diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index d706d05e..f1317856 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -272,7 +272,7 @@ namespace Timeline.Services Name = username, EncryptedPassword = _passwordService.HashPassword(password), RoleString = UserRoleConvert.ToString(administrator), - Avatar = UserAvatar.Create(DateTime.Now) + Avatar = null }; await _databaseContext.AddAsync(newUser); await _databaseContext.SaveChangesAsync(); diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index 0ba34471..519a802d 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -49,6 +49,11 @@ True TokenController.resx + + True + True + UserAvatarController.resx + True True @@ -69,6 +74,11 @@ True Exception.resx + + True + True + UserAvatarService.resx + True True @@ -97,6 +107,10 @@ + + ResXFileCodeGenerator + UserAvatarController.Designer.cs + ResXFileCodeGenerator UserController.Designer.cs @@ -116,6 +130,10 @@ ResXFileCodeGenerator Exception.Designer.cs + + ResXFileCodeGenerator + UserAvatarService.Designer.cs + ResXFileCodeGenerator UserService.Designer.cs -- cgit v1.2.3