Extract game auth service

This commit is contained in:
2026-04-04 23:21:16 +02:00
parent 8961c75305
commit a9558a16fc
3 changed files with 168 additions and 102 deletions

View File

@@ -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:

View File

@@ -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<UserAccount> passwordHasher, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PasswordHasher = passwordHasher;
m_PersistenceService = persistenceService;
}
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{
if (string.IsNullOrWhiteSpace(username))
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
if (string.IsNullOrWhiteSpace(displayName))
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
return ServiceResult<UserSummary>.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<UserSummary>.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<UserSummary>.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<MeResponse> GetMe(string sessionToken)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<MeResponse>.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<MeResponse>.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<UserAccount> m_PasswordHasher;
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}

View File

@@ -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<UserSummary> Register(string username, string password, string displayName)
{
if (string.IsNullOrWhiteSpace(username))
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
if (string.IsNullOrWhiteSpace(displayName))
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
return ServiceResult<UserSummary>.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<UserSummary>.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<UserSummary>.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<MeResponse> GetMe(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<MeResponse>.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<MeResponse>.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId));
}
return m_AuthService.GetMe(sessionToken);
}
public ServiceResult<CampaignSummary> 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<Guid, Campaign> m_CampaignsById;
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
private readonly Dictionary<Guid, Character> m_CharactersById;
private readonly GameAuthService m_AuthService;
private readonly IDiceRoller m_DiceRoller;
private readonly object m_Gate;
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly GamePersistenceService m_PersistenceService;
private readonly List<RollLogEntry> m_RollLog;
private readonly Dictionary<string, UserSession> m_SessionsByToken;