feat(Lactose): Adds RefreshToken Endpoint

This commit is contained in:
MrFastwind
2025-07-02 17:51:22 +02:00
parent 87432ad663
commit dbdce9d731
8 changed files with 263 additions and 37 deletions

View File

@@ -0,0 +1,65 @@
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
namespace Lactose.Authorization;
/*
* Technical note:
*
* The RefreshTokenTransformation is used to add a claim to the user's claims principal that indicates if
* the user's login is still valid. while UnexpiredLoginRequirement is used to check the claim.
*
* More Claim Transformation and Requirements can be added with IClaimsTransformation IAuthorizationRequirement and
* added to Program.cs
*/
public class UnexpiredLoginRequirement : IAuthorizationRequirement {
public class RefreshTokenValidityHandler : AuthorizationHandler<UnexpiredLoginRequirement> {
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
UnexpiredLoginRequirement requirement
) {
Claim? firstOrDefault
= context.User.Claims.FirstOrDefault(claim => claim.Type == RefreshTokenTransformation.ClaimType);
if (firstOrDefault is not { Value: "true" }) {
context.Fail();
return Task.CompletedTask;
}
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}
public class RefreshTokenTransformation(
IUserRepository userRepository,
LactoseAuthService authService
) : IClaimsTransformation
{
public const string ClaimType = "LoginValid";
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
foreach (ClaimsIdentity identity in principal.Identities) {
if (identity.HasClaim(claim => claim.Type == ClaimType)) {
identity.RemoveClaim(identity.FindFirst(ClaimType)!);
}
}
}
var userData = authService.GetUserData(principal);
if (userData == null) return Task.FromResult(principal);
var user = userRepository.Find(userData.Id);
if (user == null) return Task.FromResult(principal);
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.Now) return Task.FromResult(principal);
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
principal.AddIdentity(claimsIdentity);
return Task.FromResult(principal);
}
}

View File

@@ -4,6 +4,7 @@ using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -17,7 +18,8 @@ public class AuthController(
IUserRepository userRepository,
IPasswordHasher<User> passwordHasher
) : ControllerBase {
//TODO: Reply with Authentication Result
[HttpPost("login")]
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
User? user;
@@ -29,15 +31,13 @@ public class AuthController(
//search for user in database (by username)
user = userRepository.FindByUsername(userDto.Identifier);
}
if (user == null) {
return NotFound(new AuthResultDto() {
Success = false,
Success = false,
ErrorMessage = "User or Password was wrong"
}
);
}
if (user.BannedAt != null) {
return StatusCode(StatusCodes.Status100Continue,new AuthResultDto() {
Success = false,
@@ -52,9 +52,7 @@ public class AuthController(
ErrorMessage = "User is disabled"
}
);
}
PasswordVerificationResult result = passwordHasher.VerifyHashedPassword(user, user.Password, userDto.Password);
switch (result) {
@@ -67,12 +65,15 @@ public class AuthController(
}
user.LastLogin = DateTime.Now;
var token = authService.GenerateAccessToken(user);
var refreshToken = authService.GenerateRefreshToken(user);
userRepository.Save();
var token = authService.GenerateToken(user);
return new AuthResultDto() {
Token = token,
RefreshToken = token,
Success = true
UserId = user.Id,
Token = token,
RefreshToken = refreshToken,
Success = true
};
}
@@ -106,6 +107,7 @@ public class AuthController(
return Ok(user.Id);
}
[Authorize]
[HttpPost()]
public ActionResult<LactoseAuthenticatedUser> Check() {
// this.User;
@@ -114,13 +116,81 @@ public class AuthController(
return Ok(identity);
}
[Authorize]
[HttpPost("logout")]
public ActionResult Logout() {
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
if (identity == null) { return Unauthorized(); }
authService.RevokeAccess(identity.Id);
//Reset token from the database
User? user = userRepository.Find(identity.Id);
if (user == null) { return Unauthorized(); }
user.RefreshToken = string.Empty;
user.RefreshTokenExpires = null;
userRepository.Save();
return Ok();
}
/*
* Refresh access Token using the Refresh Token
*/
[HttpPost("refresh")]
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
User? user = userRepository.Find(token.UserId);
if (user == null) {
return NotFound(
new AuthResultDto() {
Success = false,
ErrorMessage = "No user found"
}
);
}
if (user.BannedAt != null) {
return StatusCode(
StatusCodes.Status100Continue,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is banned"
}
);
}
if (user.DeletedAt != null) {
return StatusCode(
StatusCodes.Status100Continue,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is disabled"
}
);
}
if (!authService.ValidateRefreshToken(user, token.RefreshToken)) {
return Unauthorized(
new AuthResultDto() {
Success = false,
ErrorMessage = "Invalid refresh token"
}
);
}
var authDto = new AuthResultDto() {
Success = true,
Token = authService.GenerateAccessToken(user)
};
// TODO: Decide if we want to change or extend refresh token
if (user.RefreshTokenExpires > DateTime.Now - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) {
user.RefreshTokenExpires = DateTime.Now.AddMinutes(LactoseAuthService.LongExpirationTime);
userRepository.Save();
}
return authDto;
}
}

View File

@@ -1,9 +1,12 @@
using Lactose.Authorization;
using Lactose.Configuration;
using Lactose.Context;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
@@ -70,6 +73,7 @@ builder.Services.AddTransient<ITagRepository, TagRepository>();
builder.Services.AddTransient<IAssetRepository, AssetRepository>();
builder.Services.AddTransient<IAlbumRepository, AlbumRepository>();
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<LactoseAuthService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
@@ -111,6 +115,22 @@ builder.Services.AddSwaggerGen(
);
SecurityKey securityKey = new SignKeyProvider(builder.Configuration).Get().GetSecurityKey();
builder.Services.AddTransient<IClaimsTransformation, RefreshTokenTransformation>();
var policy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.RequireClaim(RefreshTokenTransformation.ClaimType)
.Build();
builder.Services.AddAuthorization(options =>
{
AuthorizationPolicy defaultPolicy =
new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.RequireClaim(RefreshTokenTransformation.ClaimType,"true")
.Build();
});
builder.Services
.AddAuthentication(x =>
@@ -130,6 +150,7 @@ builder.Services
};
});
WebApplication app = builder.Build();
using (var scope = app.Services.CreateScope()) {

View File

@@ -2,31 +2,24 @@ using Butter.Types;
using Lactose.Configuration;
using Lactose.Models;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace Lactose.Services;
public class LactoseAuthService(ILogger<LactoseAuthService> logger,IOptions<SignKeyConfiguration> keyOption) {
public string GenerateToken(User user) {
var handler = new JwtSecurityTokenHandler();
public class LactoseAuthService(
ILogger<LactoseAuthService> logger,
ITokenService tokenService)
{
public const int BaseExpirationTime = 10;
public const int LongExpirationTime = 60;
SecurityKey key = keyOption.Value.GetSecurityKey();
public string GenerateAccessToken(User user) => tokenService.GenerateToken(GenerateClaims(user), BaseExpirationTime);
var credentials = new SigningCredentials(
keyOption.Value.GetSecurityKey(),
SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new SecurityTokenDescriptor {
Subject = GenerateClaims(user),
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = credentials,
};
var token = handler.CreateToken(tokenDescriptor);
return handler.WriteToken(token);
public string GenerateRefreshToken(User user) {
var token =tokenService.GenerateToken(LongExpirationTime);
user.RefreshToken = token;
user.RefreshTokenExpires = DateTime.Now.AddMinutes(LongExpirationTime);
return token;
}
static ClaimsIdentity GenerateClaims(User user) {
@@ -55,9 +48,11 @@ public class LactoseAuthService(ILogger<LactoseAuthService> logger,IOptions<Sign
(EAccessLevel)Enum.Parse(typeof(EAccessLevel),eAccessLevel)
);
}
public void RevokeAccess(Guid id) {
//TODO: Revoke access to user
public bool ValidateRefreshToken(User user, string tokenRefreshToken) {
if (user.RefreshToken != tokenRefreshToken) return false;
if (user.RefreshTokenExpires < DateTime.Now) return false;
return true;
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
namespace Lactose.Utils;
public class TokenBuilder {
SigningCredentials? credentials;
int expireTime;
ClaimsIdentity? claims;
public TokenBuilder(SigningCredentials? signingCredentials = null, int expireTime = 10) {
this.credentials = signingCredentials;
this.expireTime = expireTime;
}
public TokenBuilder ExpireTime(int minutes) {
this.expireTime = expireTime;
return this;
}
public TokenBuilder SigningCredentials(SigningCredentials signingCredentials) {
this.credentials = credentials;
return this;
}
public TokenBuilder SetClaims(ClaimsIdentity identity) {
this.claims = identity;
return this;
}
private SecurityTokenDescriptor PrepareSecurityTokenDescriptor() {
var descriptor = new SecurityTokenDescriptor {
SigningCredentials = credentials,
Expires = DateTime.UtcNow
};
if (expireTime>0) descriptor.Expires = DateTime.UtcNow.AddMinutes(expireTime);
if (claims != null) descriptor.Subject = claims;
return descriptor;
}
public SecurityToken GenerateToken(SecurityTokenHandler handler) => GenerateToken(handler.CreateToken);
public T GenerateToken<T>(Func<SecurityTokenDescriptor, T> handler) {
T token = handler(PrepareSecurityTokenDescriptor());
this.claims = null;
return token;
}
}
public class TokenGenerator(SigningCredentials credentials, Func<SecurityTokenDescriptor, string> handler) {
readonly TokenBuilder builder = new TokenBuilder(credentials);
public string Generate(ClaimsIdentity claims, int expireTime) {
builder.ExpireTime(expireTime);
builder.SetClaims(claims);
return builder.GenerateToken(handler);
}
public string Generate(int expireTime) {
builder.ExpireTime(expireTime);
return builder.GenerateToken(handler);
}
}

View File

@@ -44,9 +44,10 @@ Content-Type: application/json
}
> {%
client.global.set("auth_token", response.body);
client.global.set("auth_token", jsonPath(response.body, "$.token"));
client.test("Login rng user", function () {
client.assert(response.status === 200)
//client.assert(jsonPath(response.body, "$.success") != true,jsonPath(response.body, "$.errorMessage")??"null")
});
%}
@@ -59,7 +60,7 @@ Content-Type: application/json
> {%
client.test("API Authenticated Empty", function () {
client.assert(response.status === 204)
client.assert(response.status === 401)
});
%}
@@ -95,9 +96,10 @@ Content-Type: application/json
}
> {%
client.global.set("admin_token", response.body);
client.global.set("admin_token", jsonPath(response.body, "$.token"));
client.test("Login as admin", function () {
client.assert(response.status === 200)
//client.assert(jsonPath(response.body, "$.success") != true,jsonPath(response.body, "$.errorMessage")??"null")
});
%}