diff --git a/ApiDto/Common/AuthRoles.cs b/ApiDto/Common/AuthRoles.cs new file mode 100644 index 0000000..3d7529c --- /dev/null +++ b/ApiDto/Common/AuthRoles.cs @@ -0,0 +1,12 @@ +namespace Mirea.Api.Dto.Common; + +/// +/// An enumeration that indicates which role the user belongs to +/// +public enum AuthRoles +{ + /// + /// Administrator + /// + Admin +} \ No newline at end of file diff --git a/ApiDto/Requests/LoginRequest.cs b/ApiDto/Requests/LoginRequest.cs new file mode 100644 index 0000000..65fd4ec --- /dev/null +++ b/ApiDto/Requests/LoginRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mirea.Api.Dto.Requests; + +/// +/// Request to receive protected content +/// +public class LoginRequest +{ + /// + /// Login or Email to identify the client. + /// + [Required] + public required string Username { get; set; } + + /// + /// The client's password. + /// + [Required] + public required string Password { get; set; } +} \ No newline at end of file diff --git a/ApiDto/Responses/TokenResponse.cs b/ApiDto/Responses/TokenResponse.cs new file mode 100644 index 0000000..9761b87 --- /dev/null +++ b/ApiDto/Responses/TokenResponse.cs @@ -0,0 +1,23 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Mirea.Api.Dto.Responses; + +/// +/// Provides a JWT and RT token. +/// +public class TokenResponse +{ + /// + /// A JWT token for accessing protected resources. + /// + [Required] + public required string AccessToken { get; set; } + + /// + /// The date and time when the JWT token expires. + /// + /// After this date, a new JWT token must be requested. + [Required] + public required DateTime ExpiresIn { get; set; } +} \ No newline at end of file diff --git a/Endpoint/Common/Services/Security/DistributedCacheService.cs b/Endpoint/Common/Services/Security/DistributedCacheService.cs index bf3dc39..1d4a32b 100644 --- a/Endpoint/Common/Services/Security/DistributedCacheService.cs +++ b/Endpoint/Common/Services/Security/DistributedCacheService.cs @@ -17,7 +17,7 @@ public class DistributedCacheService(IDistributedCache cache) : ICacheService SlidingExpiration = slidingExpiration }; - var serializedValue = JsonSerializer.SerializeToUtf8Bytes(value); + var serializedValue = value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value); await cache.SetAsync(key, serializedValue, options, cancellationToken); } diff --git a/Endpoint/Common/Services/Security/MemoryCacheService.cs b/Endpoint/Common/Services/Security/MemoryCacheService.cs index a428034..6c5b8f6 100644 --- a/Endpoint/Common/Services/Security/MemoryCacheService.cs +++ b/Endpoint/Common/Services/Security/MemoryCacheService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Caching.Memory; using Mirea.Api.Security.Common.Interfaces; using System; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -16,14 +17,17 @@ public class MemoryCacheService(IMemoryCache cache) : ICacheService SlidingExpiration = slidingExpiration }; - cache.Set(key, value, options); + cache.Set(key, value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value), options); return Task.CompletedTask; } public Task GetAsync(string key, CancellationToken cancellationToken = default) { - cache.TryGetValue(key, out T? value); - return Task.FromResult(value); + return Task.FromResult( + cache.TryGetValue(key, out byte[]? value) ? + JsonSerializer.Deserialize(value) : + default + ); } public Task RemoveAsync(string key, CancellationToken cancellationToken = default) diff --git a/Endpoint/Configuration/AppConfig/SwaggerConfiguration.cs b/Endpoint/Configuration/AppConfig/SwaggerConfiguration.cs index c43c730..4c2ff9f 100644 --- a/Endpoint/Configuration/AppConfig/SwaggerConfiguration.cs +++ b/Endpoint/Configuration/AppConfig/SwaggerConfiguration.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; using Mirea.Api.Endpoint.Configuration.Swagger; using Swashbuckle.AspNetCore.SwaggerGen; using System; @@ -19,6 +20,29 @@ public static class SwaggerConfiguration options.OperationFilter(); var basePath = AppDomain.CurrentDomain.BaseDirectory; + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Keep the JWT token in the field (Bearer token)", + Name = "Authorization", + Type = SecuritySchemeType.ApiKey + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + [] + } + }); + options.IncludeXmlComments(Path.Combine(basePath, "docs.xml")); options.IncludeXmlComments(Path.Combine(basePath, "ApiDtoDocs.xml")); }); diff --git a/Endpoint/Controllers/V1/AuthController.cs b/Endpoint/Controllers/V1/AuthController.cs new file mode 100644 index 0000000..dcd1821 --- /dev/null +++ b/Endpoint/Controllers/V1/AuthController.cs @@ -0,0 +1,158 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using Mirea.Api.Dto.Common; +using Mirea.Api.Dto.Requests; +using Mirea.Api.Dto.Responses; +using Mirea.Api.Endpoint.Common.Model; +using Mirea.Api.Security.Common.Dto.Requests; +using Mirea.Api.Security.Services; +using System; +using System.Security; +using System.Threading.Tasks; + +namespace Mirea.Api.Endpoint.Controllers.V1; + +[ApiVersion("1.0")] +public class AuthController(IOptionsSnapshot user, AuthService auth, PasswordHashService passwordService) : BaseController, IActionFilter +{ + private string Fingerprint { get; set; } = string.Empty; + private string Ip { get; set; } = string.Empty; + private string UserAgent { get; set; } = string.Empty; + private string RefreshToken { get; set; } = string.Empty; + + private void SetCookie(string name, string value, DateTimeOffset? expires = null) + { + var cookieOptions = new CookieOptions + { + Expires = expires, + Path = "/api", + Domain = Request.Headers["X-Forwarded-Host"], + Secure = true, + HttpOnly = true + }; + + Response.Cookies.Append(name, value, cookieOptions); + } + + private void SetRefreshToken(string value, DateTimeOffset? expires = null) => + SetCookie("refresh_token", value, expires); + + private void SetFirstToken(string value, DateTimeOffset? expires = null) => + SetCookie("authentication_token", value, expires); + + [ApiExplorerSettings(IgnoreApi = true)] + public void OnActionExecuting(ActionExecutingContext context) + { + Ip = context.HttpContext.Connection.RemoteIpAddress?.ToString()!; + UserAgent = context.HttpContext.Request.Headers.UserAgent.ToString(); + Fingerprint = context.HttpContext.Request.Cookies["user_key"] ?? string.Empty; + RefreshToken = Request.Cookies["refresh_token"] ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(Fingerprint)) return; + + Fingerprint = Guid.NewGuid().ToString().Replace("-", ""); + SetCookie("user_key", Fingerprint); + } + + [ApiExplorerSettings(IgnoreApi = true)] + public void OnActionExecuted(ActionExecutedContext context) { } + + /// + /// Handles user authentication by verifying the username/email and password, + /// then generating and returning an authentication token if successful. + /// + /// The login request containing the username/email and password. + /// A TokenResponse containing the access token and its expiry if successful, otherwise an Unauthorized response. + [HttpPost("Login")] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Login([FromBody] LoginRequest request) + { + var userEntity = user.Value; + + if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) && + !userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase) || + !passwordService.VerifyPassword(request.Password, userEntity.Salt, userEntity.PasswordHash)) + return Unauthorized("Invalid username/email or password"); + + var token = await auth.GenerateAuthTokensAsync(new TokenRequest + { + Fingerprint = Fingerprint, + Ip = Ip, + UserAgent = UserAgent + }, "1"); + + SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn); + + return Ok(new TokenResponse + { + AccessToken = token.AccessToken, + ExpiresIn = token.AccessExpiresIn + }); + } + + /// + /// Refreshes the authentication token using the existing refresh token. + /// + /// A TokenResponse containing the new access token and its expiry if successful, otherwise an Unauthorized response. + [HttpGet("ReLogin")] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> ReLogin() + { + if (string.IsNullOrEmpty(RefreshToken)) + return Unauthorized(); + + try + { + var token = await auth.RefreshTokenAsync( + new TokenRequest + { + Ip = Ip, + UserAgent = UserAgent, + Fingerprint = Fingerprint + }, + RefreshToken + ); + + SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn); + + return Ok(new TokenResponse + { + AccessToken = token.AccessToken, + ExpiresIn = token.AccessExpiresIn + }); + } + catch (SecurityException) + { + return Unauthorized(); + } + } + + /// + /// Logs the user out by clearing the refresh token and performing any necessary cleanup. + /// + /// An Ok response if the logout was successful. + [HttpGet("Logout")] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Authorize] + public async Task Logout() + { + SetRefreshToken("", DateTimeOffset.MinValue); + SetFirstToken("", DateTimeOffset.MinValue); + + await auth.LogoutAsync(Fingerprint); + + return Ok(); + } + + /// + /// Retrieves the role of the authenticated user. + /// + /// The role of the authenticated user. + [HttpGet("GetRole")] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Authorize] + public ActionResult GetRole() => Ok(AuthRoles.Admin); +} diff --git a/Endpoint/Middleware/JwtRevocationMiddleware.cs b/Endpoint/Middleware/JwtRevocationMiddleware.cs new file mode 100644 index 0000000..97818c7 --- /dev/null +++ b/Endpoint/Middleware/JwtRevocationMiddleware.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Http; +using Mirea.Api.Security.Common.Interfaces; +using System.Threading.Tasks; + +namespace Mirea.Api.Endpoint.Middleware; + +public class JwtRevocationMiddleware(RequestDelegate next) +{ + public async Task Invoke(HttpContext context, IRevokedToken revokedTokenStore) + { + if (context.Request.Headers.ContainsKey("Authorization")) + { + var token = context.Request.Headers.Authorization.ToString().Replace("Bearer ", ""); + if (await revokedTokenStore.IsTokenRevokedAsync(token)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + + await next(context); + } +} \ No newline at end of file diff --git a/Endpoint/Program.cs b/Endpoint/Program.cs index cf016f4..d368177 100644 --- a/Endpoint/Program.cs +++ b/Endpoint/Program.cs @@ -6,6 +6,7 @@ using Mirea.Api.DataAccess.Application; using Mirea.Api.DataAccess.Persistence; using Mirea.Api.DataAccess.Persistence.Common; using Mirea.Api.Endpoint.Common.Interfaces; +using Mirea.Api.Endpoint.Common.Model; using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Configuration.AppConfig; using Mirea.Api.Endpoint.Configuration.General; @@ -37,6 +38,8 @@ public class Program builder.Configuration.AddConfiguration(EnvironmentConfiguration.GetEnvironment()); builder.Configuration.AddJsonFile(PathBuilder.Combine(GeneralConfig.FilePath), optional: true, reloadOnChange: true); builder.Services.Configure(builder.Configuration); + builder.Configuration.AddJsonFile(PathBuilder.Combine(Admin.PathToSave), optional: true, reloadOnChange: true); + builder.Services.Configure(builder.Configuration); builder.Host.AddCustomSerilog(); AddDatabase(builder.Services, builder.Configuration); @@ -95,6 +98,7 @@ public class Program app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); app.UseHttpsRedirection(); diff --git a/Security/Common/Dto/Responses/AuthTokenResponse.cs b/Security/Common/Dto/Responses/AuthTokenResponse.cs index 0c8a3d4..16aed38 100644 --- a/Security/Common/Dto/Responses/AuthTokenResponse.cs +++ b/Security/Common/Dto/Responses/AuthTokenResponse.cs @@ -5,6 +5,7 @@ namespace Mirea.Api.Security.Common.Dto.Responses; public class AuthTokenResponse { public required string AccessToken { get; set; } + public DateTime AccessExpiresIn { get; set; } public required string RefreshToken { get; set; } - public DateTime ExpiresIn { get; set; } + public DateTime RefreshExpiresIn { get; set; } } \ No newline at end of file diff --git a/Security/Services/AuthService.cs b/Security/Services/AuthService.cs index 5426532..5b293b3 100644 --- a/Security/Services/AuthService.cs +++ b/Security/Services/AuthService.cs @@ -34,7 +34,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I public async Task GenerateAuthTokensAsync(TokenRequest request, string userId, CancellationToken cancellation = default) { var refreshToken = GenerateRefreshToken(); - var accessToken = GenerateAccessToken(userId); + var (token, expireIn) = GenerateAccessToken(userId); var authTokenStruct = new AuthToken { @@ -43,16 +43,17 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I RefreshToken = refreshToken, UserAgent = request.UserAgent, UserId = userId, - AccessToken = accessToken.Token + AccessToken = token }; await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation); return new AuthTokenResponse { - AccessToken = accessToken.Token, - ExpiresIn = accessToken.ExpireIn, - RefreshToken = authTokenStruct.RefreshToken + AccessToken = token, + AccessExpiresIn = expireIn, + RefreshToken = authTokenStruct.RefreshToken, + RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime), }; } @@ -71,23 +72,28 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I authToken.UserAgent != request.UserAgent && authToken.Ip != request.Ip) { - await cache.RemoveAsync(request.Fingerprint, cancellation); + await cache.RemoveAsync(GetAuthCacheKey(request.Fingerprint), cancellation); await RevokeAccessToken(authToken.AccessToken); throw new SecurityException(request.Fingerprint); } - var accessToken = GenerateAccessToken(authToken.UserId); + var (token, expireIn) = GenerateAccessToken(authToken.UserId); await RevokeAccessToken(authToken.AccessToken); - authToken.AccessToken = accessToken.Token; + var newRefreshToken = GenerateRefreshToken(); + + authToken.AccessToken = token; + authToken.RefreshToken = newRefreshToken; + await SetAuthTokenDataToCache(request.Fingerprint, authToken, cancellation); return new AuthTokenResponse { - AccessToken = accessToken.Token, - ExpiresIn = accessToken.ExpireIn, - RefreshToken = GenerateRefreshToken() + AccessToken = token, + AccessExpiresIn = expireIn, + RefreshToken = newRefreshToken, + RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime) }; }