From e194bbf9c4ff8e741c33bb9f83669889b369e8c5 Mon Sep 17 00:00:00 2001 From: Holden <122419606+midozen@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:46:12 -0500 Subject: [PATCH] Invalidate JWTs on profile changes --- .../Profiles/Responses/LoginResponse.cs | 25 +- .../Profiles/V1/ProfilesController.cs | 14 +- src/API/Program.cs | 34 ++- .../Security/ClaimsPrincipalExtensions.cs | 11 + .../Common/Tokens/TokenVerifyResult.cs | 9 +- .../Neutrino/NeutrinoAuthorizationService.cs | 5 +- .../Profiles/IProfileService.cs | 10 +- .../Profiles/ProfileService.cs | 25 +- .../Profiles/IProfileRepository.cs | 2 + src/RecNet.Domain/Profiles/Profile.cs | 5 + .../20260628172910_TokenVersion.Designer.cs | 233 ++++++++++++++++++ .../Migrations/20260628172910_TokenVersion.cs | 30 +++ .../DatabaseContextModelSnapshot.cs | 3 + .../Repositories/ProfileRepository.cs | 9 + .../Services/Tokens/TokenService.cs | 27 +- 15 files changed, 395 insertions(+), 47 deletions(-) create mode 100644 src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.Designer.cs create mode 100644 src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.cs diff --git a/src/API/Contracts/Profiles/Responses/LoginResponse.cs b/src/API/Contracts/Profiles/Responses/LoginResponse.cs index a07529b..7ea430a 100644 --- a/src/API/Contracts/Profiles/Responses/LoginResponse.cs +++ b/src/API/Contracts/Profiles/Responses/LoginResponse.cs @@ -6,11 +6,28 @@ namespace API.Contracts.Profiles.Responses; public class LoginResponse { [JsonPropertyName("Profile")] - public required ProfileDTO Profile { get; set; } + public ProfileDTO? Profile { get; set; } [JsonPropertyName("AccessToken")] - public required string AccessToken { get; set; } + public string AccessToken { get; set; } = string.Empty; [JsonPropertyName("ExpiresIn")] - public required int ExpiresIn { get; set; } -} + public int ExpiresIn { get; set; } + + [JsonPropertyName("Error")] + public string Error { get; set; } = string.Empty; + + public static LoginResponse Success(ProfileDTO profile, string accessToken, int expiresIn) + => new() + { + Profile = profile, + AccessToken = accessToken, + ExpiresIn = expiresIn, + }; + + public static LoginResponse Fail(string? error) + => new() + { + Error = error ?? "An unexpected error occured" + }; +} \ No newline at end of file diff --git a/src/API/Controllers/Profiles/V1/ProfilesController.cs b/src/API/Controllers/Profiles/V1/ProfilesController.cs index 96a3b29..766fc77 100644 --- a/src/API/Controllers/Profiles/V1/ProfilesController.cs +++ b/src/API/Controllers/Profiles/V1/ProfilesController.cs @@ -1,4 +1,5 @@ using API.Contracts.Profiles.Responses; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using RecNet.Application.Profiles; using RecNet.Application.Profiles.Login; @@ -6,8 +7,9 @@ using LoginRequest = API.Contracts.Profiles.Requests.LoginRequest; namespace API.Controllers.Profiles.V1; -[Route("api/[controller]/v1")] +[Authorize] [ApiController] +[Route("api/[controller]/v1")] public class ProfilesController(IProfileService profileService) : ControllerBase { [HttpGet("{id:guid}")] @@ -28,6 +30,7 @@ public class ProfilesController(IProfileService profileService) : ControllerBase CancellationToken ct) => await profileService.GetProfilesAsync(ids, ct); + [AllowAnonymous] [HttpPost("login")] public async Task> Login( [FromBody] LoginRequest request, @@ -42,13 +45,8 @@ public class ProfilesController(IProfileService profileService) : ControllerBase request.PlatformAuthentication ), ct); if (!result.Succeeded) - return BadRequest(result.Message); + return LoginResponse.Fail(result.Message); - return new LoginResponse - { - Profile = result.Profile!, - AccessToken = result.AccessToken!, - ExpiresIn = result.ExpiresIn - }; + return LoginResponse.Success(result.Profile!, result.AccessToken!, result.ExpiresIn); } } diff --git a/src/API/Program.cs b/src/API/Program.cs index fb5be79..bd25808 100644 --- a/src/API/Program.cs +++ b/src/API/Program.cs @@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.IdentityModel.Tokens; using RecNet.Application; +using RecNet.Application.Common.Security; +using RecNet.Domain.Profiles; using RecNet.Infrastructure; using RecNet.Infrastructure.Services.Tokens; using RecNet.ServiceDefaults; @@ -15,17 +17,17 @@ public class Program public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - + builder.Services .AddOptions() .Bind(builder.Configuration.GetSection("Neutrino")) .ValidateOnStart(); var recNetOptions = builder.Configuration.GetSection("RecNet").Get() - ?? new RecNetOptions(); - + ?? new RecNetOptions(); + var jwtOptions = builder.Configuration.GetSection("Jwt").Get() - ?? new JwtOptions(); + ?? new JwtOptions(); builder.AddServiceDefaults(); @@ -57,8 +59,28 @@ public class Program ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1) }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var profileRepository = context.HttpContext.RequestServices + .GetRequiredService(); + + var userId = context.Principal?.GetProfileId(); + var tokenVersion = context.Principal?.GetTokenVersion(); + + if (userId.HasValue) + { + var profileTokenVersion = await profileRepository.GetProfileTokenVersion(userId.Value); + + if (profileTokenVersion != tokenVersion) + context.Fail("Unauthorized"); + } + } + }; }); - + builder.Services.AddAuthorization(); builder.Services.AddControllers(); @@ -76,4 +98,4 @@ public class Program app.Run(); } -} +} \ No newline at end of file diff --git a/src/RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs b/src/RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs index 1995521..c2937e3 100644 --- a/src/RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs +++ b/src/RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs @@ -16,4 +16,15 @@ public static class ClaimsPrincipalExtensions return profileId; } + + public static Guid GetTokenVersion(this ClaimsPrincipal user) + { + var value = user.FindFirst("token_version")?.Value; + + if (!Guid.TryParse(value, out var tokenVersion)) + throw new UnauthorizedAccessException( + "Token version claim is missing or invalid."); + + return tokenVersion; + } } \ No newline at end of file diff --git a/src/RecNet.Application/Common/Tokens/TokenVerifyResult.cs b/src/RecNet.Application/Common/Tokens/TokenVerifyResult.cs index ed3ff82..fe01245 100644 --- a/src/RecNet.Application/Common/Tokens/TokenVerifyResult.cs +++ b/src/RecNet.Application/Common/Tokens/TokenVerifyResult.cs @@ -2,11 +2,12 @@ namespace RecNet.Application.Common.Tokens; public sealed record TokenVerifyResult( bool Succeeded, - Guid? ProfileId) + Guid? ProfileId, + Guid? TokenVersion) { - public static TokenVerifyResult Success(Guid profileId) - => new(true, profileId); + public static TokenVerifyResult Success(Guid profileId, Guid tokenVersion) + => new(true, profileId, tokenVersion); public static TokenVerifyResult Failure() - => new(false, null); + => new(false, null, null); } diff --git a/src/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs b/src/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs index c5f64a5..a2d4de3 100644 --- a/src/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs +++ b/src/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs @@ -15,7 +15,7 @@ public class NeutrinoAuthorizationService( return NeutrinoAuthorizationResult.InvalidParameters(); var tokenVerifyResult = tokenService.VerifyToken(command.AccessToken); - if (!tokenVerifyResult.Succeeded || tokenVerifyResult.ProfileId is null) + if (!tokenVerifyResult.Succeeded || tokenVerifyResult.ProfileId is null || tokenVerifyResult.TokenVersion is null) return NeutrinoAuthorizationResult.AuthenticationFailed(); if (tokenVerifyResult.ProfileId.Value != command.ProfileId) @@ -25,6 +25,9 @@ public class NeutrinoAuthorizationService( if (profile is null || profile.IsBanned) return NeutrinoAuthorizationResult.AuthenticationFailed(); + if (tokenVerifyResult.TokenVersion.Value != profile.TokenVersion) + return NeutrinoAuthorizationResult.AuthenticationFailed(); + return NeutrinoAuthorizationResult.Success(profile.ProfileId, profile.Name); } } diff --git a/src/RecNet.Application/Profiles/IProfileService.cs b/src/RecNet.Application/Profiles/IProfileService.cs index 0f1dedb..d9b0e8a 100644 --- a/src/RecNet.Application/Profiles/IProfileService.cs +++ b/src/RecNet.Application/Profiles/IProfileService.cs @@ -7,11 +7,19 @@ namespace RecNet.Application.Profiles; public interface IProfileService { + // Profiles Task GetProfileAsync(Guid profileId, CancellationToken ct = default); + Task> GetProfilesAsync(List profileIds, CancellationToken ct = default); + + // Avatar Task GetAvatarAsync(Guid profileId, CancellationToken ct = default); Task UpdateAvatarAsync(Guid profileId, UpdateAvatarCommand command, CancellationToken ct = default); + + // PlayerSettings Task> GetPlayerSettingsAsync(Guid profileId, CancellationToken ct = default); Task UpdatePlayerSettingAsync(Guid profileId, UpdatePlayerSettingCommand command, CancellationToken ct = default); - Task> GetProfilesAsync(List profileIds, CancellationToken ct = default); + + Task IncrementTokenVersion(Guid profileId, CancellationToken ct = default); + Task LoginAsync(LoginProfileCommand command, CancellationToken ct = default); } diff --git a/src/RecNet.Application/Profiles/ProfileService.cs b/src/RecNet.Application/Profiles/ProfileService.cs index 87cd91f..2d1aadf 100644 --- a/src/RecNet.Application/Profiles/ProfileService.cs +++ b/src/RecNet.Application/Profiles/ProfileService.cs @@ -30,6 +30,15 @@ public class ProfileService( : mapper.Map(profile); } + public async Task> GetProfilesAsync( + List profileIds, + CancellationToken ct = default) + { + var profiles = await profileRepository.GetByIdsAsync(profileIds, ct); + + return mapper.Map>(profiles); + } + public async Task GetAvatarAsync(Guid profileId, CancellationToken ct = default) { var avatar = await profileRepository.GetAvatarByProfileIdAsync(profileId, ct); @@ -86,13 +95,15 @@ public class ProfileService( return true; } - public async Task> GetProfilesAsync( - List profileIds, - CancellationToken ct = default) + public async Task IncrementTokenVersion(Guid profileId, CancellationToken ct = default) { - var profiles = await profileRepository.GetByIdsAsync(profileIds, ct); - - return mapper.Map>(profiles); + var profile = await profileRepository.GetByIdWithSettingsAsync(profileId, ct); + if (profile is null) + return; + + profile.IncrementTokenVersion(); + + await profileRepository.SaveChangesAsync(ct); } public async Task LoginAsync( @@ -146,7 +157,7 @@ public class ProfileService( return LoginProfileResult.Fail("Platform Auth Failed: Invalid authentication"); if (profile.IsBanned) - return LoginProfileResult.Fail("Profile is banned"); + return LoginProfileResult.Fail("TenWholeYears requires you to take a shower in order to continue playing."); profile.RecordSuccessfulLogin(command.DeviceId, auth.Name); diff --git a/src/RecNet.Domain/Profiles/IProfileRepository.cs b/src/RecNet.Domain/Profiles/IProfileRepository.cs index 32a56ce..b2f16bb 100644 --- a/src/RecNet.Domain/Profiles/IProfileRepository.cs +++ b/src/RecNet.Domain/Profiles/IProfileRepository.cs @@ -11,6 +11,8 @@ public interface IProfileRepository Task> GetByIdsAsync(List profileIds, CancellationToken ct = default); Task GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default); + + Task GetProfileTokenVersion(Guid profileId, CancellationToken ct = default); Task AddAsync(Profile profile, CancellationToken ct = default); diff --git a/src/RecNet.Domain/Profiles/Profile.cs b/src/RecNet.Domain/Profiles/Profile.cs index 10aa9be..f043591 100644 --- a/src/RecNet.Domain/Profiles/Profile.cs +++ b/src/RecNet.Domain/Profiles/Profile.cs @@ -35,6 +35,8 @@ public class Profile public bool IsModerator { get; private set; } public bool IsDeveloper { get; private set; } + public Guid TokenVersion { get; private set; } = Guid.NewGuid(); + public DateTimeOffset CreatedAt { get; private set; } public Avatar Avatar { get; private set; } = Avatar.Empty; @@ -111,6 +113,9 @@ public class Profile existing.UpdateValue(value); } + + public void IncrementTokenVersion() + => TokenVersion = Guid.NewGuid(); private static string RequireValue(string value, string parameterName) => !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required."); diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.Designer.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.Designer.cs new file mode 100644 index 0000000..8729911 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.Designer.cs @@ -0,0 +1,233 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RecNet.Infrastructure.Persistence; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260628172910_TokenVersion")] + partial class TokenVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("RecNet.Domain.Configuration.ServerConfig", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Key"); + + b.ToTable("ServerConfigs"); + }); + + modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsValid") + .HasColumnType("boolean"); + + b.HasKey("Version"); + + b.ToTable("GameVersions"); + }); + + modelBuilder.Entity("RecNet.Domain.PatchNotes.PatchNote", b => + { + b.Property("Version") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection>("Changes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Date") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("Version"); + + b.ToTable("PatchNotes"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "Key"); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.Property("ProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsDeveloper") + .HasColumnType("boolean"); + + b.Property("IsModerator") + .HasColumnType("boolean"); + + b.Property("MetaAuthenticationSecret") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlatformId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TokenVersion") + .HasColumnType("uuid"); + + b.HasKey("ProfileId"); + + b.HasIndex("Platform", "PlatformId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Relationship", b => + { + b.Property("OwnerProfileId") + .HasColumnType("uuid"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("Ignored") + .HasColumnType("boolean"); + + b.Property("Muted") + .HasColumnType("boolean"); + + b.HasKey("OwnerProfileId", "ProfileId"); + + b.HasIndex("ProfileId"); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.HasOne("RecNet.Domain.Profiles.Profile", null) + .WithMany("Settings") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.OwnsOne("RecNet.Domain.Profiles.Avatar", "Avatar", b1 => + { + b1.Property("ProfileId") + .HasColumnType("uuid"); + + b1.Property("HairColor") + .IsRequired() + .HasColumnType("text"); + + b1.Property("OutfitSelections") + .IsRequired() + .HasColumnType("text"); + + b1.Property("SkinColor") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ProfileId"); + + b1.ToTable("Profiles"); + + b1.WithOwner() + .HasForeignKey("ProfileId"); + }); + + b.Navigation("Avatar") + .IsRequired(); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Relationship", b => + { + b.HasOne("RecNet.Domain.Profiles.Profile", null) + .WithMany("Relationships") + .HasForeignKey("OwnerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RecNet.Domain.Profiles.Profile", null) + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.cs new file mode 100644 index 0000000..d9ba2d7 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260628172910_TokenVersion.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + /// + public partial class TokenVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TokenVersion", + table: "Profiles", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TokenVersion", + table: "Profiles"); + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs index 7e5c094..57895ac 100644 --- a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs +++ b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs @@ -131,6 +131,9 @@ namespace RecNet.Infrastructure.Persistence.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("TokenVersion") + .HasColumnType("uuid"); + b.HasKey("ProfileId"); b.HasIndex("Platform", "PlatformId") diff --git a/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs b/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs index eb8245c..40e6960 100644 --- a/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs +++ b/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs @@ -53,6 +53,15 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository x.PlatformId == platformId, ct); + public Task GetProfileTokenVersion( + Guid profileId, + CancellationToken ct = default) + => dbContext.Profiles + .AsNoTracking() + .Where(x => x.ProfileId == profileId) + .Select(x => x.TokenVersion) + .FirstOrDefaultAsync(ct); + public async Task AddAsync( Profile profile, CancellationToken ct = default) diff --git a/src/RecNet.Infrastructure/Services/Tokens/TokenService.cs b/src/RecNet.Infrastructure/Services/Tokens/TokenService.cs index 4aaa4df..6c2532f 100644 --- a/src/RecNet.Infrastructure/Services/Tokens/TokenService.cs +++ b/src/RecNet.Infrastructure/Services/Tokens/TokenService.cs @@ -4,6 +4,7 @@ using System.Text; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using RecNet.Application.Common.Interfaces; +using RecNet.Application.Common.Security; using RecNet.Application.Common.Tokens; using RecNet.Domain.Profiles; @@ -25,10 +26,11 @@ public class TokenService(IOptions options) : ITokenService var claims = new List { new(JwtRegisteredClaimNames.Sub, profile.ProfileId.ToString()), + new("token_version", profile.TokenVersion.ToString()), new("rn.plat", ((int)profile.Platform).ToString()), new("rn.platid", profile.PlatformId) }; - + if (profile.IsModerator) claims.Add(new Claim("role", "moderator")); @@ -52,11 +54,11 @@ public class TokenService(IOptions options) : ITokenService if (string.IsNullOrWhiteSpace(token)) return TokenVerifyResult.Failure(); - + var tokenHandler = new JwtSecurityTokenHandler(); if (!tokenHandler.CanReadToken(token)) return TokenVerifyResult.Failure(); - + var validationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, @@ -72,20 +74,13 @@ public class TokenService(IOptions options) : ITokenService try { var principal = tokenHandler.ValidateToken(token, validationParameters, out _); - - var profileIdClaim = - principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? - principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - return Guid.TryParse(profileIdClaim, out var profileId) - ? TokenVerifyResult.Success(profileId) - : TokenVerifyResult.Failure(); + var profileId = principal.GetProfileId(); + var tokenVersion = principal.GetTokenVersion(); + + return TokenVerifyResult.Success(profileId, tokenVersion); } - catch (SecurityTokenException) - { - return TokenVerifyResult.Failure(); - } - catch (ArgumentException) + catch { return TokenVerifyResult.Failure(); } @@ -93,4 +88,4 @@ public class TokenService(IOptions options) : ITokenService private static SymmetricSecurityKey CreateSecurityKey(JwtOptions jwtOptions) => new(Encoding.UTF8.GetBytes(jwtOptions.Secret)); -} +} \ No newline at end of file