v0.6 prerelease commit with some minor fixes
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>
This commit is contained in:
Dmitrii `yawaflua` Shimanskii
2026-07-21 03:35:52 +03:00
committed by Dmitri Shimanski
parent b1bc688d83
commit d41cd8f079
37 changed files with 1208 additions and 703 deletions
@@ -3,7 +3,9 @@ using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Services;
namespace SpMega.Backend.Authetication;
@@ -11,28 +13,39 @@ public class JwtAuthHandler(
IOptionsMonitor<JwtAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
AppDbContext dbContext) : AuthenticationHandler<JwtAuthenticationSchemeOptions>(options, logger, encoder)
AppDbContext dbContext,
TokenService tokenService) : AuthenticationHandler<JwtAuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization") || !Request.Headers.Authorization.ToString().StartsWith("Bearer "))
return AuthenticateResult.NoResult();
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
if (string.IsNullOrEmpty(token))
var authorization = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
return AuthenticateResult.Fail("Invalid authorization header format");
return AuthenticateResult.NoResult();
}
const string bearerPrefix = "Bearer ";
if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.Fail("Invalid authorization header format.");
}
var token = authorization[bearerPrefix.Length..].Trim();
if (string.IsNullOrWhiteSpace(token))
{
return AuthenticateResult.Fail("Invalid authorization header format.");
}
try
{
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Token == token);
var validatedToken = tokenService.ValidateAccessToken(token);
var user = await dbContext.Users.FirstOrDefaultAsync(
candidate => candidate.Id == validatedToken.UserId && !candidate.IsDeleted,
Context.RequestAborted);
if (user == null)
if (user == null || !string.Equals(user.Username, validatedToken.UserName, StringComparison.Ordinal))
{
return AuthenticateResult.Fail("Invalid or expired token");
return AuthenticateResult.Fail("Invalid token subject.");
}
var claims = new List<Claim>
@@ -40,25 +53,39 @@ public class JwtAuthHandler(
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Username),
new("UserId", user.Id.ToString()),
new("Token", token),
new(ClaimTypes.Role, "default")
};
var claimsIdentity = new ClaimsIdentity(claims, Scheme.Name);
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
Context.Items["User"] = user;
Context.Items["@me"] = user;
Context.Items["user"] = user;
Context.Items[TokenService.HttpContextItemKey] = validatedToken;
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket);
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
catch (Exception ex)
catch (SecurityTokenException exception)
{
Logger.LogError(ex, "Error during JWT authentication");
return AuthenticateResult.Fail("Authentication error occurred");
Logger.LogWarning("JWT authentication rejected: {Reason}", exception.Message);
return AuthenticateResult.Fail("Invalid or expired token.");
}
catch (Exception exception)
{
Logger.LogError(exception, "Error during JWT authentication");
return AuthenticateResult.Fail("Authentication error occurred.");
}
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.ContentType = "application/json";
Response.Headers.WWWAuthenticate = $"{Scheme.Name} error=\"invalid_token\"";
Response.Headers["X-SPMega-Reauthenticate"] = "true";
return Response.WriteAsJsonAsync(new
{
code = "reauthentication_required",
message = "The access token is missing, invalid, expired, or older than two days."
});
}
}
@@ -128,6 +128,35 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
}
[HttpPost("refresh")]
[Authorize]
public async Task<IActionResult> RefreshAccessTokenAsync()
{
if (HttpContext.Items[TokenService.HttpContextItemKey] is not ValidatedAccessToken accessToken ||
HttpContext.Items["@me"] is not User user)
{
return Unauthorized(new { code = "reauthentication_required" });
}
var now = DateTimeOffset.UtcNow;
if (accessToken.ExpiresAt - now > TokenService.RefreshThreshold)
{
return Ok(new { refreshed = false, expiresAt = accessToken.ExpiresAt });
}
if (accessToken.AuthenticatedAt.Add(TokenService.MaximumAuthenticationAge) - now <= TokenService.RefreshThreshold)
{
return Unauthorized(new { code = "reauthentication_required" });
}
var token = tokenService.GenerateAccessToken(user.Username, user.Id, accessToken.AuthenticatedAt);
user.Token = token;
user.UpdatedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync();
return Ok(new { refreshed = true, token });
}
[HttpPut("cards")]
[Authorize]
public async Task<IActionResult> AddCardAsync([FromBody] AddCardBody body)
@@ -145,11 +174,6 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
throw new Exception("Its not ur card");
}
var balanceResp = await SendRequest("/public/card", new("Bearer", Base64BearerToken));
var balance = (int?)JsonNode.Parse(balanceResp)?["balance"];
var card = me.cards.First(k => k.id == body.id);
var existingCard = user.Cards.FirstOrDefault(k => k.Id.ToString() == card.id);
if (existingCard == null)
@@ -157,7 +181,6 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
{
Id = Guid.Parse(card.id),
Name = card.name,
Balance = balance ?? -1,
SpworldsID = card.number,
Token = Base64BearerToken,
CreatedAt = DateTime.UtcNow,
@@ -168,7 +191,6 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
existingCard.Name = card.name;
existingCard.SpworldsID = card.number;
existingCard.Token = Base64BearerToken;
existingCard.Balance = balance ?? -1;
existingCard.UpdatedAt = DateTime.UtcNow;
}
@@ -236,4 +258,3 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
}
public record AddCardBody(string id, string token);
@@ -65,10 +65,6 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
return BadRequest(new { error = "Card not found" });
}
if (cardToUse.Balance != -1 && cardToUse.Balance < body.amount)
{
return BadRequest(new { error = "Insufficient balance" });
}
var shortId = Program.GenerateRandomString(2);
@@ -110,8 +106,6 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
{
throw new Exception("Failed to create transaction: " + resp);
}
cardToUse.Balance = balance.Value;
} catch (Exception exception)
{
@@ -12,8 +12,6 @@ public class Card
public string? ShortId { get; set; } = "";
public bool? WebhookConnected { get; set; } = false;
public int? Balance { get; set; } = -1;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
+98 -13
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
@@ -5,36 +6,120 @@ 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)
{
private const int AccessTokenExpirationMinutes = 24;
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, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new(JwtRegisteredClaimNames.UniqueName, uuid.ToString()),
new(JwtRegisteredClaimNames.AuthTime, authenticatedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_secret);
var tokenHandler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(AccessTokenExpirationMinutes),
Expires = expiration.UtcDateTime,
Issuer = _issuer,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
IssuedAt = DateTime.UtcNow
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)),
SecurityAlgorithms.HmacSha256),
IssuedAt = now.UtcDateTime
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
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);
}
}