From a9558a16fc0f8213911c2247e19328e3cc8b56d7 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 4 Apr 2026 23:21:16 +0200 Subject: [PATCH] Extract game auth service --- README.md | 1 + RpgRoller/Services/GameAuthService.cs | 160 ++++++++++++++++++++++++++ RpgRoller/Services/GameService.cs | 109 ++---------------- 3 files changed, 168 insertions(+), 102 deletions(-) create mode 100644 RpgRoller/Services/GameAuthService.cs diff --git a/README.md b/README.md index 0d2187b..8f5e21b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Backend: - `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies) - `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: extracted pure backend rule helpers used by `GameService` - `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: extracted runtime-state ownership and SQLite load/save boundaries used by `GameService` +- `RpgRoller/Services/GameAuthService.cs`: extracted auth/session workflow ownership while `GameService` stays on the existing `IGameService` contract Frontend: diff --git a/RpgRoller/Services/GameAuthService.cs b/RpgRoller/Services/GameAuthService.cs new file mode 100644 index 0000000..d7cb639 --- /dev/null +++ b/RpgRoller/Services/GameAuthService.cs @@ -0,0 +1,160 @@ +using Microsoft.AspNetCore.Identity; +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameAuthService +{ + public GameAuthService(GameStateStore stateStore, IPasswordHasher passwordHasher, GamePersistenceService persistenceService) + { + m_StateStore = stateStore; + m_PasswordHasher = passwordHasher; + m_PersistenceService = persistenceService; + } + + public ServiceResult Register(string username, string password, string displayName) + { + if (string.IsNullOrWhiteSpace(username)) + return ServiceResult.Failure("invalid_username", "Username is required."); + + if (string.IsNullOrWhiteSpace(displayName)) + return ServiceResult.Failure("invalid_display_name", "Display name is required."); + + if (string.IsNullOrWhiteSpace(password) || password.Length < 8) + return ServiceResult.Failure("invalid_password", "Password must be at least 8 characters."); + + lock (m_StateStore.Gate) + { + var trimmedUsername = username.Trim(); + var normalizedUsername = NormalizeUsername(trimmedUsername); + if (m_StateStore.UserIdsByUsername.ContainsKey(normalizedUsername)) + return ServiceResult.Failure("duplicate_username", "Username is already taken."); + + var user = new UserAccount + { + Id = Guid.NewGuid(), + Username = trimmedUsername, + UsernameNormalized = normalizedUsername, + DisplayName = displayName.Trim(), + PasswordHash = string.Empty, + Roles = m_StateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty, + ActiveCharacterId = null + }; + + user.PasswordHash = m_PasswordHasher.HashPassword(user, password); + + m_StateStore.UsersById[user.Id] = user; + m_StateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id; + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToUserSummary(user)); + } + } + + public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + + lock (m_StateStore.Gate) + { + var normalizedUsername = NormalizeUsername(username.Trim()); + if (!m_StateStore.UserIdsByUsername.TryGetValue(normalizedUsername, out var userId)) + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + + var user = m_StateStore.UsersById[userId]; + var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (verification == PasswordVerificationResult.Failed) + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + + if (verification == PasswordVerificationResult.SuccessRehashNeeded) + user.PasswordHash = m_PasswordHasher.HashPassword(user, password); + + var session = CreateSession(userId); + m_PersistenceService.PersistStateLocked(); + return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token)); + } + } + + public void Logout(string sessionToken) + { + lock (m_StateStore.Gate) + { + if (m_StateStore.SessionsByToken.Remove(sessionToken)) + m_PersistenceService.PersistStateLocked(); + } + } + + public UserSummary? GetUserBySession(string sessionToken) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + return user is null ? null : ToUserSummary(user); + } + } + + public ServiceResult GetMe(string sessionToken) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + Guid? campaignId = null; + if (user.ActiveCharacterId is Guid activeCharacterId) + { + if (!m_StateStore.CharactersById.TryGetValue(activeCharacterId, out var activeCharacter)) + { + user.ActiveCharacterId = null; + m_PersistenceService.PersistStateLocked(); + } + else + campaignId = activeCharacter.CampaignId; + } + + return ServiceResult.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId)); + } + } + + private UserSession CreateSession(Guid userId) + { + var token = Guid.NewGuid().ToString("N"); + var session = new UserSession + { + Token = token, + UserId = userId, + CreatedAtUtc = DateTimeOffset.UtcNow + }; + + m_StateStore.SessionsByToken[token] = session; + return session; + } + + private UserAccount? ResolveUserLocked(string sessionToken) + { + if (string.IsNullOrWhiteSpace(sessionToken)) + return null; + + if (!m_StateStore.SessionsByToken.TryGetValue(sessionToken, out var session)) + return null; + + return m_StateStore.UsersById.GetValueOrDefault(session.UserId); + } + + private static UserSummary ToUserSummary(UserAccount user) + { + return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); + } + + private static string NormalizeUsername(string username) + { + return username.ToUpperInvariant(); + } + + private readonly IPasswordHasher m_PasswordHasher; + private readonly GamePersistenceService m_PersistenceService; + private readonly GameStateStore m_StateStore; +} diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 1b5f84f..fe03c17 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -23,7 +23,7 @@ public sealed class GameService : IGameService m_UserIdsByUsername = m_StateStore.UserIdsByUsername; m_UsersById = m_StateStore.UsersById; m_PersistenceService = new(dbContextFactory, m_StateStore); - m_PasswordHasher = passwordHasher; + m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService); m_DiceRoller = diceRoller; LoadStateFromDatabase(); } @@ -35,108 +35,27 @@ public sealed class GameService : IGameService public ServiceResult Register(string username, string password, string displayName) { - if (string.IsNullOrWhiteSpace(username)) - return ServiceResult.Failure("invalid_username", "Username is required."); - - if (string.IsNullOrWhiteSpace(displayName)) - return ServiceResult.Failure("invalid_display_name", "Display name is required."); - - if (string.IsNullOrWhiteSpace(password) || password.Length < 8) - return ServiceResult.Failure("invalid_password", "Password must be at least 8 characters."); - - lock (m_Gate) - { - var trimmedUsername = username.Trim(); - var normalizedUsername = NormalizeUsername(trimmedUsername); - if (m_UserIdsByUsername.ContainsKey(normalizedUsername)) - return ServiceResult.Failure("duplicate_username", "Username is already taken."); - - var user = new UserAccount - { - Id = Guid.NewGuid(), - Username = trimmedUsername, - UsernameNormalized = normalizedUsername, - DisplayName = displayName.Trim(), - PasswordHash = string.Empty, - Roles = m_UsersById.Count == 0 ? UserRoles.Admin : string.Empty, - ActiveCharacterId = null - }; - - user.PasswordHash = m_PasswordHasher.HashPassword(user, password); - - m_UsersById[user.Id] = user; - m_UserIdsByUsername[user.UsernameNormalized] = user.Id; - - PersistStateLocked(); - return ServiceResult.Success(ToUserSummary(user)); - } + return m_AuthService.Register(username, password, displayName); } public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) { - if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) - return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); - - lock (m_Gate) - { - var normalizedUsername = NormalizeUsername(username.Trim()); - if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, out var userId)) - return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); - - var user = m_UsersById[userId]; - var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password); - if (verification == PasswordVerificationResult.Failed) - return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); - - if (verification == PasswordVerificationResult.SuccessRehashNeeded) - user.PasswordHash = m_PasswordHasher.HashPassword(user, password); - - var session = CreateSession(userId); - PersistStateLocked(); - return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token)); - } + return m_AuthService.Login(username, password); } public void Logout(string sessionToken) { - lock (m_Gate) - { - if (m_SessionsByToken.Remove(sessionToken)) - PersistStateLocked(); - } + m_AuthService.Logout(sessionToken); } public UserSummary? GetUserBySession(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - return user is null ? null : ToUserSummary(user); - } + return m_AuthService.GetUserBySession(sessionToken); } public ServiceResult GetMe(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - Guid? campaignId = null; - if (user.ActiveCharacterId is Guid activeCharacterId) - { - if (!m_CharactersById.TryGetValue(activeCharacterId, out var activeCharacter)) - { - user.ActiveCharacterId = null; - PersistStateLocked(); - } - else - campaignId = activeCharacter.CampaignId; - } - - return ServiceResult.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId)); - } + return m_AuthService.GetMe(sessionToken); } public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) @@ -1638,20 +1557,6 @@ public sealed class GameService : IGameService return RoleSerializer.HasRole(user.Roles, role); } - private UserSession CreateSession(Guid userId) - { - var token = Guid.NewGuid().ToString("N"); - var session = new UserSession - { - Token = token, - UserId = userId, - CreatedAtUtc = DateTimeOffset.UtcNow - }; - - m_SessionsByToken[token] = session; - return session; - } - private UserAccount? ResolveUserLocked(string sessionToken) { if (string.IsNullOrWhiteSpace(sessionToken)) @@ -1766,9 +1671,9 @@ public sealed class GameService : IGameService private readonly Dictionary m_CampaignsById; private readonly Dictionary m_CampaignStateById; private readonly Dictionary m_CharactersById; + private readonly GameAuthService m_AuthService; private readonly IDiceRoller m_DiceRoller; private readonly object m_Gate; - private readonly IPasswordHasher m_PasswordHasher; private readonly GamePersistenceService m_PersistenceService; private readonly List m_RollLog; private readonly Dictionary m_SessionsByToken;