diff options
-rw-r--r-- | Timeline.Tests/AuthorizationUnitTest.cs | 14 | ||||
-rw-r--r-- | Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs (renamed from Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs) | 15 | ||||
-rw-r--r-- | Timeline.Tests/Helpers/TestUsers.cs | 40 | ||||
-rw-r--r-- | Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs | 21 | ||||
-rw-r--r-- | Timeline.Tests/JwtTokenUnitTest.cs | 4 | ||||
-rw-r--r-- | Timeline.Tests/Timeline.Tests.csproj | 2 | ||||
-rw-r--r-- | Timeline.Tests/UserUnitTest.cs | 36 | ||||
-rw-r--r-- | Timeline/Configs/DatabaseConfig.cs | 7 | ||||
-rw-r--r-- | Timeline/Controllers/AdminUserController.cs | 83 | ||||
-rw-r--r-- | Timeline/Controllers/TokenController.cs | 74 | ||||
-rw-r--r-- | Timeline/Controllers/UserController.cs | 93 | ||||
-rw-r--r-- | Timeline/Entities/AdminUser.cs | 14 | ||||
-rw-r--r-- | Timeline/Entities/UserInfo.cs | 64 | ||||
-rw-r--r-- | nuget.config | 3 |
14 files changed, 293 insertions, 177 deletions
diff --git a/Timeline.Tests/AuthorizationUnitTest.cs b/Timeline.Tests/AuthorizationUnitTest.cs index e450af06..28715ada 100644 --- a/Timeline.Tests/AuthorizationUnitTest.cs +++ b/Timeline.Tests/AuthorizationUnitTest.cs @@ -26,7 +26,7 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var response = await client.GetAsync(NeedAuthorizeUrl); + var response = await client.GetAsync(NeedAuthorizeUrl); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } @@ -34,10 +34,9 @@ namespace Timeline.Tests [Fact] public async Task AuthenticationTest() { - using (var client = _factory.CreateDefaultClient()) + using (var client = await _factory.CreateClientWithUser("user", "user")) { - var token = (await client.CreateUserTokenAsync("user", "user")).Token; - var response = await client.SendWithAuthenticationAsync(token, NeedAuthorizeUrl); + var response = await client.GetAsync(NeedAuthorizeUrl); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } @@ -58,12 +57,11 @@ namespace Timeline.Tests [Fact] public async Task AdminAuthorizationTest() { - using (var client = _factory.CreateDefaultClient()) + using (var client = await _factory.CreateClientWithUser("admin", "admin")) { - var token = (await client.CreateUserTokenAsync("admin", "admin")).Token; - var response1 = await client.SendWithAuthenticationAsync(token, BothUserAndAdminUrl); + var response1 = await client.GetAsync(BothUserAndAdminUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); - var response2 = await client.SendWithAuthenticationAsync(token, OnlyAdminUrl); + var response2 = await client.GetAsync(OnlyAdminUrl); Assert.Equal(HttpStatusCode.OK, response2.StatusCode); } } diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs index c0051c53..40191009 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Newtonsoft.Json; using System; using System.Net; using System.Net.Http; @@ -8,9 +9,9 @@ using Xunit; namespace Timeline.Tests.Helpers.Authentication { - public static class AuthenticationHttpClientExtensions + public static class AuthenticationExtensions { - private const string CreateTokenUrl = "/User/CreateToken"; + private const string CreateTokenUrl = "/token/create"; public static async Task<CreateTokenResponse> CreateUserTokenAsync(this HttpClient client, string username, string password, bool assertSuccess = true) { @@ -24,6 +25,14 @@ namespace Timeline.Tests.Helpers.Authentication return result; } + public static async Task<HttpClient> CreateClientWithUser<T>(this WebApplicationFactory<T> factory, string username, string password) where T : class + { + var client = factory.CreateDefaultClient(); + var token = (await client.CreateUserTokenAsync(username, password)).Token; + client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); + return client; + } + public static async Task<HttpResponseMessage> SendWithAuthenticationAsync(this HttpClient client, string token, string path, Action<HttpRequestMessage> requestBuilder = null) { var request = new HttpRequestMessage diff --git a/Timeline.Tests/Helpers/TestUsers.cs b/Timeline.Tests/Helpers/TestUsers.cs new file mode 100644 index 00000000..b7005d54 --- /dev/null +++ b/Timeline.Tests/Helpers/TestUsers.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using Timeline.Entities; +using Timeline.Models; +using Timeline.Services; + +namespace Timeline.Tests.Helpers +{ + public static class TestMockUsers + { + static TestMockUsers() + { + var mockUsers = new List<User>(); + var passwordService = new PasswordService(null); + + mockUsers.Add(new User + { + Name = "user", + EncryptedPassword = passwordService.HashPassword("user"), + RoleString = "user" + }); + mockUsers.Add(new User + { + Name = "admin", + EncryptedPassword = passwordService.HashPassword("admin"), + RoleString = "user,admin" + }); + + MockUsers = mockUsers; + + var mockUserInfos = mockUsers.Select(u => new UserInfo(u)).ToList(); + mockUserInfos.Sort(UserInfo.Comparer); + MockUserInfos = mockUserInfos; + } + + public static List<User> MockUsers { get; } + + public static IReadOnlyList<UserInfo> MockUserInfos { get; } + } +} diff --git a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs b/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs index 4a7f87fb..a34217f4 100644 --- a/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs +++ b/Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Timeline.Models; -using Timeline.Services; using Xunit.Abstractions; namespace Timeline.Tests.Helpers @@ -42,28 +41,10 @@ namespace Timeline.Tests.Helpers var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService<DatabaseContext>(); - var passwordService = new PasswordService(null); - // Ensure the database is created. db.Database.EnsureCreated(); - db.Users.AddRange(new User[] { - new User - { - Id = 0, - Name = "user", - EncryptedPassword = passwordService.HashPassword("user"), - RoleString = "user" - }, - new User - { - Id = 0, - Name = "admin", - EncryptedPassword = passwordService.HashPassword("admin"), - RoleString = "user,admin" - } - }); - + db.Users.AddRange(TestMockUsers.MockUsers); db.SaveChanges(); } }); diff --git a/Timeline.Tests/JwtTokenUnitTest.cs b/Timeline.Tests/JwtTokenUnitTest.cs index fa9c7628..39ffc928 100644 --- a/Timeline.Tests/JwtTokenUnitTest.cs +++ b/Timeline.Tests/JwtTokenUnitTest.cs @@ -12,8 +12,8 @@ namespace Timeline.Tests { public class JwtTokenUnitTest : IClassFixture<WebApplicationFactory<Startup>> { - private const string CreateTokenUrl = "User/CreateToken"; - private const string VerifyTokenUrl = "User/VerifyToken"; + private const string CreateTokenUrl = "token/create"; + private const string VerifyTokenUrl = "token/verify"; private readonly WebApplicationFactory<Startup> _factory; diff --git a/Timeline.Tests/Timeline.Tests.csproj b/Timeline.Tests/Timeline.Tests.csproj index 57e04fc0..820737cc 100644 --- a/Timeline.Tests/Timeline.Tests.csproj +++ b/Timeline.Tests/Timeline.Tests.csproj @@ -8,7 +8,7 @@ <PackageReference Include="Microsoft.AspNetCore.App" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Testing" Version="2.2.0-rtm-35646" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.0.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1"> <PrivateAssets>all</PrivateAssets> diff --git a/Timeline.Tests/UserUnitTest.cs b/Timeline.Tests/UserUnitTest.cs new file mode 100644 index 00000000..7d8cc824 --- /dev/null +++ b/Timeline.Tests/UserUnitTest.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Newtonsoft.Json; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Xunit; +using Xunit.Abstractions; + +namespace Timeline.Tests +{ + public class UserUnitTest : IClassFixture<WebApplicationFactory<Startup>> + { + private readonly WebApplicationFactory<Startup> _factory; + + public UserUnitTest(WebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) + { + _factory = factory.WithTestConfig(outputHelper); + } + + [Fact] + public async Task UserTest() + { + using (var client = await _factory.CreateClientWithUser("admin", "admin")) + { + var res1 = await client.GetAsync("users"); + Assert.Equal(HttpStatusCode.OK, res1.StatusCode); + var users = JsonConvert.DeserializeObject<UserInfo[]>(await res1.Content.ReadAsStringAsync()).ToList(); + users.Sort(UserInfo.Comparer); + Assert.Equal(TestMockUsers.MockUserInfos, users, UserInfo.EqualityComparer); + } + } + } +} diff --git a/Timeline/Configs/DatabaseConfig.cs b/Timeline/Configs/DatabaseConfig.cs index 34e5e65f..05dc630e 100644 --- a/Timeline/Configs/DatabaseConfig.cs +++ b/Timeline/Configs/DatabaseConfig.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Timeline.Configs +namespace Timeline.Configs { public class DatabaseConfig { diff --git a/Timeline/Controllers/AdminUserController.cs b/Timeline/Controllers/AdminUserController.cs deleted file mode 100644 index 7cc8c150..00000000 --- a/Timeline/Controllers/AdminUserController.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Services; - -namespace Timeline.Controllers -{ - [Route("admin")] - [Authorize(Roles = "admin")] - public class AdminUserController : Controller - { - private readonly IUserService _userService; - - public AdminUserController(IUserService userService) - { - _userService = userService; - } - - [HttpGet("users")] - public async Task<ActionResult<UserInfo[]>> List() - { - return Ok(await _userService.ListUsers()); - } - - [HttpGet("user/{username}")] - public async Task<IActionResult> Get([FromRoute] string username) - { - var user = await _userService.GetUser(username); - if (user == null) - { - return NotFound(); - } - return Ok(user); - } - - [HttpPut("user/{username}")] - public async Task<IActionResult> Put([FromBody] AdminUserEntityRequest request, [FromRoute] string username) - { - var result = await _userService.PutUser(username, request.Password, request.Roles); - switch (result) - { - case PutUserResult.Created: - return CreatedAtAction("Get", new { username }, AdminUserPutResponse.Created); - case PutUserResult.Modified: - return Ok(AdminUserPutResponse.Modified); - default: - throw new Exception("Unreachable code."); - } - } - - [HttpPatch("user/{username}")] - public async Task<IActionResult> Patch([FromBody] AdminUserEntityRequest request, [FromRoute] string username) - { - var result = await _userService.PatchUser(username, request.Password, request.Roles); - switch (result) - { - case PatchUserResult.Success: - return Ok(); - case PatchUserResult.NotExists: - return NotFound(); - default: - throw new Exception("Unreachable code."); - } - } - - [HttpDelete("user/{username}")] - public async Task<ActionResult<AdminUserDeleteResponse>> Delete([FromRoute] string username) - { - var result = await _userService.DeleteUser(username); - switch (result) - { - case DeleteUserResult.Success: - return Ok(AdminUserDeleteResponse.Success); - case DeleteUserResult.NotExists: - return Ok(AdminUserDeleteResponse.NotExists); - default: - throw new Exception("Uncreachable code."); - } - } - } -} diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs new file mode 100644 index 00000000..463fb83c --- /dev/null +++ b/Timeline/Controllers/TokenController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Services; + +namespace Timeline.Controllers +{ + [Route("token")] + public class TokenController : Controller + { + private static class LoggingEventIds + { + public const int LogInSucceeded = 4000; + public const int LogInFailed = 4001; + } + + private readonly IUserService _userService; + private readonly ILogger<TokenController> _logger; + + public TokenController(IUserService userService, ILogger<TokenController> logger) + { + _userService = userService; + _logger = logger; + } + + [HttpPost("create")] + [AllowAnonymous] + public async Task<ActionResult<CreateTokenResponse>> Create([FromBody] CreateTokenRequest request) + { + var result = await _userService.CreateToken(request.Username, request.Password); + + if (result == null) + { + _logger.LogInformation(LoggingEventIds.LogInFailed, "Attemp to login with username: {} and password: {} failed.", request.Username, request.Password); + return Ok(new CreateTokenResponse + { + Success = false + }); + } + + _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login with username: {} succeeded.", request.Username); + + return Ok(new CreateTokenResponse + { + Success = true, + Token = result.Token, + UserInfo = result.UserInfo + }); + } + + [HttpPost("verify")] + [AllowAnonymous] + public async Task<ActionResult<VerifyTokenResponse>> Verify([FromBody] VerifyTokenRequest request) + { + var result = await _userService.VerifyToken(request.Token); + + if (result == null) + { + return Ok(new VerifyTokenResponse + { + IsValid = false, + }); + } + + return Ok(new VerifyTokenResponse + { + IsValid = true, + UserInfo = result + }); + } + } +} diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 285e0146..ab7e1b99 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -1,74 +1,81 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; +using System; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Services; namespace Timeline.Controllers { - [Route("[controller]")] public class UserController : Controller { - private static class LoggingEventIds - { - public const int LogInSucceeded = 4000; - public const int LogInFailed = 4001; - } - private readonly IUserService _userService; - private readonly ILogger<UserController> _logger; - public UserController(IUserService userService, ILogger<UserController> logger) + public UserController(IUserService userService) { _userService = userService; - _logger = logger; } - [HttpPost("[action]")] - [AllowAnonymous] - public async Task<ActionResult<CreateTokenResponse>> CreateToken([FromBody] CreateTokenRequest request) + [HttpGet("users"), Authorize(Roles = "admin")] + public async Task<ActionResult<UserInfo[]>> List() { - var result = await _userService.CreateToken(request.Username, request.Password); + return Ok(await _userService.ListUsers()); + } - if (result == null) + [HttpGet("user/{username}"), Authorize] + public async Task<IActionResult> Get([FromRoute] string username) + { + var user = await _userService.GetUser(username); + if (user == null) { - _logger.LogInformation(LoggingEventIds.LogInFailed, "Attemp to login with username: {} and password: {} failed.", request.Username, request.Password); - return Ok(new CreateTokenResponse - { - Success = false - }); + return NotFound(); } + return Ok(user); + } - _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login with username: {} succeeded.", request.Username); - - return Ok(new CreateTokenResponse + [HttpPut("user/{username}"), Authorize(Roles = "admin")] + public async Task<IActionResult> Put([FromBody] UserModifyRequest request, [FromRoute] string username) + { + var result = await _userService.PutUser(username, request.Password, request.Roles); + switch (result) { - Success = true, - Token = result.Token, - UserInfo = result.UserInfo - }); + case PutUserResult.Created: + return CreatedAtAction("Get", new { username }, UserPutResponse.Created); + case PutUserResult.Modified: + return Ok(UserPutResponse.Modified); + default: + throw new Exception("Unreachable code."); + } } - [HttpPost("[action]")] - [AllowAnonymous] - public async Task<ActionResult<VerifyTokenResponse>> VerifyToken([FromBody] VerifyTokenRequest request) + [HttpPatch("user/{username}"), Authorize(Roles = "admin")] + public async Task<IActionResult> Patch([FromBody] UserModifyRequest request, [FromRoute] string username) { - var result = await _userService.VerifyToken(request.Token); - - if (result == null) + var result = await _userService.PatchUser(username, request.Password, request.Roles); + switch (result) { - return Ok(new VerifyTokenResponse - { - IsValid = false, - }); + case PatchUserResult.Success: + return Ok(); + case PatchUserResult.NotExists: + return NotFound(); + default: + throw new Exception("Unreachable code."); } + } - return Ok(new VerifyTokenResponse + [HttpDelete("user/{username}"), Authorize(Roles = "admin")] + public async Task<ActionResult<UserDeleteResponse>> Delete([FromRoute] string username) + { + var result = await _userService.DeleteUser(username); + switch (result) { - IsValid = true, - UserInfo = result - }); + case DeleteUserResult.Success: + return Ok(UserDeleteResponse.Success); + case DeleteUserResult.NotExists: + return Ok(UserDeleteResponse.NotExists); + default: + throw new Exception("Uncreachable code."); + } } } } diff --git a/Timeline/Entities/AdminUser.cs b/Timeline/Entities/AdminUser.cs index 7b8b7fb7..eb126165 100644 --- a/Timeline/Entities/AdminUser.cs +++ b/Timeline/Entities/AdminUser.cs @@ -1,29 +1,29 @@ namespace Timeline.Entities { - public class AdminUserEntityRequest + public class UserModifyRequest { public string Password { get; set; } public string[] Roles { get; set; } } - public class AdminUserPutResponse + public class UserPutResponse { public const int CreatedCode = 0; public const int ModifiedCode = 1; - public static AdminUserPutResponse Created { get; } = new AdminUserPutResponse { ReturnCode = CreatedCode }; - public static AdminUserPutResponse Modified { get; } = new AdminUserPutResponse { ReturnCode = ModifiedCode }; + public static UserPutResponse Created { get; } = new UserPutResponse { ReturnCode = CreatedCode }; + public static UserPutResponse Modified { get; } = new UserPutResponse { ReturnCode = ModifiedCode }; public int ReturnCode { get; set; } } - public class AdminUserDeleteResponse + public class UserDeleteResponse { public const int SuccessCode = 0; public const int NotExistsCode = 1; - public static AdminUserDeleteResponse Success { get; } = new AdminUserDeleteResponse { ReturnCode = SuccessCode }; - public static AdminUserDeleteResponse NotExists { get; } = new AdminUserDeleteResponse { ReturnCode = NotExistsCode }; + public static UserDeleteResponse Success { get; } = new UserDeleteResponse { ReturnCode = SuccessCode }; + public static UserDeleteResponse NotExists { get; } = new UserDeleteResponse { ReturnCode = NotExistsCode }; public int ReturnCode { get; set; } } diff --git a/Timeline/Entities/UserInfo.cs b/Timeline/Entities/UserInfo.cs index d9c5acad..a1860552 100644 --- a/Timeline/Entities/UserInfo.cs +++ b/Timeline/Entities/UserInfo.cs @@ -1,14 +1,20 @@ using System; +using System.Collections.Generic; using System.Linq; using Timeline.Models; namespace Timeline.Entities { - public class UserInfo + public sealed class UserInfo { public UserInfo() { + } + public UserInfo(string username, params string[] roles) + { + Username = username; + Roles = roles; } public UserInfo(User user) @@ -17,10 +23,64 @@ namespace Timeline.Entities throw new ArgumentNullException(nameof(user)); Username = user.Name; - Roles = user.RoleString.Split(',').Select(s => s.Trim()).ToArray(); + + if (user.RoleString == null) + Roles = null; + else + Roles = user.RoleString.Split(',').Select(r => r.Trim()).ToArray(); } public string Username { get; set; } public string[] Roles { get; set; } + + public static IEqualityComparer<UserInfo> EqualityComparer { get; } = new EqualityComparerImpl(); + public static IComparer<UserInfo> Comparer { get; } = Comparer<UserInfo>.Create(Compare); + + private class EqualityComparerImpl : IEqualityComparer<UserInfo> + { + bool IEqualityComparer<UserInfo>.Equals(UserInfo x, UserInfo y) + { + return Compare(x, y) == 0; + } + + int IEqualityComparer<UserInfo>.GetHashCode(UserInfo obj) + { + return obj.Username.GetHashCode() ^ NormalizeRoles(obj.Roles).GetHashCode(); + } + } + + private static string NormalizeRoles(string[] rawRoles) + { + var roles = rawRoles.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()).ToList(); + roles.Sort(); + return string.Join(',', roles); + } + + public static int Compare(UserInfo left, UserInfo right) + { + if (left == null) + { + if (right == null) + return 0; + return -1; + } + + if (right == null) + return 1; + + var uc = string.Compare(left.Username, right.Username); + if (uc != 0) + return uc; + + var leftRoles = NormalizeRoles(left.Roles); + var rightRoles = NormalizeRoles(right.Roles); + + return string.Compare(leftRoles, rightRoles); + } + + public override string ToString() + { + return $"Username: {Username} ; Roles: {Roles}"; + } } } diff --git a/nuget.config b/nuget.config index d4244526..194c0bbf 100644 --- a/nuget.config +++ b/nuget.config @@ -1,7 +1,6 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8"?> <configuration> <packageSources> - <add key="nuget" value="https://api.nuget.org/v3/index.json" /> <add key="aspnetcore-dev" value="https://dotnet.myget.org/F/aspnetcore-dev/api/v3/index.json" /> </packageSources> </configuration> |