feat(Lactose): Adds RefreshToken Endpoint
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
namespace Butter.Dtos;
|
||||
|
||||
public class AuthResultDto {
|
||||
public Guid? UserId { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
6
Butter/Dtos/RefreshDto.cs
Normal file
6
Butter/Dtos/RefreshDto.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Butter.Dtos;
|
||||
|
||||
public class RefreshDto {
|
||||
public required Guid UserId { get; set; }
|
||||
public required string RefreshToken { get; set; }
|
||||
}
|
||||
65
Lactose/Authorization/RefreshTokenAuthorization.cs
Normal file
65
Lactose/Authorization/RefreshTokenAuthorization.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
Lactose/Utils/TokenGeneratorProvvider.cs
Normal file
66
Lactose/Utils/TokenGeneratorProvvider.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
});
|
||||
%}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user