From 6e156ee3a43cf530c20bea0570b9fae06372bd24 Mon Sep 17 00:00:00 2001 From: Cardidi Date: Sun, 23 Mar 2025 06:33:26 +0800 Subject: [PATCH] Add login and register feature. --- .../.idea/dataSources.xml | 13 ++ .idea/Flawless-Version-Control.iml | 8 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .idea/workspace.xml | 53 +++++ Flawless-Version-Control.sln.DotSettings.user | 13 ++ Flawless.Abstract/HashUtility.cs | 17 ++ .../Authentication/AuthenticationStatus.cs | 7 - .../Authentication/LoginRequest.cs | 10 - .../Authentication/RegisterRequest.cs | 12 - .../Authentication/RegisterResult.cs | 10 - .../Request/Auth/LoginRequest.cs | 8 + .../Request/Auth/RegisterRequest.cs | 10 + .../Response/FailedResponse.cs | 8 + .../Response/UnintendedExceptionResponse.cs | 8 + Flawless.Communication/Shared/TokenInfo.cs | 6 + Flawless.Core/Flawless.Core.csproj | 2 + .../Controllers/AuthenticationController.cs | 137 ++++++++++-- Flawless.Server/Flawless.Server.csproj | 16 ++ Flawless.Server/FlawlessClaimsType.cs | 8 + .../ExceptionTransformMiddleware.cs | 17 ++ .../Middlewares/WebSocketHandoffMiddleware.cs | 18 +- Flawless.Server/Models/AppUser.cs | 13 ++ Flawless.Server/Models/AppUserRefreshKey.cs | 13 ++ Flawless.Server/Models/GlobalContext.cs | 10 - Flawless.Server/Program.cs | 208 +++++++++++++----- Flawless.Server/Services/AppDbContext.cs | 12 + .../{Models => Services}/RepositoryContext.cs | 7 +- .../RepositoryContextFactory.cs | 2 +- .../Services/TokenGenerationService.cs | 93 ++++++++ Flawless.Server/Settings.cs | 9 - Flawless.Server/appsettings.Development.json | 14 ++ Flawless.Server/appsettings.json | 15 +- 33 files changed, 647 insertions(+), 144 deletions(-) create mode 100644 .idea/.idea.Flawless-Version-Control/.idea/dataSources.xml create mode 100644 .idea/Flawless-Version-Control.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 Flawless.Abstract/HashUtility.cs delete mode 100644 Flawless.Communication/Authentication/AuthenticationStatus.cs delete mode 100644 Flawless.Communication/Authentication/LoginRequest.cs delete mode 100644 Flawless.Communication/Authentication/RegisterRequest.cs delete mode 100644 Flawless.Communication/Authentication/RegisterResult.cs create mode 100644 Flawless.Communication/Request/Auth/LoginRequest.cs create mode 100644 Flawless.Communication/Request/Auth/RegisterRequest.cs create mode 100644 Flawless.Communication/Response/FailedResponse.cs create mode 100644 Flawless.Communication/Response/UnintendedExceptionResponse.cs create mode 100644 Flawless.Communication/Shared/TokenInfo.cs create mode 100644 Flawless.Server/FlawlessClaimsType.cs create mode 100644 Flawless.Server/Middlewares/ExceptionTransformMiddleware.cs create mode 100644 Flawless.Server/Models/AppUser.cs create mode 100644 Flawless.Server/Models/AppUserRefreshKey.cs delete mode 100644 Flawless.Server/Models/GlobalContext.cs create mode 100644 Flawless.Server/Services/AppDbContext.cs rename Flawless.Server/{Models => Services}/RepositoryContext.cs (59%) rename Flawless.Server/{Models => Services}/RepositoryContextFactory.cs (91%) create mode 100644 Flawless.Server/Services/TokenGenerationService.cs delete mode 100644 Flawless.Server/Settings.cs diff --git a/.idea/.idea.Flawless-Version-Control/.idea/dataSources.xml b/.idea/.idea.Flawless-Version-Control/.idea/dataSources.xml new file mode 100644 index 0000000..2227939 --- /dev/null +++ b/.idea/.idea.Flawless-Version-Control/.idea/dataSources.xml @@ -0,0 +1,13 @@ + + + + + postgresql + true + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/postgres + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/Flawless-Version-Control.iml b/.idea/Flawless-Version-Control.iml new file mode 100644 index 0000000..0399c4b --- /dev/null +++ b/.idea/Flawless-Version-Control.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..515d104 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..5a7a6af --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Flawless-Version-Control.sln.DotSettings.user b/Flawless-Version-Control.sln.DotSettings.user index c4b3a2c..d55441a 100644 --- a/Flawless-Version-Control.sln.DotSettings.user +++ b/Flawless-Version-Control.sln.DotSettings.user @@ -1,12 +1,25 @@  + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded <SessionState ContinuousTestingMode="0" IsActive="True" IsLocked="True" Name="PathValidationTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit</TestId> diff --git a/Flawless.Abstract/HashUtility.cs b/Flawless.Abstract/HashUtility.cs new file mode 100644 index 0000000..2dcadcf --- /dev/null +++ b/Flawless.Abstract/HashUtility.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Flawless.Abstraction; + +public static class HashUtility +{ + public static UInt128 StringToMd5Uint128(string input) + { + ReadOnlySpan source = Encoding.UTF8.GetBytes(input); + Span destination = stackalloc byte[16]; + + if (MD5.HashData(source, destination) != 16) throw new InvalidOperationException("MD5 hash length is invalid."); + + return BitConverter.ToUInt128(destination); + } +} \ No newline at end of file diff --git a/Flawless.Communication/Authentication/AuthenticationStatus.cs b/Flawless.Communication/Authentication/AuthenticationStatus.cs deleted file mode 100644 index 55632f1..0000000 --- a/Flawless.Communication/Authentication/AuthenticationStatus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Flawless.Communication.Authentication; - -public record AuthenticationStatus -{ - public required bool OpenRegister { get; set; } - public required bool OpenLogin { get; set; } -} \ No newline at end of file diff --git a/Flawless.Communication/Authentication/LoginRequest.cs b/Flawless.Communication/Authentication/LoginRequest.cs deleted file mode 100644 index c970aff..0000000 --- a/Flawless.Communication/Authentication/LoginRequest.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Flawless.Communication.Authentication; - -public record LoginRequest -{ - public required string Identification { get; init; } - - public required string Password { get; init; } - - public required bool DontExpireHalfMonth { get; set; } -} \ No newline at end of file diff --git a/Flawless.Communication/Authentication/RegisterRequest.cs b/Flawless.Communication/Authentication/RegisterRequest.cs deleted file mode 100644 index f391d98..0000000 --- a/Flawless.Communication/Authentication/RegisterRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Flawless.Communication.Authentication; - -public record RegisterRequest -{ - public required string Email { get; set; } - - public required string Username { get; set; } - - public required string Password { get; set; } - - public required bool DontExpireHalfMonth { get; set; } -} \ No newline at end of file diff --git a/Flawless.Communication/Authentication/RegisterResult.cs b/Flawless.Communication/Authentication/RegisterResult.cs deleted file mode 100644 index 5f2009c..0000000 --- a/Flawless.Communication/Authentication/RegisterResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Flawless.Communication.Authentication; - -public enum RegisterResultStatus -{ - Success, - Forbidden, - Registered -} - -public record RegisterResult(RegisterResultStatus Status); \ No newline at end of file diff --git a/Flawless.Communication/Request/Auth/LoginRequest.cs b/Flawless.Communication/Request/Auth/LoginRequest.cs new file mode 100644 index 0000000..149f062 --- /dev/null +++ b/Flawless.Communication/Request/Auth/LoginRequest.cs @@ -0,0 +1,8 @@ +namespace Flawless.Communication.Request.Auth; + +public record LoginRequest +{ + public required string Username { get; set; } + + public required string Password { get; set; } +} \ No newline at end of file diff --git a/Flawless.Communication/Request/Auth/RegisterRequest.cs b/Flawless.Communication/Request/Auth/RegisterRequest.cs new file mode 100644 index 0000000..aab3e4b --- /dev/null +++ b/Flawless.Communication/Request/Auth/RegisterRequest.cs @@ -0,0 +1,10 @@ +namespace Flawless.Communication.Request.Auth; + +public record RegisterRequest +{ + public string Email { get; set; } + + public string Username { get; set; } + + public string Password { get; set; } +} \ No newline at end of file diff --git a/Flawless.Communication/Response/FailedResponse.cs b/Flawless.Communication/Response/FailedResponse.cs new file mode 100644 index 0000000..5594aee --- /dev/null +++ b/Flawless.Communication/Response/FailedResponse.cs @@ -0,0 +1,8 @@ +namespace Flawless.Communication.Response; + +public record FailedResponse(object message, string? failure = "Unknown") +{ + public string? Failure { get; set; } = failure; + + public object? Message { get; set; } = message; +} \ No newline at end of file diff --git a/Flawless.Communication/Response/UnintendedExceptionResponse.cs b/Flawless.Communication/Response/UnintendedExceptionResponse.cs new file mode 100644 index 0000000..3b03e69 --- /dev/null +++ b/Flawless.Communication/Response/UnintendedExceptionResponse.cs @@ -0,0 +1,8 @@ +namespace Flawless.Communication.Response; + +public record UnintendedExceptionResponse(Exception e) +{ + public string ExceptionType { get; } = e.GetType().Name; + + public string ExceptionMessage { get; } = e.Message; +} \ No newline at end of file diff --git a/Flawless.Communication/Shared/TokenInfo.cs b/Flawless.Communication/Shared/TokenInfo.cs new file mode 100644 index 0000000..9445db1 --- /dev/null +++ b/Flawless.Communication/Shared/TokenInfo.cs @@ -0,0 +1,6 @@ +namespace Flawless.Communication.Shared; + +public record TokenInfo +{ + public required string Token { get; set; } +} \ No newline at end of file diff --git a/Flawless.Core/Flawless.Core.csproj b/Flawless.Core/Flawless.Core.csproj index 2cd32ab..1de3793 100644 --- a/Flawless.Core/Flawless.Core.csproj +++ b/Flawless.Core/Flawless.Core.csproj @@ -11,8 +11,10 @@ + + diff --git a/Flawless.Server/Controllers/AuthenticationController.cs b/Flawless.Server/Controllers/AuthenticationController.cs index 3e4d8aa..244ff82 100644 --- a/Flawless.Server/Controllers/AuthenticationController.cs +++ b/Flawless.Server/Controllers/AuthenticationController.cs @@ -1,34 +1,137 @@ -using Flawless.Communication.Authentication; +using System.Security.Claims; +using Flawless.Communication.Request.Auth; +using Flawless.Communication.Response; +using Flawless.Communication.Shared; using Flawless.Server.Models; +using Flawless.Server.Services; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace Flawless.Server.Controllers; [ApiController, Route("api/auth")] -public class AuthenticationController(GlobalContext dbContext, ILogger logger) +public class AuthenticationController( + UserManager userManager, + SignInManager signInManager, + TokenGenerationService tokenService, + AppDbContext dbContext, + IConfiguration config, + ILogger logger) : ControllerBase { - [HttpGet("status")] - public ActionResult GetStatus() - { - logger.LogInformation("Authentication status has sent to {0}", HttpContext.Connection.RemoteIpAddress); - return new AuthenticationStatus() - { - OpenRegister = true, - OpenLogin = true, - }; - } - [HttpPost("register")] - public async Task> RegisterAsync(RegisterRequest request) + public async Task PublicRegisterAsync(RegisterRequest request) { - return BadRequest(); + if (!config.GetValue("User:PublicRegister", true)) + return BadRequest(new FailedResponse("Not opened for public register.")); + + + var user = new AppUser + { + UserName = request.Username, + Email = request.Email, + EmailConfirmed = true, + CreatedOn = DateTime.UtcNow, + SecurityStamp = Guid.NewGuid().ToString() + }; + + var result = await userManager.CreateAsync(user, request.Password); + if (result.Succeeded) + { + logger.LogInformation("User '{0}' created (PUBLIC REGISTER)", user.UserName); + return Ok(); + } + + logger.LogInformation("User '{0}' NOT created (PUBLIC REGISTER) : {1}", user.UserName, result.Errors); + return BadRequest(new FailedResponse(result.Errors)); } [HttpPost("login")] - public async Task> LoginAsync([FromBody] LoginRequest request) + public async Task LoginAsync(LoginRequest r) { - return "SuccessToken"; + var user = await userManager.FindByNameAsync(r.Username); + if (user == null) return BadRequest(new FailedResponse("Invalid username or password.")); + + var result = await signInManager.CheckPasswordSignInAsync(user, r.Password, false); + if (result.Succeeded) + { + var refreshToken = tokenService.GenerateRefreshToken(); + var claims = await GetClaimsAsync(user, refreshToken); + var jwtToken = tokenService.GenerateToken(claims); + var refKey = new AppUserRefreshKey + { + UserId = user.Id, + RefreshToken = refreshToken, + ExpireIn = DateTime.UtcNow.AddDays(tokenService.RefreshTokenLifeTime), + }; + + dbContext.RefreshTokens.Add(refKey); + await dbContext.SaveChangesAsync(); + await userManager.AddLoginAsync(user, new UserLoginInfo("login-interface", "", null)); + + return Ok(new TokenInfo { Token = jwtToken }); + } + + if (result.IsLockedOut) + { + return BadRequest(new FailedResponse("Account is locked out.")); + } + + return BadRequest(new FailedResponse("Invalid username or password.")); + } + + [HttpPost("refresh")] + public async Task RefreshAsync(TokenInfo r) + { + var now = DateTime.UtcNow; + var set = dbContext.RefreshTokens; + var principal = tokenService.GetPrincipalFromExpiredToken(r.Token); + var user = await signInManager.ValidateSecurityStampAsync(principal); + if (user == null) return BadRequest(new FailedResponse("Token is ban. Please login again.")); + + try + { + // Remove timeout guys + await set.Where(k => k.ExpireIn < now).ExecuteDeleteAsync(); + + // Find valid expired refresh token + var refreshToken = principal.FindFirst(FlawlessClaimsType.RefreshToken)?.Value; + var tk = await set.FirstOrDefaultAsync(k => k.RefreshToken == refreshToken && k.UserId.ToString() == user.Id.ToString()); + if (tk == null) return BadRequest(new FailedResponse("Token is ban. Please login again.")); + + // Renew keys + refreshToken = tokenService.GenerateRefreshToken(); + var claims = await GetClaimsAsync(user, refreshToken); + var newJwtToken = tokenService.GenerateToken(claims); + + // Reassign a new key. + set.Remove(tk); + set.Add(new AppUserRefreshKey + { + UserId = user.Id, + RefreshToken = refreshToken, + ExpireIn = DateTime.UtcNow.AddDays(tokenService.RefreshTokenLifeTime), + }); + + return Ok(new TokenInfo { Token = newJwtToken }); + } + finally + { + await dbContext.SaveChangesAsync(); + } + + } + + private async ValueTask> GetClaimsAsync(AppUser user, string refreshToken) + { + var c = await userManager.GetClaimsAsync(user); + c.Add(new (FlawlessClaimsType.SecurityStamp, user.SecurityStamp ?? string.Empty)); + c.Add(new (ClaimTypes.NameIdentifier, user.Id.ToString())); + c.Add(new (ClaimTypes.Name, user.UserName!)); + c.Add(new (FlawlessClaimsType.RefreshToken, refreshToken)); + + return c; } } \ No newline at end of file diff --git a/Flawless.Server/Flawless.Server.csproj b/Flawless.Server/Flawless.Server.csproj index 7b32f3c..e787c38 100644 --- a/Flawless.Server/Flawless.Server.csproj +++ b/Flawless.Server/Flawless.Server.csproj @@ -8,9 +8,14 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -25,4 +30,15 @@ + + + + + + + + + + + diff --git a/Flawless.Server/FlawlessClaimsType.cs b/Flawless.Server/FlawlessClaimsType.cs new file mode 100644 index 0000000..fefd674 --- /dev/null +++ b/Flawless.Server/FlawlessClaimsType.cs @@ -0,0 +1,8 @@ +namespace Flawless.Server; + +public static class FlawlessClaimsType +{ + public static readonly string SecurityStamp = "sest"; + + public static readonly string RefreshToken = "reftk"; +} \ No newline at end of file diff --git a/Flawless.Server/Middlewares/ExceptionTransformMiddleware.cs b/Flawless.Server/Middlewares/ExceptionTransformMiddleware.cs new file mode 100644 index 0000000..6d26f2c --- /dev/null +++ b/Flawless.Server/Middlewares/ExceptionTransformMiddleware.cs @@ -0,0 +1,17 @@ +using Flawless.Communication.Response; + +namespace Flawless.Server.Middlewares; + +public class ExceptionTransformMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + try { await next(context); } + catch (Exception e) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsJsonAsync( + new FailedResponse(new UnintendedExceptionResponse(e), "UnintendedException")); + } + } +} \ No newline at end of file diff --git a/Flawless.Server/Middlewares/WebSocketHandoffMiddleware.cs b/Flawless.Server/Middlewares/WebSocketHandoffMiddleware.cs index 74e9fc0..755d217 100644 --- a/Flawless.Server/Middlewares/WebSocketHandoffMiddleware.cs +++ b/Flawless.Server/Middlewares/WebSocketHandoffMiddleware.cs @@ -1,3 +1,5 @@ +using Flawless.Communication.Response; + namespace Flawless.Server.Middlewares; public class WebSocketHandoffMiddleware(RequestDelegate next) @@ -6,16 +8,14 @@ public class WebSocketHandoffMiddleware(RequestDelegate next) { // Is a WebSocket call and no ws presents. if (context.Request.Path.StartsWithSegments("/ws") && !context.WebSockets.IsWebSocketRequest) - throw new InvalidOperationException("Connection is not a WebSocket!"); + { + context.Response.StatusCode = 400; + await context.Response.WriteAsJsonAsync( + new FailedResponse("Connection should be a WebSocket!", "NotWebSocketRequest")); + + return; + } await next(context); } } - -public static class WebSocketHandoffMiddlewareExtensions -{ - public static IApplicationBuilder UseWebSocketHandoffMiddleware(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } -} \ No newline at end of file diff --git a/Flawless.Server/Models/AppUser.cs b/Flawless.Server/Models/AppUser.cs new file mode 100644 index 0000000..ee948cb --- /dev/null +++ b/Flawless.Server/Models/AppUser.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; + +namespace Flawless.Server.Models; + +public class AppUser : IdentityUser +{ + public DateTime CreatedOn { get; set; } + + public void RenewSecurityStamp() + { + this.SecurityStamp = Guid.NewGuid().ToString(); + } +} \ No newline at end of file diff --git a/Flawless.Server/Models/AppUserRefreshKey.cs b/Flawless.Server/Models/AppUserRefreshKey.cs new file mode 100644 index 0000000..5e00a62 --- /dev/null +++ b/Flawless.Server/Models/AppUserRefreshKey.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Flawless.Server.Models; + +public class AppUserRefreshKey +{ + [Key] + public required string RefreshToken { get; set; } + + public Guid UserId { get; set; } + + public DateTime ExpireIn { get; set; } +} \ No newline at end of file diff --git a/Flawless.Server/Models/GlobalContext.cs b/Flawless.Server/Models/GlobalContext.cs deleted file mode 100644 index 9f74d56..0000000 --- a/Flawless.Server/Models/GlobalContext.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Flawless.Server.Models; - -public class GlobalContext : DbContext -{ - public GlobalContext(DbContextOptions options) : base(options) - { - } -} \ No newline at end of file diff --git a/Flawless.Server/Program.cs b/Flawless.Server/Program.cs index d6775c0..2ad63c1 100644 --- a/Flawless.Server/Program.cs +++ b/Flawless.Server/Program.cs @@ -1,69 +1,173 @@ -using Flawless.Server.Controllers; +using System.Security.Claims; +using System.Text; +using Flawless.Communication.Response; using Flawless.Server.Middlewares; using Flawless.Server.Models; +using Flawless.Server.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; -var builder = WebApplication.CreateBuilder(args); +namespace Flawless.Server; -// Api related -builder.Services.AddOpenApi(); -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSingleton(); -builder.Services.AddSwaggerGen(opt => +public static class Program { - opt.DocInclusionPredicate((name, api) => api.HttpMethod != null); // Filter out WebSocket methods - opt.SupportNonNullableReferenceTypes(); -}); + + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + ConfigAppService(builder); + ConfigAuthentication(builder); + ConfigDbContext(builder); + + var app = builder.Build(); + + SetupWebApplication(app); + app.Run(); + } -// Authentication related. -builder.Services.AddAuthentication(opt => -{ - opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; -}).AddJwtBearer(opt => -{ + private static void ConfigAppService(WebApplicationBuilder builder) + { + // Api related + builder.Services.AddOpenApi(); + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(opt => + { + opt.DocInclusionPredicate((name, api) => api.HttpMethod != null); // Filter out WebSocket methods + opt.SupportNonNullableReferenceTypes(); + }); + } -}); + private static void ConfigDbContext(WebApplicationBuilder builder) + { + // Data connection related. + builder.Services.AddDbContextFactory(); + builder.Services.AddDbContext(opt => + { + opt.UseNpgsql(builder.Configuration.GetConnectionString("CoreDb")); + }); -// Data connection related. -builder.Services.AddDbContextFactory(); -builder.Services.AddDbContext(opt => -{ - opt.UseInMemoryDatabase("Main"); -}); + builder.Services.AddIdentityCore(opt => + { + opt.Password.RequireLowercase = false; + opt.Password.RequireUppercase = false; + opt.Password.RequireNonAlphanumeric = false; + opt.Password.RequireDigit = false; + opt.Password.RequiredLength = 6; + opt.User.RequireUniqueEmail = true; + opt.SignIn.RequireConfirmedAccount = false; + opt.SignIn.RequireConfirmedEmail = false; + opt.SignIn.RequireConfirmedPhoneNumber = false; + opt.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier; + opt.ClaimsIdentity.UserNameClaimType = ClaimTypes.Name; + opt.ClaimsIdentity.SecurityStampClaimType = FlawlessClaimsType.SecurityStamp; + }) + .AddSignInManager() + .AddEntityFrameworkStores(); + } -var app = builder.Build(); + private static void ConfigAuthentication(WebApplicationBuilder builder) + { + var config = builder.Configuration; + var secretKey = config["Jwt:SecretKey"] ?? throw new ApplicationException("No secret key found."); + var issuer = config["Jwt:Issuer"] ?? throw new ApplicationException("No issuer found."); -app.UseRouting(); -// Config WebSocket support. -app.UseWebSockets(new WebSocketOptions -{ - KeepAliveInterval = TimeSpan.FromSeconds(60), - KeepAliveTimeout = TimeSpan.FromSeconds(300), -}); -app.UseWebSocketHandoffMiddleware(); + // Authentication related. + builder.Services.AddSingleton(); + builder.Services.AddAuthentication(opt => + { + opt.DefaultScheme = + opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(opt => + { + opt.TokenValidationParameters = new TokenValidationParameters { + ValidateIssuer = true, + ValidateAudience = false, + ValidateIssuerSigningKey = true, + ValidIssuer = issuer, + ValidateLifetime = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ClockSkew = TimeSpan.Zero, + RoleClaimType = ClaimTypes.Role, + NameClaimType = ClaimTypes.Name, + }; + + opt.Events = new JwtBearerEvents { + OnAuthenticationFailed = context => + { + if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) { + context.Response.Headers.Append("Token-Expired", "true"); + } + + return Task.CompletedTask; + }, + OnTokenValidated = async context => + { + if (context.Principal != null) + { + var p = context.Principal!; -// Configure identity control -app.UseAuthentication(); -app.UseAuthorization(); + var id = p.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (id == null) throw new SecurityTokenExpiredException("User is not defined in the token!"); -// Configure actual controllers -app.MapControllers(); + var stamp = p.FindFirst(FlawlessClaimsType.SecurityStamp)?.Value; + if (stamp == null) throw new SecurityTokenExpiredException("No valid SecurityStamp found."); -// Configure fallback endpoints -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); - app.UseSwagger(); - app.UseSwaggerUI(); - app.MapGet("/", () => Results.Redirect("/swagger/index.html")); -} -else -{ - app.MapGet("/", () => "

Please use client app to open this server.

"); -} + var db = context.HttpContext.RequestServices.GetRequiredService>(); -app.Run(); + // Start validate user is existed and not expired. + var u = await db.FindByIdAsync(id!); + if (u == null) throw new SecurityTokenExpiredException("User is not existed in db."); + if (u.SecurityStamp != stamp) throw new SecurityTokenExpiredException("SecurityStamp is mismatched."); + + context.HttpContext.User = p; + context.Success(); + } + } + }; + }); + // builder.Services.AddAuthorization(opt => + // { + // opt.DefaultPolicy = + // }) + } + + private static void SetupWebApplication(WebApplication app) + { + app.UseMiddleware(); + app.UseRouting(); + + // Config WebSocket support. + app.UseWebSockets(new WebSocketOptions + { + KeepAliveInterval = TimeSpan.FromSeconds(60), + KeepAliveTimeout = TimeSpan.FromSeconds(300), + }); + app.UseMiddleware(); + + // Configure identity control + app.UseAuthentication(); + app.UseAuthorization(); + + // Configure actual controllers + app.MapControllers(); + + // Configure fallback endpoints + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); + app.MapGet("/", () => Results.Redirect("/swagger/index.html")); + } + else + { + app.MapGet("/", () => "

Please use client app to open this server.

"); + } + } +} \ No newline at end of file diff --git a/Flawless.Server/Services/AppDbContext.cs b/Flawless.Server/Services/AppDbContext.cs new file mode 100644 index 0000000..1fe39a5 --- /dev/null +++ b/Flawless.Server/Services/AppDbContext.cs @@ -0,0 +1,12 @@ +using Flawless.Server.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Flawless.Server.Services; + +public class AppDbContext(DbContextOptions options) + : IdentityDbContext, Guid>(options) +{ + public DbSet RefreshTokens { get; set; } +} \ No newline at end of file diff --git a/Flawless.Server/Models/RepositoryContext.cs b/Flawless.Server/Services/RepositoryContext.cs similarity index 59% rename from Flawless.Server/Models/RepositoryContext.cs rename to Flawless.Server/Services/RepositoryContext.cs index b12fc46..8917267 100644 --- a/Flawless.Server/Models/RepositoryContext.cs +++ b/Flawless.Server/Services/RepositoryContext.cs @@ -1,15 +1,14 @@ using Microsoft.EntityFrameworkCore; -namespace Flawless.Server.Models; +namespace Flawless.Server.Services; public class RepositoryContext : DbContext { private readonly string RepositoryPath; - public RepositoryContext(IConfiguration config, DbContextOptions options) : base(options) + public RepositoryContext(IConfiguration config, DbContextOptions options) : base(options) { - var settings = config.GetSection("Settings").Get(); - RepositoryPath = settings?.DataStoragePath ?? "./Data"; + RepositoryPath = Path.Combine(config["LocalStoragePath"] ?? "./Data", "Repository"); if (!Directory.Exists(RepositoryPath)) Directory.CreateDirectory(RepositoryPath); diff --git a/Flawless.Server/Models/RepositoryContextFactory.cs b/Flawless.Server/Services/RepositoryContextFactory.cs similarity index 91% rename from Flawless.Server/Models/RepositoryContextFactory.cs rename to Flawless.Server/Services/RepositoryContextFactory.cs index 208876a..54e0edf 100644 --- a/Flawless.Server/Models/RepositoryContextFactory.cs +++ b/Flawless.Server/Services/RepositoryContextFactory.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Internal; -namespace Flawless.Server.Models; +namespace Flawless.Server.Services; public class RepositoryContextFactory : DbContextFactory { diff --git a/Flawless.Server/Services/TokenGenerationService.cs b/Flawless.Server/Services/TokenGenerationService.cs new file mode 100644 index 0000000..c325dd2 --- /dev/null +++ b/Flawless.Server/Services/TokenGenerationService.cs @@ -0,0 +1,93 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Flawless.Server.Services; + +public class TokenGenerationService +{ + private readonly SymmetricSecurityKey _key; + + private readonly string _issuer; + + private readonly double _expiresIn; + + private readonly double _refreshTokenLifeTime; + + public TokenGenerationService(IConfiguration c) + { + var rawKey = Encoding.UTF8.GetBytes(c["Jwt:SecretKey"] ?? throw new Exception("No Jwt:SecretKey")); + _key = new SymmetricSecurityKey(rawKey); + _issuer = c["Jwt:Issuer"] ?? throw new Exception("No Jwt:Issuer"); + _expiresIn = double.Parse(c["Jwt:ExpiresIn"] ?? throw new Exception("No Jwt:ExpiresIn")); + _refreshTokenLifeTime = double.Parse(c["Jwt:RefreshTokenLifeTime"] ?? throw new Exception("No Jwt:RefreshTokenLifeTime")); + } + + public double RefreshTokenLifeTime => _refreshTokenLifeTime; + + public string GenerateToken(IEnumerable claims) + { + var now = DateTime.UtcNow; + var jwt = new JwtSecurityToken( + issuer: _issuer, + claims: claims, + notBefore: now, + expires: now.AddMinutes(_expiresIn), + signingCredentials: new SigningCredentials(_key, SecurityAlgorithms.HmacSha256) + ); + + return new JwtSecurityTokenHandler().WriteToken(jwt); + } + + public ClaimsPrincipal GetPrincipalFromExpiredToken(string token) + { + var tokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + ValidateIssuer = true, + ValidateLifetime = false, + ValidateIssuerSigningKey = true, + ValidIssuer = _issuer, + IssuerSigningKey = _key, + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); + var jwtSecurityToken = securityToken as JwtSecurityToken; + if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) + throw new SecurityTokenException("Invalid token"); + + return principal; + } + + public ClaimsPrincipal GetPrincipal(string token) + { + var tokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + ValidateIssuer = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = _issuer, + IssuerSigningKey = _key, + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); + var jwtSecurityToken = securityToken as JwtSecurityToken; + if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) + throw new SecurityTokenException("Invalid token"); + + return principal; + } + + public string GenerateRefreshToken() + { + Span randomNumber = stackalloc byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } +} \ No newline at end of file diff --git a/Flawless.Server/Settings.cs b/Flawless.Server/Settings.cs deleted file mode 100644 index e0f02e3..0000000 --- a/Flawless.Server/Settings.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Flawless.Server; - -public sealed class Settings -{ - // Local settings - public required string DataStoragePath { get; set; } = "./Data"; - - -} \ No newline at end of file diff --git a/Flawless.Server/appsettings.Development.json b/Flawless.Server/appsettings.Development.json index 0c208ae..d4ee3dc 100644 --- a/Flawless.Server/appsettings.Development.json +++ b/Flawless.Server/appsettings.Development.json @@ -4,5 +4,19 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "CoreDb": "Server=localhost;Port=5432;User Id=postgres;Password=postgres;Database=flawless" + }, + "LocalStoragePath": "./data/development", + "User": { + "PublicRegister": false + }, + "Jwt": { + "SecretKey": "your_256bit_security_key_at_here_otherwise_not_bootable", + "Issuer": "test", + "ExpiresIn": 30, + "RefreshTokenLifeTime": 7 } } diff --git a/Flawless.Server/appsettings.json b/Flawless.Server/appsettings.json index add7934..96b602d 100644 --- a/Flawless.Server/appsettings.json +++ b/Flawless.Server/appsettings.json @@ -5,8 +5,17 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "Settings": { - "DataStoragePath": "/Users/hcm-b0485/Desktop/test" + "AllowedHosts": "*", + "ConnectionStrings": { + "CoreDb": "Server=localhost;Port=5432;User Id=postgres;Password=postgres;Database=flawless" + }, + "LocalStoragePath": "./data/production", + "User": { + "PublicRegister": false + }, + "Jwt": { + "SecretKey": "your_256bit_security_key_at_here_otherwise_not_bootable", + "Issuer": "test", + "ExpiresIn": 30 } }