Release CI / build-and-upload-mod (push) Canceled after 0s
Release CI / build-and-upload-mod (release) Canceled after 0s
Java CI / build (push) Successful in 4m54s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 44s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Successful in 1m45s
Signed-off-by: Dmitri Shimanski <yawaflua@perseverance.yawaflua.tech>
126 lines
5.2 KiB
C#
126 lines
5.2 KiB
C#
using System.Globalization;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace SpMega.Backend.Services;
|
|
|
|
public sealed record ValidatedAccessToken(
|
|
Guid UserId,
|
|
string UserName,
|
|
DateTimeOffset AuthenticatedAt,
|
|
DateTimeOffset ExpiresAt);
|
|
|
|
public class TokenService(IConfiguration conf)
|
|
{
|
|
public const string HttpContextItemKey = "ValidatedAccessToken";
|
|
public static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromHours(24);
|
|
public static readonly TimeSpan MaximumAuthenticationAge = TimeSpan.FromDays(2);
|
|
public static readonly TimeSpan RefreshThreshold = TimeSpan.FromHours(1);
|
|
|
|
private static readonly TimeSpan ClockSkew = TimeSpan.FromMinutes(1);
|
|
private readonly string _issuer = conf["JWT:Issuer"] ?? conf["JWT__Issuer"] ?? "spmega.il.yawaflua.tech";
|
|
private readonly string _secret = conf["JWT:Secret"] ?? conf["JWT__Secret"] ?? throw new InvalidOperationException("JWT__Secret is not configured.");
|
|
|
|
public string GenerateAccessToken(string userName, Guid uuid)
|
|
{
|
|
return GenerateAccessToken(userName, uuid, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
public string GenerateAccessToken(string userName, Guid uuid, DateTimeOffset authenticatedAt)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var maximumExpiration = authenticatedAt.Add(MaximumAuthenticationAge);
|
|
var expiration = now.Add(AccessTokenLifetime);
|
|
if (expiration > maximumExpiration)
|
|
{
|
|
expiration = maximumExpiration;
|
|
}
|
|
|
|
if (expiration <= now)
|
|
{
|
|
throw new SecurityTokenExpiredException("The original authentication is older than two days.");
|
|
}
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Name, userName),
|
|
new(JwtRegisteredClaimNames.UniqueName, uuid.ToString()),
|
|
new(JwtRegisteredClaimNames.AuthTime, authenticatedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
|
};
|
|
|
|
var tokenHandler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
|
var tokenDescriptor = new SecurityTokenDescriptor
|
|
{
|
|
Subject = new ClaimsIdentity(claims),
|
|
Expires = expiration.UtcDateTime,
|
|
Issuer = _issuer,
|
|
SigningCredentials = new SigningCredentials(
|
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)),
|
|
SecurityAlgorithms.HmacSha256),
|
|
IssuedAt = now.UtcDateTime
|
|
};
|
|
|
|
return tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
|
|
}
|
|
|
|
public ValidatedAccessToken ValidateAccessToken(string token)
|
|
{
|
|
var tokenHandler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
|
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = _issuer,
|
|
ValidateAudience = false,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)),
|
|
ValidateLifetime = true,
|
|
RequireExpirationTime = true,
|
|
RequireSignedTokens = true,
|
|
ClockSkew = ClockSkew,
|
|
ValidAlgorithms = [SecurityAlgorithms.HmacSha256]
|
|
}, out var validatedToken);
|
|
|
|
if (validatedToken is not JwtSecurityToken jwtToken ||
|
|
!string.Equals(jwtToken.Header.Alg, SecurityAlgorithms.HmacSha256, StringComparison.Ordinal))
|
|
{
|
|
throw new SecurityTokenValidationException("Unsupported JWT signing algorithm.");
|
|
}
|
|
|
|
var userName = principal.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
|
|
var uniqueName = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
|
var authTimeValue = principal.FindFirst(JwtRegisteredClaimNames.AuthTime)?.Value;
|
|
var expirationValue = principal.FindFirst(JwtRegisteredClaimNames.Exp)?.Value;
|
|
|
|
if (string.IsNullOrWhiteSpace(userName) ||
|
|
!Guid.TryParse(uniqueName, out var userId) ||
|
|
!long.TryParse(authTimeValue, NumberStyles.None, CultureInfo.InvariantCulture, out var authTimeSeconds) ||
|
|
!long.TryParse(expirationValue, NumberStyles.None, CultureInfo.InvariantCulture, out var expirationSeconds))
|
|
{
|
|
throw new SecurityTokenValidationException("Required JWT claims are missing or invalid.");
|
|
}
|
|
|
|
DateTimeOffset authenticatedAt;
|
|
DateTimeOffset expiresAt;
|
|
try
|
|
{
|
|
authenticatedAt = DateTimeOffset.FromUnixTimeSeconds(authTimeSeconds);
|
|
expiresAt = DateTimeOffset.FromUnixTimeSeconds(expirationSeconds);
|
|
}
|
|
catch (ArgumentOutOfRangeException exception)
|
|
{
|
|
throw new SecurityTokenValidationException("JWT timestamps are invalid.", exception);
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
if (authenticatedAt > now.Add(ClockSkew) || now - authenticatedAt > MaximumAuthenticationAge)
|
|
{
|
|
throw new SecurityTokenExpiredException("Reauthentication is required.");
|
|
}
|
|
|
|
return new ValidatedAccessToken(userId, userName, authenticatedAt, expiresAt);
|
|
}
|
|
}
|