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)
};
}