diff options
Diffstat (limited to 'Timeline/Services')
-rw-r--r-- | Timeline/Services/JwtService.cs | 22 | ||||
-rw-r--r-- | Timeline/Services/QCloudCosService.cs | 329 | ||||
-rw-r--r-- | Timeline/Services/UserService.cs | 275 |
3 files changed, 597 insertions, 29 deletions
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; } } } |