diff options
author | 杨宇千 <crupest@outlook.com> | 2019-05-06 14:22:10 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-05-06 14:22:10 +0800 |
commit | ea84a067eee6dcd10dd0bc89967ef608677ef37e (patch) | |
tree | 37c2b6f9dcb3d6f57867ebc7538523d1a338183d | |
parent | ae848e311b46a25ec1ed571432d55e800ac7595b (diff) | |
parent | 070a4a8399201150a633c80608a13cc44781a3c4 (diff) | |
download | timeline-ea84a067eee6dcd10dd0bc89967ef608677ef37e.tar.gz timeline-ea84a067eee6dcd10dd0bc89967ef608677ef37e.tar.bz2 timeline-ea84a067eee6dcd10dd0bc89967ef608677ef37e.zip |
Merge pull request #22 from crupest/user-admin
Develop user management feature.
30 files changed, 1243 insertions, 252 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..bb765851 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/Timeline/bin/Debug/netcoreapp2.2/Timeline.dll", + "args": [], + "cwd": "${workspaceFolder}/Timeline", + "stopAtEntry": false, + "launchBrowser": { + "enabled": true + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +}
\ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..eeb06c4f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,36 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Timeline/Timeline.csproj" + ], + "problemMatcher": "$tsc" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/Timeline/Timeline.csproj" + ], + "problemMatcher": "$tsc" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "${workspaceFolder}/Timeline/Timeline.csproj" + ], + "problemMatcher": "$tsc" + } + ] +}
\ No newline at end of file @@ -2,8 +2,12 @@ [](https://dev.azure.com/crupest-web/Timeline/_build/latest?definitionId=3&branchName=master) -This is the first web app consisting of front-end and back-end of [me](https://github.com/crupest). +This is the first web app back-end of [me](https://github.com/crupest). + +It is written in C# and built with [Asp.Net Core](https://github.com/aspnet/AspNetCore). The final product is hosting on my [Tencent Cloud](https://cloud.tencent.com/) Cloud Virtual Machine on [https://crupest.xyz](https://crupest.xyz). Feel free to comment by opening an issue. + +`tools/open-code` file is a simple *bash* script that fixes the problem that *OminiSharp* in C# extension on vscode can't work using *dotnet* in Arch official package repository on Arch Linux. See [this page](https://bugs.archlinux.org/task/60903). 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..cda9fe99 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -1,16 +1,17 @@ -using Newtonsoft.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Newtonsoft.Json; using System; using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Timeline.Entities; +using Timeline.Entities.Http; 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..89ddf218 --- /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 => UserInfo.Create(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..a7616b41 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 @@ -16,7 +15,6 @@ namespace Timeline.Tests.Helpers return factory.WithWebHostBuilder(builder => { builder - .UseEnvironment(EnvironmentConstants.TestEnvironmentName) .ConfigureLogging(logging => { logging.AddXunit(outputHelper); @@ -42,28 +40,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..8a503bd7 100644 --- a/Timeline.Tests/JwtTokenUnitTest.cs +++ b/Timeline.Tests/JwtTokenUnitTest.cs @@ -2,7 +2,7 @@ using Newtonsoft.Json; using System.Net; using System.Net.Http; -using Timeline.Entities; +using Timeline.Entities.Http; using Timeline.Tests.Helpers; using Timeline.Tests.Helpers.Authentication; using Xunit; @@ -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/QCloudCosServiceUnitTest.cs b/Timeline.Tests/QCloudCosServiceUnitTest.cs new file mode 100644 index 00000000..0940c70d --- /dev/null +++ b/Timeline.Tests/QCloudCosServiceUnitTest.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace Timeline.Tests +{ + public class QCloudCosServiceUnitTest : IClassFixture<WebApplicationFactory<Startup>> + { + private readonly WebApplicationFactory<Startup> _factory; + + public QCloudCosServiceUnitTest(WebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) + { + _factory = factory.WithTestConfig(outputHelper); + _factory.CreateDefaultClient().Dispose(); // Ensure test server is created. + } + + [Fact] + public void ValidateBucketNameTest() + { + Assert.True(QCloudCosService.ValidateBucketName("hello")); + Assert.True(QCloudCosService.ValidateBucketName("hello0123")); + Assert.True(QCloudCosService.ValidateBucketName("hello0123-hello")); + Assert.False(QCloudCosService.ValidateBucketName("-hello")); + Assert.False(QCloudCosService.ValidateBucketName("hello-")); + Assert.False(QCloudCosService.ValidateBucketName("helloU")); + Assert.False(QCloudCosService.ValidateBucketName("hello!")); + } + + [Fact] + public void GenerateSignatureTest() + { + var credential = new QCloudCosService.QCloudCredentials + { + SecretId = "AKIDQjz3ltompVjBni5LitkWHFlFpwkn9U5q", + SecretKey = "BQYIM75p8x0iWVFSIgqEKwFprpRSVHlz" + }; + + var request = new QCloudCosService.RequestInfo + { + Method = "put", + Uri = "/exampleobject", + Parameters = new Dictionary<string, string>(), + Headers = new Dictionary<string, string> + { + ["Host"] = "examplebucket-1250000000.cos.ap-beijing.myqcloud.com", + ["x-cos-storage-class"] = "standard", + ["x-cos-content-sha1"] = "b502c3a1f48c8609ae212cdfb639dee39673f5e" + } + }; + + var signValidTime = new QCloudCosService.TimeDuration + { + Start = DateTimeOffset.FromUnixTimeSeconds(1417773892), + End = DateTimeOffset.FromUnixTimeSeconds(1417853898) + }; + + Assert.Equal("q-sign-algorithm=sha1&q-ak=AKIDQjz3ltompVjBni5LitkWHFlFpwkn9U5q&q-sign-time=1417773892;1417853898&q-key-time=1417773892;1417853898&q-header-list=host;x-cos-content-sha1;x-cos-storage-class&q-url-param-list=&q-signature=0ab12f43e74cbe148d705cd9fae8adc9a6d39cc1", QCloudCosService.GenerateSign(credential, request, signValidTime)); + } + +/* +// Tests in this part need secret configs in cos. +#region SecretTests + [Fact] + public async Task ObjectExistsTest() + { + using (var serviceScope = _factory.Server.Host.Services.CreateScope()) + { + var services = serviceScope.ServiceProvider; + var service = services.GetRequiredService<IQCloudCosService>(); + Assert.True(await service.IsObjectExists("avatar", "__default")); + Assert.False(await service.IsObjectExists("avatar", "haha")); + Assert.False(await service.IsObjectExists("haha", "haha")); + } + } + + [Fact] + public async Task GenerateObjectGetUrlTest() + { + using (var serviceScope = _factory.Server.Host.Services.CreateScope()) + { + var services = serviceScope.ServiceProvider; + var service = services.GetRequiredService<IQCloudCosService>(); + var url = service.GenerateObjectGetUrl("avatar", "__default"); + // never use the following line! Because client created by factory can't access Internet. + //using (var client = _factory.CreateClient()) + using (var client = services.GetRequiredService<IHttpClientFactory>().CreateClient()) + { + var res = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + } + } +#endregion +*/ + } +} 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/Configs/QCloudCosConfig.cs b/Timeline/Configs/QCloudCosConfig.cs new file mode 100644 index 00000000..6d10436c --- /dev/null +++ b/Timeline/Configs/QCloudCosConfig.cs @@ -0,0 +1,10 @@ +namespace Timeline.Configs +{ + public class QCloudCosConfig + { + public string AppId { get; set; } + public string Region { get; set; } + public string SecretId { get; set; } + public string SecretKey { get; set; } + } +} diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs new file mode 100644 index 00000000..0be5fb2f --- /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.Http; +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 147724c1..a18e36e9 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -1,90 +1,135 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using System; +using System.IO; using System.Threading.Tasks; using Timeline.Entities; +using Timeline.Entities.Http; 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() + { + return Ok(await _userService.ListUsers()); + } + + [HttpGet("user/{username}"), Authorize] + public async Task<IActionResult> Get([FromRoute] string username) { - var result = await _userService.CreateToken(request.Username, request.Password); + var user = await _userService.GetUser(username); + if (user == null) + { + return NotFound(); + } + return Ok(user); + } - if (result == null) + [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) { - _logger.LogInformation(LoggingEventIds.LogInFailed, "Attemp to login with username: {} and password: {} failed.", request.Username, request.Password); - return Ok(new CreateTokenResponse - { - Success = false - }); + case PutUserResult.Created: + return CreatedAtAction("Get", new { username }, UserPutResponse.Created); + case PutUserResult.Modified: + return Ok(UserPutResponse.Modified); + default: + throw new Exception("Unreachable code."); } + } - _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login with username: {} succeeded.", request.Username); + [HttpPatch("user/{username}"), Authorize(Roles = "admin")] + public async Task<IActionResult> Patch([FromBody] UserModifyRequest 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."); + } + } - return Ok(new CreateTokenResponse + [HttpDelete("user/{username}"), Authorize(Roles = "admin")] + public async Task<IActionResult> Delete([FromRoute] string username) + { + var result = await _userService.DeleteUser(username); + switch (result) { - Success = true, - Token = result.Token, - UserInfo = result.UserInfo - }); + case DeleteUserResult.Deleted: + return Ok(UserDeleteResponse.Deleted); + case DeleteUserResult.NotExists: + return Ok(UserDeleteResponse.NotExists); + default: + throw new Exception("Uncreachable code."); + } } - [HttpPost("[action]")] - [AllowAnonymous] - public async Task<ActionResult<VerifyTokenResponse>> VerifyToken([FromBody] VerifyTokenRequest request) + [HttpGet("user/{username}/avatar"), Authorize] + public async Task<IActionResult> GetAvatar([FromRoute] string username) { - var result = await _userService.VerifyToken(request.Token); + var url = await _userService.GetAvatarUrl(username); + if (url == null) + return NotFound(); + return Redirect(url); + } - if (result == null) + [HttpPut("user/{username}/avatar"), Authorize] + [Consumes("image/png", "image/gif", "image/jpeg", "image/svg+xml")] + public async Task<IActionResult> PutAvatar([FromRoute] string username, [FromHeader(Name="Content-Type")] string contentType) + { + bool isAdmin = User.IsInRole("admin"); + if (!isAdmin) { - return Ok(new VerifyTokenResponse - { - IsValid = false, - }); + if (username != User.Identity.Name) + return StatusCode(StatusCodes.Status403Forbidden, PutAvatarResponse.Forbidden); } - return Ok(new VerifyTokenResponse + var stream = new MemoryStream(); + await Request.Body.CopyToAsync(stream); + var result = await _userService.PutAvatar(username, stream.ToArray(), contentType); + switch (result) { - IsValid = true, - UserInfo = result - }); + case PutAvatarResult.Success: + return Ok(PutAvatarResponse.Success); + case PutAvatarResult.UserNotExists: + return BadRequest(PutAvatarResponse.NotExists); + default: + throw new Exception("Unknown put avatar result."); + } } - [HttpPost("[action]")] - [Authorize(Roles = "admin")] - public async Task<ActionResult<CreateUserResponse>> CreateUser([FromBody] CreateUserRequest request) + + [HttpPost("userop/changepassword"), Authorize] + public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request) { - var result = await _userService.CreateUser(request.Username, request.Password, request.Roles); + var result = await _userService.ChangePassword(User.Identity.Name, request.OldPassword, request.NewPassword); switch (result) { - case CreateUserResult.Success: - return Ok(new CreateUserResponse { ReturnCode = CreateUserResponse.SuccessCode }); - case CreateUserResult.AlreadyExists: - return Ok(new CreateUserResponse { ReturnCode = CreateUserResponse.AlreadyExistsCode }); + case ChangePasswordResult.Success: + return Ok(ChangePasswordResponse.Success); + case ChangePasswordResult.BadOldPassword: + return Ok(ChangePasswordResponse.BadOldPassword); + case ChangePasswordResult.NotExists: + return Ok(ChangePasswordResponse.NotExists); default: - throw new Exception("Unreachable code."); + throw new Exception("Uncreachable code."); } } } diff --git a/Timeline/Entities/Http/Common.cs b/Timeline/Entities/Http/Common.cs new file mode 100644 index 00000000..9575e6fa --- /dev/null +++ b/Timeline/Entities/Http/Common.cs @@ -0,0 +1,29 @@ +namespace Timeline.Entities.Http +{ + public class ReturnCodeMessageResponse + { + public ReturnCodeMessageResponse() + { + + } + + public ReturnCodeMessageResponse(int code) + { + ReturnCode = code; + } + + public ReturnCodeMessageResponse(string message) + { + Message = message; + } + + public ReturnCodeMessageResponse(int code, string message) + { + ReturnCode = code; + Message = message; + } + + public int? ReturnCode { get; set; } = null; + public string Message { get; set; } = null; + } +} diff --git a/Timeline/Entities/User.cs b/Timeline/Entities/Http/Token.cs index b5664bb0..45ee0fc5 100644 --- a/Timeline/Entities/User.cs +++ b/Timeline/Entities/Http/Token.cs @@ -1,4 +1,4 @@ -namespace Timeline.Entities +namespace Timeline.Entities.Http { public class CreateTokenRequest { @@ -23,19 +23,4 @@ public bool IsValid { get; set; } public UserInfo UserInfo { get; set; } } - - public class CreateUserRequest - { - public string Username { get; set; } - public string Password { get; set; } - public string[] Roles { get; set; } - } - - public class CreateUserResponse - { - public const int SuccessCode = 0; - public const int AlreadyExistsCode = 1; - - public int ReturnCode { get; set; } - } } diff --git a/Timeline/Entities/Http/User.cs b/Timeline/Entities/Http/User.cs new file mode 100644 index 00000000..31cafaa3 --- /dev/null +++ b/Timeline/Entities/Http/User.cs @@ -0,0 +1,54 @@ +namespace Timeline.Entities.Http +{ + public class UserModifyRequest + { + public string Password { get; set; } + public string[] Roles { get; set; } + } + + public static class UserPutResponse + { + public const int CreatedCode = 0; + public const int ModifiedCode = 1; + + public static ReturnCodeMessageResponse Created { get; } = new ReturnCodeMessageResponse(CreatedCode, "A new user is created."); + public static ReturnCodeMessageResponse Modified { get; } = new ReturnCodeMessageResponse(ModifiedCode, "A existing user is modified."); + } + + public static class UserDeleteResponse + { + public const int DeletedCode = 0; + public const int NotExistsCode = 1; + + public static ReturnCodeMessageResponse Deleted { get; } = new ReturnCodeMessageResponse(DeletedCode, "A existing user is deleted."); + public static ReturnCodeMessageResponse NotExists { get; } = new ReturnCodeMessageResponse(NotExistsCode, "User with given name does not exists."); + } + + public class ChangePasswordRequest + { + public string OldPassword { get; set; } + public string NewPassword { get; set; } + } + + public static class ChangePasswordResponse + { + public const int SuccessCode = 0; + public const int BadOldPasswordCode = 1; + public const int NotExistsCode = 2; + + public static ReturnCodeMessageResponse Success { get; } = new ReturnCodeMessageResponse(SuccessCode, "Success to change password."); + public static ReturnCodeMessageResponse BadOldPassword { get; } = new ReturnCodeMessageResponse(BadOldPasswordCode, "Old password is wrong."); + public static ReturnCodeMessageResponse NotExists { get; } = new ReturnCodeMessageResponse(NotExistsCode, "Username does not exists, please update token."); + } + + public static class PutAvatarResponse + { + public const int SuccessCode = 0; + public const int ForbiddenCode = 1; + public const int NotExistsCode = 2; + + public static ReturnCodeMessageResponse Success {get;} = new ReturnCodeMessageResponse(SuccessCode, "Success to upload avatar."); + public static ReturnCodeMessageResponse Forbidden {get;} = new ReturnCodeMessageResponse(ForbiddenCode, "You are not allowed to upload the user's avatar."); + public static ReturnCodeMessageResponse NotExists {get;} = new ReturnCodeMessageResponse(NotExistsCode, "The username does not exists. If you are a user, try update your token."); + } +} diff --git a/Timeline/Entities/UserInfo.cs b/Timeline/Entities/UserInfo.cs index d9c5acad..c9bcde5b 100644 --- a/Timeline/Entities/UserInfo.cs +++ b/Timeline/Entities/UserInfo.cs @@ -1,26 +1,93 @@ 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) + public static UserInfo Create(User user) { if (user == null) throw new ArgumentNullException(nameof(user)); - - Username = user.Name; - Roles = user.RoleString.Split(',').Select(s => s.Trim()).ToArray(); + return Create(user.Name, user.RoleString); } + public static UserInfo Create(string username, string roleString) => new UserInfo + { + Username = username, + Roles = RolesFromString(roleString) + }; + 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 static string[] RolesFromString(string roleString) + { + if (roleString == null) + return null; + return roleString.Split(',').Select(r => r.Trim()).ToArray(); + } + + 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/Timeline/EnvironmentConstants.cs b/Timeline/EnvironmentConstants.cs deleted file mode 100644 index 5ffc3623..00000000 --- a/Timeline/EnvironmentConstants.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Hosting; - -namespace Timeline -{ - public static class EnvironmentConstants - { - public const string TestEnvironmentName = "Test"; - - public static bool IsTest(this IHostingEnvironment environment) - { - return environment.EnvironmentName == TestEnvironmentName; - } - } -} diff --git a/Timeline/Pages/Error.cshtml b/Timeline/Pages/Error.cshtml deleted file mode 100644 index 6f92b956..00000000 --- a/Timeline/Pages/Error.cshtml +++ /dev/null @@ -1,26 +0,0 @@ -@page -@model ErrorModel -@{ - ViewData["Title"] = "Error"; -} - -<h1 class="text-danger">Error.</h1> -<h2 class="text-danger">An error occurred while processing your request.</h2> - -@if (Model.ShowRequestId) -{ - <p> - <strong>Request ID:</strong> <code>@Model.RequestId</code> - </p> -} - -<h3>Development Mode</h3> -<p> - Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred. -</p> -<p> - <strong>The Development environment shouldn't be enabled for deployed applications.</strong> - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong> - and restarting the app. -</p> diff --git a/Timeline/Pages/Error.cshtml.cs b/Timeline/Pages/Error.cshtml.cs deleted file mode 100644 index 2c11a93b..00000000 --- a/Timeline/Pages/Error.cshtml.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; - -namespace Timeline.Pages -{ - [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public class ErrorModel : PageModel - { - public string RequestId { get; set; } - - public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - public void OnGet() - { - RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; - } - } -} diff --git a/Timeline/Pages/_ViewImports.cshtml b/Timeline/Pages/_ViewImports.cshtml deleted file mode 100644 index 2fd64a78..00000000 --- a/Timeline/Pages/_ViewImports.cshtml +++ /dev/null @@ -1,3 +0,0 @@ -@using Timeline -@namespace Timeline.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs index 91e7f879..bf470354 100644 --- a/Timeline/Services/JwtService.cs +++ b/Timeline/Services/JwtService.cs @@ -7,25 +7,28 @@ using System.Linq; using System.Security.Claims; using System.Text; using Timeline.Configs; +using Timeline.Entities; namespace Timeline.Services { public interface IJwtService { /// <summary> - /// Create a JWT token for a given user id. + /// Create a JWT token for a given user info. /// </summary> - /// <param name="userId">The user id used to generate token.</param> + /// <param name="userId">The user id contained in generate token.</param> + /// <param name="username">The username contained in token.</param> + /// <param name="roles">The roles contained in token.</param> /// <returns>Return the generated token.</returns> - string GenerateJwtToken(long userId, string[] roles); + string GenerateJwtToken(long userId, string username, string[] roles); /// <summary> /// Verify a JWT token. /// Return null is <paramref name="token"/> is null. /// </summary> /// <param name="token">The token string to verify.</param> - /// <returns>Return null if <paramref name="token"/> is null or token is invalid. Return the saved user id otherwise.</returns> - long? VerifyJwtToken(string token); + /// <returns>Return null if <paramref name="token"/> is null or token is invalid. Return the saved user info otherwise.</returns> + UserInfo VerifyJwtToken(string token); } @@ -41,12 +44,13 @@ namespace Timeline.Services _logger = logger; } - public string GenerateJwtToken(long id, string[] roles) + public string GenerateJwtToken(long id, string username, string[] roles) { var jwtConfig = _jwtConfig.CurrentValue; var identity = new ClaimsIdentity(); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, id.ToString())); + identity.AddClaim(new Claim(identity.NameClaimType, username)); identity.AddClaims(roles.Select(role => new Claim(identity.RoleClaimType, role))); var tokenDescriptor = new SecurityTokenDescriptor() @@ -67,13 +71,12 @@ namespace Timeline.Services } - public long? VerifyJwtToken(string token) + public UserInfo VerifyJwtToken(string token) { if (token == null) return null; var config = _jwtConfig.CurrentValue; - try { var principal = _tokenHandler.ValidateToken(token, new TokenValidationParameters @@ -87,7 +90,8 @@ namespace Timeline.Services IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)) }, out SecurityToken validatedToken); - return long.Parse(principal.FindAll(ClaimTypes.NameIdentifier).Single().Value); + return new UserInfo(principal.Identity.Name, + principal.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray()); } catch (Exception e) { diff --git a/Timeline/Services/QCloudCosService.cs b/Timeline/Services/QCloudCosService.cs new file mode 100644 index 00000000..b37631e5 --- /dev/null +++ b/Timeline/Services/QCloudCosService.cs @@ -0,0 +1,329 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Timeline.Configs; + +namespace Timeline.Services +{ + public interface IQCloudCosService + { + /// <summary> + /// Test if an object in the bucket exists. + /// </summary> + /// <param name="bucket">The bucket name.</param> + /// <param name="key">The object key.</param> + /// <returns>True if exists. False if not.</returns> + Task<bool> IsObjectExists(string bucket, string key); + + /// <summary> + /// Upload an object use put method. + /// </summary> + /// <param name="bucket">The bucket name.</param> + /// <param name="key">The object key.</param> + /// <param name="data">The data to upload.</param> + Task PutObject(string bucket, string key, byte[] data, string contentType); + + /// <summary> + /// Generate a presignated url to access the object. + /// </summary> + /// <param name="bucket">The bucket name.</param> + /// <param name="key">The object key.</param> + /// <returns>The presignated url.</returns> + string GenerateObjectGetUrl(string bucket, string key); + } + + public class QCloudCosService : IQCloudCosService + { + private readonly IOptionsMonitor<QCloudCosConfig> _config; + private readonly ILogger<QCloudCosService> _logger; + private readonly IHttpClientFactory _httpClientFactory; + + public QCloudCosService(IOptionsMonitor<QCloudCosConfig> config, ILogger<QCloudCosService> logger, IHttpClientFactory httpClientFactory) + { + _config = config; + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + private const string BucketNamePattern = @"^(([a-z0-9][a-z0-9-]*[a-z0-9])|[a-z0-9])$"; + + public static bool ValidateBucketName(string bucketName) + { + return Regex.IsMatch(bucketName, BucketNamePattern); + } + + public class QCloudCredentials + { + public string SecretId { get; set; } + public string SecretKey { get; set; } + } + + public class RequestInfo + { + public string Method { get; set; } + public string Uri { get; set; } + public IEnumerable<KeyValuePair<string, string>> Parameters { get; set; } + public IEnumerable<KeyValuePair<string, string>> Headers { get; set; } + } + + public class TimeDuration + { + public TimeDuration() + { + + } + + public TimeDuration(DateTimeOffset start, DateTimeOffset end) + { + Start = start; + End = end; + } + + public DateTimeOffset Start { get; set; } + public DateTimeOffset End { get; set; } + } + + public static string GenerateSign(QCloudCredentials credentials, RequestInfo request, TimeDuration signValidTime) + { + Debug.Assert(credentials != null); + Debug.Assert(credentials.SecretId != null); + Debug.Assert(credentials.SecretKey != null); + Debug.Assert(request != null); + Debug.Assert(request.Method != null); + Debug.Assert(request.Uri != null); + Debug.Assert(signValidTime != null); + Debug.Assert(signValidTime.Start < signValidTime.End, "Start must be before End in sign valid time."); + + List<(string key, string value)> Transform(IEnumerable<KeyValuePair<string, string>> raw) + { + if (raw == null) + return new List<(string key, string value)>(); + + var sorted = raw.Select(p => (key: p.Key.ToLower(), value: WebUtility.UrlEncode(p.Value))).ToList(); + sorted.Sort((left, right) => string.CompareOrdinal(left.key, right.key)); + return sorted; + } + + var transformedParameters = Transform(request.Parameters); + var transformedHeaders = Transform(request.Headers); + + List<(string, string)> result = new List<(string, string)>(); + + const string signAlgorithm = "sha1"; + result.Add(("q-sign-algorithm", signAlgorithm)); + + result.Add(("q-ak", credentials.SecretId)); + + var signTime = $"{signValidTime.Start.ToUnixTimeSeconds().ToString()};{signValidTime.End.ToUnixTimeSeconds().ToString()}"; + var keyTime = signTime; + result.Add(("q-sign-time", signTime)); + result.Add(("q-key-time", keyTime)); + + result.Add(("q-header-list", string.Join(';', transformedHeaders.Select(h => h.key)))); + result.Add(("q-url-param-list", string.Join(';', transformedParameters.Select(p => p.key)))); + + HMACSHA1 hmac = new HMACSHA1(); + + string ByteArrayToString(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", "").ToLower(); + } + + hmac.Key = Encoding.UTF8.GetBytes(credentials.SecretKey); + var signKey = ByteArrayToString(hmac.ComputeHash(Encoding.UTF8.GetBytes(keyTime))); + + string Join(IEnumerable<(string key, string value)> raw) + { + return string.Join('&', raw.Select(p => string.Concat(p.key, "=", p.value))); + } + + var httpString = new StringBuilder() + .Append(request.Method.ToLower()).Append('\n') + .Append(request.Uri).Append('\n') + .Append(Join(transformedParameters)).Append('\n') + .Append(Join(transformedHeaders)).Append('\n') + .ToString(); + + string Sha1(string data) + { + var sha1 = SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(data)); + return ByteArrayToString(sha1); + } + + var stringToSign = new StringBuilder() + .Append(signAlgorithm).Append('\n') + .Append(signTime).Append('\n') + .Append(Sha1(httpString)).Append('\n') + .ToString(); + + hmac.Key = Encoding.UTF8.GetBytes(signKey); + var signature = ByteArrayToString(hmac.ComputeHash( + Encoding.UTF8.GetBytes(stringToSign))); + + result.Add(("q-signature", signature)); + + return Join(result); + } + + private QCloudCredentials GetCredentials() + { + var config = _config.CurrentValue; + return new QCloudCredentials + { + SecretId = config.SecretId, + SecretKey = config.SecretKey + }; + } + + private string GetHost(string bucket) + { + var config = _config.CurrentValue; + return $"{bucket}-{config.AppId}.cos.{config.Region}.myqcloud.com"; + } + + public async Task<bool> IsObjectExists(string bucket, string key) + { + if (bucket == null) + throw new ArgumentNullException(nameof(bucket)); + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (!ValidateBucketName(bucket)) + throw new ArgumentException($"Bucket name is not valid. Param is {bucket} .", nameof(bucket)); + + var client = _httpClientFactory.CreateClient(); + + var host = GetHost(bucket); + var encodedKey = WebUtility.UrlEncode(key); + + var request = new HttpRequestMessage(); + request.Method = HttpMethod.Head; + request.RequestUri = new Uri($"https://{host}/{encodedKey}"); + request.Headers.Host = host; + request.Headers.Date = DateTimeOffset.Now; + request.Headers.TryAddWithoutValidation("Authorization", GenerateSign(GetCredentials(), new RequestInfo + { + Method = "head", + Uri = "/" + encodedKey, + Headers = new Dictionary<string, string> + { + ["Host"] = host + } + }, new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(2)))); + + try + { + var response = await client.SendAsync(request); + + if (response.IsSuccessStatusCode) + return true; + if (response.StatusCode == HttpStatusCode.NotFound) + return false; + + throw new Exception($"Unknown response code. {response.ToString()}"); + } + catch (Exception e) + { + _logger.LogError(e, "An error occured when test a cos object existence."); + throw; + } + } + + public async Task PutObject(string bucket, string key, byte[] data, string contentType) + { + if (bucket == null) + throw new ArgumentNullException(nameof(bucket)); + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (!ValidateBucketName(bucket)) + throw new ArgumentException($"Bucket name is not valid. Param is {bucket} .", nameof(bucket)); + if (data == null) + throw new ArgumentNullException(nameof(data)); + + var host = GetHost(bucket); + var encodedKey = WebUtility.UrlEncode(key); + var md5 = Convert.ToBase64String(MD5.Create().ComputeHash(data)); + + const string kContentMD5HeaderName = "Content-MD5"; + const string kContentTypeHeaderName = "Content-Type"; + + var httpRequest = new HttpRequestMessage() + { + Method = HttpMethod.Put, + RequestUri = new Uri($"https://{host}/{encodedKey}") + }; + httpRequest.Headers.Host = host; + httpRequest.Headers.Date = DateTimeOffset.Now; + var httpContent = new ByteArrayContent(data); + httpContent.Headers.Add(kContentMD5HeaderName, md5); + httpRequest.Content = httpContent; + + var signedHeaders = new Dictionary<string, string> + { + ["Host"] = host, + [kContentMD5HeaderName] = md5 + }; + + if (contentType != null) + { + httpContent.Headers.Add(kContentTypeHeaderName, contentType); + signedHeaders.Add(kContentTypeHeaderName, contentType); + } + + httpRequest.Headers.TryAddWithoutValidation("Authorization", GenerateSign(GetCredentials(), new RequestInfo + { + Method = "put", + Uri = "/" + encodedKey, + Headers = signedHeaders + }, new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(10)))); + + var client = _httpClientFactory.CreateClient(); + + try + { + var response = await client.SendAsync(httpRequest); + if (!response.IsSuccessStatusCode) + throw new Exception($"Not success status code. {response.ToString()}"); + } + catch (Exception e) + { + _logger.LogError(e, "An error occured when test a cos object existence."); + throw; + } + } + + public string GenerateObjectGetUrl(string bucket, string key) + { + if (bucket == null) + throw new ArgumentNullException(nameof(bucket)); + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (!ValidateBucketName(bucket)) + throw new ArgumentException($"Bucket name is not valid. Param is {bucket} .", nameof(bucket)); + + var host = GetHost(bucket); + var encodedKey = WebUtility.UrlEncode(key); + + var signature = GenerateSign(GetCredentials(), new RequestInfo + { + Method = "get", + Uri = "/" + encodedKey, + Headers = new Dictionary<string, string> + { + ["Host"] = host + } + }, new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(6))); + + return $"https://{host}/{encodedKey}?{signature}"; + } + } +} diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index ad36c37b..8ab3bc54 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using System; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; @@ -13,10 +14,68 @@ namespace Timeline.Services public UserInfo UserInfo { get; set; } } - public enum CreateUserResult + public enum PutUserResult { + /// <summary> + /// A new user is created. + /// </summary> + Created, + /// <summary> + /// A existing user is modified. + /// </summary> + Modified + } + + public enum PatchUserResult + { + /// <summary> + /// Succeed to modify user. + /// </summary> Success, - AlreadyExists + /// <summary> + /// A user of given username does not exist. + /// </summary> + NotExists + } + + public enum DeleteUserResult + { + /// <summary> + /// A existing user is deleted. + /// </summary> + Deleted, + /// <summary> + /// A user of given username does not exist. + /// </summary> + NotExists + } + + public enum ChangePasswordResult + { + /// <summary> + /// Success to change password. + /// </summary> + Success, + /// <summary> + /// The user does not exists. + /// </summary> + NotExists, + /// <summary> + /// Old password is wrong. + /// </summary> + BadOldPassword + } + + public enum PutAvatarResult + { + /// <summary> + /// Success to upload avatar. + /// </summary> + Success, + /// <summary> + /// The user does not exists. + /// </summary> + UserNotExists } public interface IUserService @@ -38,7 +97,79 @@ namespace Timeline.Services /// <returns>Return null if verification failed. The user info if verification succeeded.</returns> Task<UserInfo> VerifyToken(string token); - Task<CreateUserResult> CreateUser(string username, string password, string[] roles); + /// <summary> + /// Get the user info of given username. + /// </summary> + /// <param name="username">Username of the user.</param> + /// <returns>The info of the user. Null if the user of given username does not exists.</returns> + Task<UserInfo> GetUser(string username); + + /// <summary> + /// List all users. + /// </summary> + /// <returns>The user info of users.</returns> + Task<UserInfo[]> ListUsers(); + + /// <summary> + /// Create or modify a user with given username. + /// Return <see cref="PutUserResult.Created"/> if a new user is created. + /// Return <see cref="PutUserResult.Modified"/> if a existing user is modified. + /// </summary> + /// <param name="username">Username of user.</param> + /// <param name="password">Password of user.</param> + /// <param name="roles">Array of roles of user.</param> + /// <returns>Return <see cref="PutUserResult.Created"/> if a new user is created. + /// Return <see cref="PutUserResult.Modified"/> if a existing user is modified.</returns> + Task<PutUserResult> PutUser(string username, string password, string[] roles); + + /// <summary> + /// Partially modify a use of given username. + /// </summary> + /// <param name="username">Username of the user to modify.</param> + /// <param name="password">New password. If not modify, then null.</param> + /// <param name="roles">New roles. If not modify, then null.</param> + /// <returns>Return <see cref="PatchUserResult.Success"/> if modification succeeds. + /// Return <see cref="PatchUserResult.NotExists"/> if the user of given username doesn't exist.</returns> + Task<PatchUserResult> PatchUser(string username, string password, string[] roles); + + /// <summary> + /// Delete a user of given username. + /// Return <see cref="DeleteUserResult.Deleted"/> if the user is deleted. + /// Return <see cref="DeleteUserResult.NotExists"/> if the user of given username + /// does not exist. + /// </summary> + /// <param name="username">Username of thet user to delete.</param> + /// <returns><see cref="DeleteUserResult.Deleted"/> if the user is deleted. + /// <see cref="DeleteUserResult.NotExists"/> if the user doesn't exist.</returns> + Task<DeleteUserResult> DeleteUser(string username); + + /// <summary> + /// Try to change a user's password with old password. + /// </summary> + /// <param name="username">The name of user to change password of.</param> + /// <param name="oldPassword">The user's old password.</param> + /// <param name="newPassword">The user's new password.</param> + /// <returns><see cref="ChangePasswordResult.Success"/> if success. + /// <see cref="ChangePasswordResult.NotExists"/> if user does not exist. + /// <see cref="ChangePasswordResult.BadOldPassword"/> if old password is wrong.</returns> + Task<ChangePasswordResult> ChangePassword(string username, string oldPassword, string newPassword); + + /// <summary> + /// Get the true avatar url of a user. + /// </summary> + /// <param name="username">The name of user.</param> + /// <returns>The url if user exists. Null if user does not exist.</returns> + Task<string> GetAvatarUrl(string username); + + /// <summary> + /// Put a avatar of a user. + /// </summary> + /// <param name="username">The name of user.</param> + /// <param name="data">The data of avatar image.</param> + /// <param name="mimeType">The mime type of the image.</param> + /// <returns>Return <see cref="PutAvatarResult.Success"/> if success. + /// Return <see cref="PutAvatarResult.UserNotExists"/> if user does not exist.</returns> + Task<PutAvatarResult> PutAvatar(string username, byte[] data, string mimeType); } public class UserService : IUserService @@ -47,19 +178,19 @@ namespace Timeline.Services private readonly DatabaseContext _databaseContext; private readonly IJwtService _jwtService; private readonly IPasswordService _passwordService; + private readonly IQCloudCosService _cosService; - public UserService(ILogger<UserService> logger, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService) + public UserService(ILogger<UserService> logger, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService, IQCloudCosService cosService) { _logger = logger; _databaseContext = databaseContext; _jwtService = jwtService; _passwordService = passwordService; + _cosService = cosService; } public async Task<CreateTokenResult> CreateToken(string username, string password) { - var users = _databaseContext.Users.ToList(); - var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) @@ -72,11 +203,11 @@ namespace Timeline.Services if (verifyResult) { - var userInfo = new UserInfo(user); + var userInfo = UserInfo.Create(user); return new CreateTokenResult { - Token = _jwtService.GenerateJwtToken(user.Id, userInfo.Roles), + Token = _jwtService.GenerateJwtToken(user.Id, userInfo.Username, userInfo.Roles), UserInfo = userInfo }; } @@ -89,38 +220,142 @@ namespace Timeline.Services public async Task<UserInfo> VerifyToken(string token) { - var userId = _jwtService.VerifyJwtToken(token); + var userInfo = _jwtService.VerifyJwtToken(token); - if (userId == null) + if (userInfo == null) { _logger.LogInformation($"Verify token falied. Reason: invalid token. Token: {token} ."); return null; } - var user = await _databaseContext.Users.Where(u => u.Id == userId.Value).SingleOrDefaultAsync(); + return await Task.FromResult(userInfo); + } + + public async Task<UserInfo> GetUser(string username) + { + return await _databaseContext.Users + .Where(user => user.Name == username) + .Select(user => UserInfo.Create(user.Name, user.RoleString)) + .SingleOrDefaultAsync(); + } + + public async Task<UserInfo[]> ListUsers() + { + return await _databaseContext.Users + .Select(user => UserInfo.Create(user.Name, user.RoleString)) + .ToArrayAsync(); + } + + public async Task<PutUserResult> PutUser(string username, string password, string[] roles) + { + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) { - _logger.LogInformation($"Verify token falied. Reason: invalid user id. UserId: {userId} Token: {token} ."); - return null; + await _databaseContext.AddAsync(new User + { + Name = username, + EncryptedPassword = _passwordService.HashPassword(password), + RoleString = string.Join(',', roles) + }); + await _databaseContext.SaveChangesAsync(); + return PutUserResult.Created; } - return new UserInfo(user); + user.EncryptedPassword = _passwordService.HashPassword(password); + user.RoleString = string.Join(',', roles); + await _databaseContext.SaveChangesAsync(); + + return PutUserResult.Modified; } - public async Task<CreateUserResult> CreateUser(string username, string password, string[] roles) + public async Task<PatchUserResult> PatchUser(string username, string password, string[] roles) { - var exists = (await _databaseContext.Users.Where(u => u.Name == username).ToListAsync()).Count != 0; + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); - if (exists) + if (user == null) + return PatchUserResult.NotExists; + + bool modified = false; + + if (password != null) { - return CreateUserResult.AlreadyExists; + modified = true; + user.EncryptedPassword = _passwordService.HashPassword(password); } - await _databaseContext.Users.AddAsync(new User { Name = username, EncryptedPassword = _passwordService.HashPassword(password), RoleString = string.Join(',', roles) }); + if (roles != null) + { + modified = true; + user.RoleString = string.Join(',', roles); + } + + if (modified) + { + await _databaseContext.SaveChangesAsync(); + } + + return PatchUserResult.Success; + } + + public async Task<DeleteUserResult> DeleteUser(string username) + { + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + + if (user == null) + { + return DeleteUserResult.NotExists; + } + + _databaseContext.Users.Remove(user); + await _databaseContext.SaveChangesAsync(); + return DeleteUserResult.Deleted; + } + + public async Task<ChangePasswordResult> ChangePassword(string username, string oldPassword, string newPassword) + { + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (user == null) + return ChangePasswordResult.NotExists; + + var verifyResult = _passwordService.VerifyPassword(user.EncryptedPassword, oldPassword); + if (!verifyResult) + return ChangePasswordResult.BadOldPassword; + + user.EncryptedPassword = _passwordService.HashPassword(newPassword); await _databaseContext.SaveChangesAsync(); + return ChangePasswordResult.Success; + } + + public async Task<string> GetAvatarUrl(string username) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + if ((await GetUser(username)) == null) + return null; + + var exists = await _cosService.IsObjectExists("avatar", username); + if (exists) + return _cosService.GenerateObjectGetUrl("avatar", username); + else + return _cosService.GenerateObjectGetUrl("avatar", "__default"); + } + + public async Task<PutAvatarResult> PutAvatar(string username, byte[] data, string mimeType) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (mimeType == null) + throw new ArgumentNullException(nameof(mimeType)); + + if ((await GetUser(username)) == null) + return PutAvatarResult.UserNotExists; - return CreateUserResult.Success; + await _cosService.PutObject("avatar", username, data, mimeType); + return PutAvatarResult.Success; } } } diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 0c8d7052..46d0afe5 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; @@ -36,26 +37,16 @@ namespace Timeline options.InputFormatters.Add(new StringInputFormatter()); }).SetCompatibilityVersion(CompatibilityVersion.Version_2_2); - if (Environment.IsDevelopment()) + services.AddCors(options => { - services.AddCors(options => + options.AddPolicy(corsPolicyName, builder => { - options.AddPolicy(corsPolicyName, builder => - { - builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().AllowCredentials(); - }); - }); - } - else - { - services.AddCors(options => - { - options.AddPolicy(corsPolicyName, builder => - { + if (Environment.IsProduction()) builder.WithOrigins("https://www.crupest.xyz", "https://crupest.xyz").AllowAnyMethod().AllowAnyHeader().AllowCredentials(); - }); + else + builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().AllowCredentials(); }); - } + }); services.Configure<JwtConfig>(Configuration.GetSection(nameof(JwtConfig))); var jwtConfig = Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>(); @@ -80,22 +71,25 @@ namespace Timeline services.AddDbContext<DatabaseContext>(options => { - options.UseMySql(databaseConfig.ConnectionString); + options.UseMySql(databaseConfig.ConnectionString) + .ConfigureWarnings(warnings => + { + if (Environment.IsProduction()) + warnings.Log(RelationalEventId.QueryClientEvaluationWarning); + else + warnings.Throw(RelationalEventId.QueryClientEvaluationWarning); + }); }); + + services.AddHttpClient(); + + services.Configure<QCloudCosConfig>(Configuration.GetSection(nameof(QCloudCosConfig))); + services.AddSingleton<IQCloudCosService, QCloudCosService>(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app) { - if (Environment.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - else - { - app.UseExceptionHandler("/Error"); - } - app.UseCors(corsPolicyName); app.UseForwardedHeaders(new ForwardedHeadersOptions diff --git a/Timeline/appsettings.Test.json b/Timeline/appsettings.Test.json deleted file mode 100644 index ea32348b..00000000 --- a/Timeline/appsettings.Test.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information", - "Microsoft.AspNetCore.Authentication": "Debug", - "Microsoft.AspNetCore.Authorization": "Debug" - } - }, - "JwtConfig": { - "SigningKey": "crupest hahahahahahahhahahahahaha" - } -} diff --git a/nuget.config b/nuget.config index d4244526..6e408d84 100644 --- a/nuget.config +++ b/nuget.config @@ -1,7 +1,8 @@ -<?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" /> + <clear/> + <add key="nuget.org" 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> diff --git a/tools/open-code b/tools/open-code new file mode 100755 index 00000000..0ccc04c1 --- /dev/null +++ b/tools/open-code @@ -0,0 +1,8 @@ +#!/bin/bash + +export DOTNET_ROOT=/opt/dotnet +export MSBuildSDKsPath=$DOTNET_ROOT/sdk/$(${DOTNET_ROOT}/dotnet --version)/Sdks + +code $(dirname "$0")/.. + +exit |