aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author杨宇千 <crupest@outlook.com>2019-05-06 14:22:10 +0800
committerGitHub <noreply@github.com>2019-05-06 14:22:10 +0800
commit79dc38059819cf6dbcb8533b031f9a0dba8f6cb1 (patch)
tree37c2b6f9dcb3d6f57867ebc7538523d1a338183d
parent1cb92b8f2a98005b793c00e0191903c0792d540a (diff)
parenta04bcb5971872e7dbc079de9337875e73f7642dc (diff)
downloadtimeline-79dc38059819cf6dbcb8533b031f9a0dba8f6cb1.tar.gz
timeline-79dc38059819cf6dbcb8533b031f9a0dba8f6cb1.tar.bz2
timeline-79dc38059819cf6dbcb8533b031f9a0dba8f6cb1.zip
Merge pull request #22 from crupest/user-admin
Develop user management feature.
-rw-r--r--.vscode/launch.json34
-rw-r--r--.vscode/tasks.json36
-rw-r--r--README.md6
-rw-r--r--Timeline.Tests/AuthorizationUnitTest.cs14
-rw-r--r--Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs (renamed from Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs)17
-rw-r--r--Timeline.Tests/Helpers/TestUsers.cs40
-rw-r--r--Timeline.Tests/Helpers/WebApplicationFactoryExtensions.cs22
-rw-r--r--Timeline.Tests/JwtTokenUnitTest.cs6
-rw-r--r--Timeline.Tests/QCloudCosServiceUnitTest.cs104
-rw-r--r--Timeline.Tests/Timeline.Tests.csproj2
-rw-r--r--Timeline.Tests/UserUnitTest.cs36
-rw-r--r--Timeline/Configs/DatabaseConfig.cs7
-rw-r--r--Timeline/Configs/QCloudCosConfig.cs10
-rw-r--r--Timeline/Controllers/TokenController.cs74
-rw-r--r--Timeline/Controllers/UserController.cs145
-rw-r--r--Timeline/Entities/Http/Common.cs29
-rw-r--r--Timeline/Entities/Http/Token.cs (renamed from Timeline/Entities/User.cs)17
-rw-r--r--Timeline/Entities/Http/User.cs54
-rw-r--r--Timeline/Entities/UserInfo.cs77
-rw-r--r--Timeline/EnvironmentConstants.cs14
-rw-r--r--Timeline/Pages/Error.cshtml26
-rw-r--r--Timeline/Pages/Error.cshtml.cs23
-rw-r--r--Timeline/Pages/_ViewImports.cshtml3
-rw-r--r--Timeline/Services/JwtService.cs22
-rw-r--r--Timeline/Services/QCloudCosService.cs329
-rw-r--r--Timeline/Services/UserService.cs275
-rw-r--r--Timeline/Startup.cs46
-rw-r--r--Timeline/appsettings.Test.json14
-rw-r--r--nuget.config5
-rwxr-xr-xtools/open-code8
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
diff --git a/README.md b/README.md
index 56ecbf2b..614bf884 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,12 @@
[![Build Status](https://dev.azure.com/crupest-web/Timeline/_apis/build/status/crupest.Timeline?branchName=master)](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