using Dapper; using Femto.Common.Domain; using Femto.Common.Infrastructure.DbConnection; using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Infrastructure; using Femto.Modules.Auth.Models; using Microsoft.EntityFrameworkCore; namespace Femto.Modules.Auth.Application; internal class AuthService( AuthContext context, SessionStorage storage, IDbConnectionFactory connectionFactory ) : IAuthService { public async Task AuthenticateUserCredentials( string username, string password, CancellationToken cancellationToken = default ) { var user = await context .Users.Where(u => u.Username == username) .SingleOrDefaultAsync(cancellationToken); if (user is null) return null; if (!user.HasPassword(password)) return null; var session = new Session(user.Id, true); await storage.AddSession(session); return new( new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()), session ); } public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken) { return context .Users.Where(u => u.Id == userId) .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList())) .SingleOrDefaultAsync(cancellationToken); } public async Task CreateNewSession(Guid userId) { var session = new Session(userId, true); await storage.AddSession(session); return session; } public async Task CreateWeakSession(Guid userId) { var session = new Session(userId, false); await storage.AddSession(session); return session; } public Task GetSession(string sessionId) { return storage.GetSession(sessionId); } public async Task DeleteSession(string sessionId) { await storage.DeleteSession(sessionId); } public async Task CreateUserWithCredentials( string username, string password, string signupCode, CancellationToken cancellationToken = default ) { var now = DateTimeOffset.UtcNow; var code = await context .SignupCodes.Where(c => c.Code == signupCode) .Where(c => c.ExpiresAt == null || c.ExpiresAt > now) .Where(c => c.RedeemingUserId == null) .SingleOrDefaultAsync(cancellationToken); if (code is null) throw new DomainError("invalid signup code"); var usernameTaken = await context.Users.AnyAsync( u => u.Username == username, cancellationToken ); if (usernameTaken) throw new DomainError("username taken"); var user = new UserIdentity(username); await context.AddAsync(user, cancellationToken); user.SetPassword(password); code.Redeem(user.Id); var session = new Session(user.Id, true); await storage.AddSession(session); await context.SaveChangesAsync(cancellationToken); return new(new UserInfo(user), session); } public async Task AddSignupCode( string code, string recipientName, CancellationToken cancellationToken ) { await context.SignupCodes.AddAsync( new SignupCode("", recipientName, code), cancellationToken ); await context.SaveChangesAsync(cancellationToken); } public async Task> GetSignupCodes( CancellationToken cancellationToken = default ) { using var conn = connectionFactory.GetConnection(); // lang=sql const string sql = """ SELECT sc.code as Code, sc.recipient_email as Email, sc.recipient_name as Name, sc.redeeming_user_id as RedeemedByUserId, u.username as RedeemedByUsername, sc.expires_at as ExpiresOn FROM authn.signup_code sc LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id ORDER BY sc.created_at DESC """; var result = await conn.QueryAsync(sql, cancellationToken); return result .Select(row => new SignupCodeDto( row.Code, row.Email, row.Name, row.RedeemedByUserId, row.RedeemedByUsername, row.ExpiresOn )) .ToList(); } public async Task CreateRememberMeToken(Guid userId) { var (rememberMeToken, verifier) = LongTermSession.Create(userId); await context.AddAsync(rememberMeToken); await context.SaveChangesAsync(); return new(rememberMeToken.Selector, verifier, rememberMeToken.Expires); } public async Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken( RememberMeToken rememberMeToken ) { var token = await context.LongTermSessions.SingleOrDefaultAsync(t => t.Selector == rememberMeToken.Selector ); if (token is null) return (null, null); if (!token.Validate(rememberMeToken.Verifier)) return (null, null); var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId); if (user is null) return (null, null); if (token.ExpiresSoon) { var (newToken, verifier) = LongTermSession.Create(user.Id); await context.AddAsync(newToken); await context.SaveChangesAsync(); return (new(user), new(newToken.Selector, verifier, newToken.Expires)); } return (new(user), null); } public async Task DeleteRememberMeToken(RememberMeToken rememberMeToken) { var session = await context.LongTermSessions.SingleOrDefaultAsync(s => s.Selector == rememberMeToken.Selector ); if (session is null) return; if (!session.Validate(rememberMeToken.Verifier)) return; context.Remove(session); await context.SaveChangesAsync(); } private class GetSignupCodesQueryResultRow { public string Code { get; set; } public string Email { get; set; } public string Name { get; set; } public Guid? RedeemedByUserId { get; set; } public string? RedeemedByUsername { get; set; } public DateTimeOffset? ExpiresOn { get; set; } } }