Compare commits

..

No commits in common. "160c7505f0ee87aa5d7f3710b7c0f0726b99e03d" and "21866d54cbe685c60a6e964916d7a79c087b1e6c" have entirely different histories.

11 changed files with 15 additions and 263 deletions

View File

@ -1,12 +0,0 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// An enumeration that indicates which role the user belongs to
/// </summary>
public enum AuthRoles
{
/// <summary>
/// Administrator
/// </summary>
Admin
}

View File

@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Request to receive protected content
/// </summary>
public class LoginRequest
{
/// <summary>
/// Login or Email to identify the client.
/// </summary>
[Required]
public required string Username { get; set; }
/// <summary>
/// The client's password.
/// </summary>
[Required]
public required string Password { get; set; }
}

View File

@ -1,23 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Provides a JWT and RT token.
/// </summary>
public class TokenResponse
{
/// <summary>
/// A JWT token for accessing protected resources.
/// </summary>
[Required]
public required string AccessToken { get; set; }
/// <summary>
/// The date and time when the JWT token expires.
/// </summary>
/// <remarks>After this date, a new JWT token must be requested.</remarks>
[Required]
public required DateTime ExpiresIn { get; set; }
}

View File

@ -17,7 +17,7 @@ public class DistributedCacheService(IDistributedCache cache) : ICacheService
SlidingExpiration = slidingExpiration
};
var serializedValue = value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value);
var serializedValue = JsonSerializer.SerializeToUtf8Bytes(value);
await cache.SetAsync(key, serializedValue, options, cancellationToken);
}

View File

@ -1,7 +1,6 @@
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@ -17,14 +16,14 @@ public class MemoryCacheService(IMemoryCache cache) : ICacheService
SlidingExpiration = slidingExpiration
};
cache.Set(key, value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value), options);
cache.Set(key, value, options);
return Task.CompletedTask;
}
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
cache.TryGetValue(key, out byte[]? value);
return Task.FromResult(JsonSerializer.Deserialize<T>(value));
cache.TryGetValue(key, out T? value);
return Task.FromResult(value);
}
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)

View File

@ -2,7 +2,6 @@
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;
@ -20,29 +19,6 @@ public static class SwaggerConfiguration
options.OperationFilter<SwaggerDefaultValues>();
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"));
});

View File

@ -1,140 +0,0 @@
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<Admin> 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) { }
[HttpPost("Login")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> 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
});
}
[HttpGet("ReLogin")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> 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();
}
}
[HttpGet("Logout")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize]
public async Task<ActionResult> Logout()
{
SetRefreshToken("", DateTimeOffset.MinValue);
SetFirstToken("", DateTimeOffset.MinValue);
await auth.LogoutAsync(Fingerprint);
return Ok();
}
[HttpGet("GetRole")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize]
public ActionResult<AuthRoles> GetRole() => Ok(AuthRoles.Admin);
}

View File

@ -1,23 +0,0 @@
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);
}
}

View File

@ -95,7 +95,6 @@ public class Program
app.UseMiddleware<MaintenanceModeMiddleware>();
app.UseMiddleware<CustomExceptionHandlerMiddleware>();
app.UseMiddleware<JwtRevocationMiddleware>();
app.UseHttpsRedirection();

View File

@ -5,7 +5,6 @@ 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 RefreshExpiresIn { get; set; }
public DateTime ExpiresIn { get; set; }
}

View File

@ -34,7 +34,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
public async Task<AuthTokenResponse> GenerateAuthTokensAsync(TokenRequest request, string userId, CancellationToken cancellation = default)
{
var refreshToken = GenerateRefreshToken();
var (token, expireIn) = GenerateAccessToken(userId);
var accessToken = GenerateAccessToken(userId);
var authTokenStruct = new AuthToken
{
@ -43,17 +43,16 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
RefreshToken = refreshToken,
UserAgent = request.UserAgent,
UserId = userId,
AccessToken = token
AccessToken = accessToken.Token
};
await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation);
return new AuthTokenResponse
{
AccessToken = token,
AccessExpiresIn = expireIn,
RefreshToken = authTokenStruct.RefreshToken,
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime),
AccessToken = accessToken.Token,
ExpiresIn = accessToken.ExpireIn,
RefreshToken = authTokenStruct.RefreshToken
};
}
@ -78,18 +77,17 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
throw new SecurityException(request.Fingerprint);
}
var (token, expireIn) = GenerateAccessToken(authToken.UserId);
var accessToken = GenerateAccessToken(authToken.UserId);
await RevokeAccessToken(authToken.AccessToken);
authToken.AccessToken = token;
authToken.AccessToken = accessToken.Token;
await SetAuthTokenDataToCache(request.Fingerprint, authToken, cancellation);
return new AuthTokenResponse
{
AccessToken = token,
AccessExpiresIn = expireIn,
RefreshToken = GenerateRefreshToken(),
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime)
AccessToken = accessToken.Token,
ExpiresIn = accessToken.ExpireIn,
RefreshToken = GenerateRefreshToken()
};
}