From fbaa8cab95a91b887bbd2d108d27c5abb38e4e29 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 28 Oct 2019 23:35:00 +0800 Subject: Add UserDetailController unit tests. --- .../Controllers/UserDetailControllerTest.cs | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 Timeline.Tests/Controllers/UserDetailControllerTest.cs (limited to 'Timeline.Tests/Controllers/UserDetailControllerTest.cs') diff --git a/Timeline.Tests/Controllers/UserDetailControllerTest.cs b/Timeline.Tests/Controllers/UserDetailControllerTest.cs new file mode 100644 index 00000000..99341c40 --- /dev/null +++ b/Timeline.Tests/Controllers/UserDetailControllerTest.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Filters; +using Timeline.Models.Validation; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Xunit; + +namespace Timeline.Tests.Controllers +{ + public class UserDetailControllerTest : IDisposable + { + private readonly Mock _mockUserDetailService; + private readonly UserDetailController _controller; + + public UserDetailControllerTest() + { + _mockUserDetailService = new Mock(); + _controller = new UserDetailController(_mockUserDetailService.Object); + } + + public void Dispose() + { + _controller.Dispose(); + } + + [Fact] + public void AttributeTest() + { + typeof(UserDetailController).Should().BeDecoratedWith(); + + var getNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.GetNickname)); + getNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + getNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + + var putNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.PutNickname)); + putNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + putNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + var stringLengthAttributeOnPutBody = putNickname.GetParameter("body").Should().BeDecoratedWith() + .And.BeDecoratedWith() + .Which; + stringLengthAttributeOnPutBody.MinimumLength.Should().Be(1); + stringLengthAttributeOnPutBody.MaximumLength.Should().Be(10); + + var deleteNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.DeleteNickname)); + deleteNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + deleteNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + } + + [Fact] + public async Task GetNickname_ShouldWork() + { + const string username = "uuu"; + const string nickname = "nnn"; + _mockUserDetailService.Setup(s => s.GetNickname(username)).ReturnsAsync(nickname); + var actionResult = await _controller.GetNickname(username); + actionResult.Result.Should().BeAssignableTo(nickname); + _mockUserDetailService.VerifyAll(); + } + + [Fact] + public async Task PutNickname_ShouldWork() + { + const string username = "uuu"; + const string nickname = "nnn"; + _mockUserDetailService.Setup(s => s.SetNickname(username, nickname)).Returns(Task.CompletedTask); + var actionResult = await _controller.PutNickname(username, nickname); + actionResult.Should().BeAssignableTo(); + _mockUserDetailService.VerifyAll(); + } + + [Fact] + public async Task DeleteNickname_ShouldWork() + { + const string username = "uuu"; + _mockUserDetailService.Setup(s => s.SetNickname(username, null)).Returns(Task.CompletedTask); + var actionResult = await _controller.DeleteNickname(username); + actionResult.Should().BeAssignableTo(); + _mockUserDetailService.VerifyAll(); + } + } +} -- cgit v1.2.3 From 2198ad257a2c049f3601a6f95b8906c5be8b27d5 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 31 Oct 2019 00:56:46 +0800 Subject: Continue to construct feature and tests. --- Timeline.Tests/Controllers/UserControllerTest.cs | 1 - .../Controllers/UserDetailControllerTest.cs | 5 ++ Timeline.Tests/Helpers/HttpClientExtensions.cs | 12 +++ Timeline.Tests/Helpers/ResponseAssertions.cs | 5 ++ Timeline.Tests/IntegratedTests/UserAvatarTest.cs | 4 +- Timeline.Tests/IntegratedTests/UserDetailTest.cs | 83 ++++++++++++++++++ Timeline.Tests/Properties/launchSettings.json | 52 ++++++------ Timeline.Tests/Timeline.Tests.csproj | 4 + Timeline/Auth/Attribute.cs | 21 +++++ Timeline/Auth/MyAuthenticationHandler.cs | 99 ++++++++++++++++++++++ Timeline/Auth/PrincipalExtensions.cs | 13 +++ Timeline/Authentication/Attribute.cs | 21 ----- Timeline/Authentication/AuthHandler.cs | 99 ---------------------- Timeline/Authentication/PrincipalExtensions.cs | 13 --- .../Controllers/Testing/TestingAuthController.cs | 2 +- Timeline/Controllers/UserAvatarController.cs | 2 +- Timeline/Controllers/UserController.cs | 2 +- Timeline/Controllers/UserDetailController.cs | 5 ++ Timeline/Filters/User.cs | 66 ++++++++++++--- Timeline/Formatters/StringInputFormatter.cs | 27 ++++++ Timeline/Resources/Filters.Designer.cs | 36 ++++++++ Timeline/Resources/Filters.resx | 12 +++ Timeline/Resources/Filters.zh.resx | 3 + Timeline/Startup.cs | 18 ++-- 24 files changed, 424 insertions(+), 181 deletions(-) create mode 100644 Timeline.Tests/IntegratedTests/UserDetailTest.cs create mode 100644 Timeline/Auth/Attribute.cs create mode 100644 Timeline/Auth/MyAuthenticationHandler.cs create mode 100644 Timeline/Auth/PrincipalExtensions.cs delete mode 100644 Timeline/Authentication/Attribute.cs delete mode 100644 Timeline/Authentication/AuthHandler.cs delete mode 100644 Timeline/Authentication/PrincipalExtensions.cs create mode 100644 Timeline/Formatters/StringInputFormatter.cs (limited to 'Timeline.Tests/Controllers/UserDetailControllerTest.cs') diff --git a/Timeline.Tests/Controllers/UserControllerTest.cs b/Timeline.Tests/Controllers/UserControllerTest.cs index a9cce970..83b8cdcf 100644 --- a/Timeline.Tests/Controllers/UserControllerTest.cs +++ b/Timeline.Tests/Controllers/UserControllerTest.cs @@ -13,7 +13,6 @@ using Timeline.Models.Http; using Timeline.Services; using Timeline.Tests.Helpers; using Timeline.Tests.Mock.Data; -using Timeline.Tests.Mock.Services; using Xunit; using static Timeline.ErrorCodes.Http.User; diff --git a/Timeline.Tests/Controllers/UserDetailControllerTest.cs b/Timeline.Tests/Controllers/UserDetailControllerTest.cs index 99341c40..ffd88790 100644 --- a/Timeline.Tests/Controllers/UserDetailControllerTest.cs +++ b/Timeline.Tests/Controllers/UserDetailControllerTest.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Moq; using System; @@ -42,6 +43,8 @@ namespace Timeline.Tests.Controllers var putNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.PutNickname)); putNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith() + .And.BeDecoratedWith() .And.BeDecoratedWith(); putNickname.GetParameter("username").Should().BeDecoratedWith() .And.BeDecoratedWith(); @@ -53,6 +56,8 @@ namespace Timeline.Tests.Controllers var deleteNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.DeleteNickname)); deleteNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith() + .And.BeDecoratedWith() .And.BeDecoratedWith(); deleteNickname.GetParameter("username").Should().BeDecoratedWith() .And.BeDecoratedWith(); diff --git a/Timeline.Tests/Helpers/HttpClientExtensions.cs b/Timeline.Tests/Helpers/HttpClientExtensions.cs index 38641f90..6513bbe7 100644 --- a/Timeline.Tests/Helpers/HttpClientExtensions.cs +++ b/Timeline.Tests/Helpers/HttpClientExtensions.cs @@ -35,5 +35,17 @@ namespace Timeline.Tests.Helpers content.Headers.ContentType = new MediaTypeHeaderValue(mimeType); return client.PutAsync(url, content); } + + public static Task PutStringAsync(this HttpClient client, string url, string body, string mimeType = null) + { + return client.PutStringAsync(new Uri(url, UriKind.RelativeOrAbsolute), body, mimeType); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")] + public static Task PutStringAsync(this HttpClient client, Uri url, string body, string mimeType = null) + { + var content = new StringContent(body, Encoding.UTF8, mimeType ?? MediaTypeNames.Text.Plain); + return client.PutAsync(url, content); + } } } diff --git a/Timeline.Tests/Helpers/ResponseAssertions.cs b/Timeline.Tests/Helpers/ResponseAssertions.cs index 08f10b2b..db86ff59 100644 --- a/Timeline.Tests/Helpers/ResponseAssertions.cs +++ b/Timeline.Tests/Helpers/ResponseAssertions.cs @@ -91,6 +91,11 @@ namespace Timeline.Tests.Helpers var result = JsonConvert.DeserializeObject(body); return new AndWhichConstraint(Subject, result); } + + internal void HaveStatusCode(object statusCode) + { + throw new NotImplementedException(); + } } public static class AssertionResponseExtensions diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs index ad2e11df..b338665e 100644 --- a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs +++ b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs @@ -22,12 +22,12 @@ using static Timeline.ErrorCodes.Http.UserAvatar; namespace Timeline.Tests.IntegratedTests { - public class UserAvatarUnitTest : IClassFixture>, IDisposable + public class UserAvatarTest : IClassFixture>, IDisposable { private readonly TestApplication _testApp; private readonly WebApplicationFactory _factory; - public UserAvatarUnitTest(WebApplicationFactory factory) + public UserAvatarTest(WebApplicationFactory factory) { _testApp = new TestApplication(factory); _factory = _testApp.Factory; diff --git a/Timeline.Tests/IntegratedTests/UserDetailTest.cs b/Timeline.Tests/IntegratedTests/UserDetailTest.cs new file mode 100644 index 00000000..ff2c03a5 --- /dev/null +++ b/Timeline.Tests/IntegratedTests/UserDetailTest.cs @@ -0,0 +1,83 @@ +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 Timeline.Tests.Mock.Data; +using Xunit; + +namespace Timeline.Tests.IntegratedTests +{ + public class UserDetailTest : IClassFixture>, IDisposable + { + private readonly TestApplication _testApp; + private readonly WebApplicationFactory _factory; + + public UserDetailTest(WebApplicationFactory factory) + { + _testApp = new TestApplication(factory); + _factory = _testApp.Factory; + } + + public void Dispose() + { + _testApp.Dispose(); + } + + [Fact] + public async Task PermissionTest() + { + { // unauthorize + using var client = _factory.CreateDefaultClient(); + { // GET + var res = await client.GetAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // PUT + var res = await client.PutStringAsync($"users/{MockUser.User.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); + } + { // DELETE + var res = await client.DeleteAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); + } + } + { // user + using var client = await _factory.CreateClientAsUser(); + { // GET + var res = await client.GetAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // PUT self + var res = await client.PutStringAsync($"users/{MockUser.User.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // PUT other + var res = await client.PutStringAsync($"users/{MockUser.Admin.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden); + } + { // DELETE self + var res = await client.DeleteAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // DELETE other + var res = await client.DeleteAsync($"users/{MockUser.Admin.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden); + } + } + { // user + using var client = await _factory.CreateClientAsAdmin(); + { // PUT other + var res = await client.PutStringAsync($"users/{MockUser.User.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // DELETE other + var res = await client.DeleteAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + } + } + } +} diff --git a/Timeline.Tests/Properties/launchSettings.json b/Timeline.Tests/Properties/launchSettings.json index 0c1cae5d..7a94d57a 100644 --- a/Timeline.Tests/Properties/launchSettings.json +++ b/Timeline.Tests/Properties/launchSettings.json @@ -1,27 +1,27 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:11197/", - "sslPort": 0 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "Timeline.Tests": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:11199/;http://localhost:11198/" - } - } +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52040/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Timeline.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:52041/" + } + } } \ No newline at end of file diff --git a/Timeline.Tests/Timeline.Tests.csproj b/Timeline.Tests/Timeline.Tests.csproj index 497a00b7..21e887eb 100644 --- a/Timeline.Tests/Timeline.Tests.csproj +++ b/Timeline.Tests/Timeline.Tests.csproj @@ -31,4 +31,8 @@ + + + + diff --git a/Timeline/Auth/Attribute.cs b/Timeline/Auth/Attribute.cs new file mode 100644 index 00000000..86d0109b --- /dev/null +++ b/Timeline/Auth/Attribute.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authorization; +using Timeline.Entities; + +namespace Timeline.Auth +{ + public class AdminAuthorizeAttribute : AuthorizeAttribute + { + public AdminAuthorizeAttribute() + { + Roles = UserRoles.Admin; + } + } + + public class UserAuthorizeAttribute : AuthorizeAttribute + { + public UserAuthorizeAttribute() + { + Roles = UserRoles.User; + } + } +} diff --git a/Timeline/Auth/MyAuthenticationHandler.cs b/Timeline/Auth/MyAuthenticationHandler.cs new file mode 100644 index 00000000..f5dcd697 --- /dev/null +++ b/Timeline/Auth/MyAuthenticationHandler.cs @@ -0,0 +1,99 @@ +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; +using static Timeline.Resources.Authentication.AuthHandler; + +namespace Timeline.Auth +{ + public static class AuthenticationConstants + { + public const string Scheme = "Bearer"; + public const string DisplayName = "My Jwt Auth Scheme"; + } + + public class MyAuthenticationOptions : 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 MyAuthenticationHandler : AuthenticationHandler + { + private readonly ILogger _logger; + private readonly IUserService _userService; + + public MyAuthenticationHandler(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(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(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(LogTokenNotFound); + return AuthenticateResult.NoResult(); + } + + try + { + var userInfo = await _userService.VerifyToken(token); + + var identity = new ClaimsIdentity(AuthenticationConstants.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, AuthenticationConstants.Scheme)); + } + catch (Exception e) when (!(e is ArgumentException)) + { + _logger.LogInformation(e, LogTokenValidationFail); + return AuthenticateResult.Fail(e); + } + } + } +} diff --git a/Timeline/Auth/PrincipalExtensions.cs b/Timeline/Auth/PrincipalExtensions.cs new file mode 100644 index 00000000..ad7a887f --- /dev/null +++ b/Timeline/Auth/PrincipalExtensions.cs @@ -0,0 +1,13 @@ +using System.Security.Principal; +using Timeline.Entities; + +namespace Timeline.Auth +{ + internal static class PrincipalExtensions + { + internal static bool IsAdministrator(this IPrincipal principal) + { + return principal.IsInRole(UserRoles.Admin); + } + } +} diff --git a/Timeline/Authentication/Attribute.cs b/Timeline/Authentication/Attribute.cs deleted file mode 100644 index 370b37e1..00000000 --- a/Timeline/Authentication/Attribute.cs +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 2b457eb1..00000000 --- a/Timeline/Authentication/AuthHandler.cs +++ /dev/null @@ -1,99 +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; -using static Timeline.Resources.Authentication.AuthHandler; - -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(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(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(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, LogTokenValidationFail); - return AuthenticateResult.Fail(e); - } - } - } -} diff --git a/Timeline/Authentication/PrincipalExtensions.cs b/Timeline/Authentication/PrincipalExtensions.cs deleted file mode 100644 index 8d77ab62..00000000 --- a/Timeline/Authentication/PrincipalExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -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/Controllers/Testing/TestingAuthController.cs b/Timeline/Controllers/Testing/TestingAuthController.cs index 67b5b2ef..4d3b3ec7 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.Authentication; +using Timeline.Auth; namespace Timeline.Controllers.Testing { diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index 7c77897d..7625f962 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.Authentication; +using Timeline.Auth; using Timeline.Filters; using Timeline.Helpers; using Timeline.Models.Http; diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 7b441c3a..0d950cd7 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System.Globalization; using System.Threading.Tasks; -using Timeline.Authentication; +using Timeline.Auth; using Timeline.Helpers; using Timeline.Models; using Timeline.Models.Http; diff --git a/Timeline/Controllers/UserDetailController.cs b/Timeline/Controllers/UserDetailController.cs index ef13b462..9de9899e 100644 --- a/Timeline/Controllers/UserDetailController.cs +++ b/Timeline/Controllers/UserDetailController.cs @@ -4,6 +4,7 @@ using Timeline.Filters; using Timeline.Models.Validation; using Timeline.Services; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; namespace Timeline.Controllers { @@ -25,6 +26,8 @@ namespace Timeline.Controllers } [HttpPut("users/{username}/nickname")] + [Authorize] + [SelfOrAdmin] [CatchUserNotExistException] public async Task PutNickname([FromRoute][Username] string username, [FromBody][StringLength(10, MinimumLength = 1)] string body) @@ -34,6 +37,8 @@ namespace Timeline.Controllers } [HttpDelete("users/{username}/nickname")] + [Authorize] + [SelfOrAdmin] [CatchUserNotExistException] public async Task DeleteNickname([FromRoute][Username] string username) { diff --git a/Timeline/Filters/User.cs b/Timeline/Filters/User.cs index 22fae938..16c76750 100644 --- a/Timeline/Filters/User.cs +++ b/Timeline/Filters/User.cs @@ -1,7 +1,13 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; +using Timeline.Auth; using Timeline.Models.Http; +using Timeline.Services; +using static Timeline.Resources.Filters; namespace Timeline { @@ -13,9 +19,10 @@ namespace Timeline { public static class User // bbb = 101 { - public const int NotExist = 11010001; - } + public const int NotExist = 11010101; + public const int NotSelfOrAdminForbid = 11010201; + } } } } @@ -23,20 +30,59 @@ namespace Timeline namespace Timeline.Filters { + public class SelfOrAdminAttribute : ActionFilterAttribute + { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] + public override void OnActionExecuting(ActionExecutingContext context) + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + var user = context.HttpContext.User; + + if (user == null) + { + logger.LogError(LogSelfOrAdminNoUser); + return; + } + + if (context.ModelState.TryGetValue("username", out var model)) + { + if (model.RawValue is string username) + { + if (!user.IsAdministrator() && user.Identity.Name != username) + { + context.Result = new ObjectResult( + new CommonResponse(ErrorCodes.Http.Filter.User.NotSelfOrAdminForbid, MessageSelfOrAdminForbid)) + { StatusCode = StatusCodes.Status403Forbidden }; + } + } + else + { + logger.LogError(LogSelfOrAdminUsernameNotString); + } + } + else + { + logger.LogError(LogSelfOrAdminNoUsername); + } + } + } + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] public class CatchUserNotExistExceptionAttribute : ExceptionFilterAttribute { [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "ASP.Net already checked.")] public override void OnException(ExceptionContext context) { - var body = new CommonResponse( - ErrorCodes.Http.Filter.User.NotExist, - Resources.Filters.MessageUserNotExist); + if (context.Exception is UserNotExistException) + { + var body = new CommonResponse(ErrorCodes.Http.Filter.User.NotExist, MessageUserNotExist); - if (context.HttpContext.Request.Method == "GET") - context.Result = new NotFoundObjectResult(body); - else - context.Result = new BadRequestObjectResult(body); + if (context.HttpContext.Request.Method == "GET") + context.Result = new NotFoundObjectResult(body); + else + context.Result = new BadRequestObjectResult(body); + } } } } diff --git a/Timeline/Formatters/StringInputFormatter.cs b/Timeline/Formatters/StringInputFormatter.cs new file mode 100644 index 00000000..90847e36 --- /dev/null +++ b/Timeline/Formatters/StringInputFormatter.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; +using System.IO; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; + +namespace Timeline.Formatters +{ + public class StringInputFormatter : TextInputFormatter + { + public StringInputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Text.Plain)); + SupportedEncodings.Add(Encoding.UTF8); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] + public override async Task ReadRequestBodyAsync(InputFormatterContext context, Encoding effectiveEncoding) + { + var request = context.HttpContext.Request; + using var reader = new StreamReader(request.Body, effectiveEncoding); + var stringContent = await reader.ReadToEndAsync(); + return await InputFormatterResult.SuccessAsync(stringContent); + } + } +} diff --git a/Timeline/Resources/Filters.Designer.cs b/Timeline/Resources/Filters.Designer.cs index e3c8be41..3481e4ae 100644 --- a/Timeline/Resources/Filters.Designer.cs +++ b/Timeline/Resources/Filters.Designer.cs @@ -60,6 +60,33 @@ namespace Timeline.Resources { } } + /// + /// Looks up a localized string similar to You apply a SelfOrAdminAttribute on an action, but there is no user. Try add AuthorizeAttribute.. + /// + internal static string LogSelfOrAdminNoUser { + get { + return ResourceManager.GetString("LogSelfOrAdminNoUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You apply a SelfOrAdminAttribute on an action, but it does not have a model named username.. + /// + internal static string LogSelfOrAdminNoUsername { + get { + return ResourceManager.GetString("LogSelfOrAdminNoUsername", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You apply a SelfOrAdminAttribute on an action, found a model named username, but it is not string.. + /// + internal static string LogSelfOrAdminUsernameNotString { + get { + return ResourceManager.GetString("LogSelfOrAdminUsernameNotString", resourceCulture); + } + } + /// /// Looks up a localized string similar to Header Content-Length is missing or of bad format.. /// @@ -87,6 +114,15 @@ namespace Timeline.Resources { } } + /// + /// Looks up a localized string similar to You can't access the resource unless you are the owner or administrator.. + /// + internal static string MessageSelfOrAdminForbid { + get { + return ResourceManager.GetString("MessageSelfOrAdminForbid", resourceCulture); + } + } + /// /// Looks up a localized string similar to The user does not exist.. /// diff --git a/Timeline/Resources/Filters.resx b/Timeline/Resources/Filters.resx index ba1fcee8..b91d4612 100644 --- a/Timeline/Resources/Filters.resx +++ b/Timeline/Resources/Filters.resx @@ -117,6 +117,15 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + You apply a SelfOrAdminAttribute on an action, but there is no user. Try add AuthorizeAttribute. + + + You apply a SelfOrAdminAttribute on an action, but it does not have a model named username. + + + You apply a SelfOrAdminAttribute on an action, found a model named username, but it is not string. + Header Content-Length is missing or of bad format. @@ -126,6 +135,9 @@ Header Content-Type is required. + + You can't access the resource unless you are the owner or administrator. + The user does not exist. diff --git a/Timeline/Resources/Filters.zh.resx b/Timeline/Resources/Filters.zh.resx index 690a3e39..159ac04a 100644 --- a/Timeline/Resources/Filters.zh.resx +++ b/Timeline/Resources/Filters.zh.resx @@ -126,6 +126,9 @@ 缺少必需的请求头Content-Type。 + + 你无权访问该资源除非你是资源的拥有者或者管理员。 + 用户不存在。 diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index b44add6f..f6abf36d 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -8,9 +8,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.Collections.Generic; using System.Globalization; -using Timeline.Authentication; +using Timeline.Auth; using Timeline.Configs; using Timeline.Entities; +using Timeline.Formatters; using Timeline.Helpers; using Timeline.Services; @@ -31,17 +32,22 @@ namespace Timeline // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddControllers() + services.AddControllers(setup => + { + setup.InputFormatters.Add(new StringInputFormatter()); + }) .ConfigureApiBehaviorOptions(options => { options.InvalidModelStateResponseFactory = InvalidModelResponseFactory.Factory; }) - .AddNewtonsoftJson(); + .AddNewtonsoftJson(); // TODO: Remove this. services.Configure(Configuration.GetSection(nameof(JwtConfig))); var jwtConfig = Configuration.GetSection(nameof(JwtConfig)).Get(); - services.AddAuthentication(AuthConstants.Scheme) - .AddScheme(AuthConstants.Scheme, AuthConstants.DisplayName, o => { }); + services.AddAuthentication(AuthenticationConstants.Scheme) + .AddScheme(AuthenticationConstants.Scheme, AuthenticationConstants.DisplayName, o => { }); + services.AddAuthorization(); + var corsConfig = Configuration.GetSection("Cors").Get(); services.AddCors(setup => @@ -62,8 +68,8 @@ namespace Timeline services.AddScoped(); services.AddTransient(); services.AddTransient(); - services.AddUserAvatarService(); + services.AddScoped(); var databaseConfig = Configuration.GetSection(nameof(DatabaseConfig)).Get(); -- cgit v1.2.3