using System.Security.Cryptography; using System.Text; using Dapper; using Flashsharp.Domain; using Microsoft.AspNetCore.WebUtilities; namespace Flashsharp.Data; public sealed partial class Store { const int PasswordWorkFactor = 12; const string TokenPrefix = "fsh_"; public async Task CountUsersAsync(CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); return await connection.ExecuteScalarAsync("select count(*) from app_user"); } public async Task SeedAsync( string? username, string? password, string? passwordHash, CancellationToken cancellationToken = default) { if (await CountUsersAsync(cancellationToken) > 0) { return; } if (string.IsNullOrWhiteSpace(username) || (string.IsNullOrEmpty(password) && string.IsNullOrWhiteSpace(passwordHash))) { throw new InvalidOperationException( "No users exist yet. Set FLASHSHARP_SEED_USERNAME and one of " + "FLASHSHARP_SEED_PASSWORD, FLASHSHARP_SEED_PASSWORD_FILE, " + "FLASHSHARP_SEED_PASSWORD_HASH or FLASHSHARP_SEED_PASSWORD_HASH_FILE " + "to create the first user."); } var normalized = username.Trim(); var hash = string.IsNullOrWhiteSpace(passwordHash) ? BCrypt.Net.BCrypt.HashPassword(password!, PasswordWorkFactor) : passwordHash.Trim(); if (!hash.StartsWith("$2", StringComparison.Ordinal)) { throw new InvalidOperationException("The seed password hash must be a bcrypt hash."); } await InsertUserAsync(normalized, hash, cancellationToken); } public async Task CreateUserAsync( string username, string password, CancellationToken cancellationToken = default) { var normalized = username.Trim(); if (normalized.Length == 0) { throw new ArgumentException("Username must not be empty.", nameof(username)); } if (password.Length < 8) { throw new ArgumentException("Password must be at least 8 characters.", nameof(password)); } var hash = BCrypt.Net.BCrypt.HashPassword(password, PasswordWorkFactor); return await InsertUserAsync(normalized, hash, cancellationToken); } async Task InsertUserAsync(string username, string passwordHash, CancellationToken cancellationToken) { await using var connection = await db.OpenAsync(cancellationToken); var id = await connection.ExecuteScalarAsync( """ insert into app_user (username, password_hash) values (@username, @passwordHash) returning id """, new { username, passwordHash }); return new User(id, username); } public async Task GetUserByUsernameAsync(string username, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); return await connection.QuerySingleOrDefaultAsync( """select id as "Id", username as "Username" from app_user where lower(username) = lower(@username) and is_active""", new { username }); } public async Task GetUserAsync(long id, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); return await connection.QuerySingleOrDefaultAsync( """select id as "Id", username as "Username" from app_user where id = @id and is_active""", new { id }); } public async Task> ListUsersAsync(CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); var rows = await connection.QueryAsync( """select id as "Id", username as "Username" from app_user where is_active order by lower(username)"""); return rows.ToList(); } public async Task DeactivateUserAsync(long id, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); await connection.ExecuteAsync("update app_user set is_active = false where id = @id", new { id }); } public async Task VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); var hash = await connection.QuerySingleOrDefaultAsync( """select password_hash from app_user where lower(username) = lower(@username) and is_active""", new { username }); return hash is not null && BCrypt.Net.BCrypt.Verify(password, hash); } public async Task ChangePasswordAsync(long userId, string newPassword, CancellationToken cancellationToken = default) { if (newPassword.Length < 8) { throw new ArgumentException("Password must be at least 8 characters.", nameof(newPassword)); } var hash = BCrypt.Net.BCrypt.HashPassword(newPassword, PasswordWorkFactor); await using var connection = await db.OpenAsync(cancellationToken); await connection.ExecuteAsync( "update app_user set password_hash = @hash where id = @userId", new { hash, userId }); } public async Task<(AuthToken Token, string Raw)> CreateTokenAsync( long userId, string label, string kind, TimeSpan? lifetime, CancellationToken cancellationToken = default) { var raw = TokenPrefix + WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); var hash = HashToken(raw); var expiresAt = lifetime is null ? (DateTime?)null : DateTime.UtcNow.Add(lifetime.Value); await using var connection = await db.OpenAsync(cancellationToken); var row = await connection.QuerySingleAsync( """ insert into auth_token (user_id, label, kind, token_hash, expires_at) values (@userId, @label, @kind, @hash, @expiresAt) returning id as "Id", label as "Label", kind as "Kind", created_at as "CreatedAt", last_used_at as "LastUsedAt", expires_at as "ExpiresAt" """, new { userId, label = label.Trim(), kind, hash, expiresAt }); return (row, raw); } public async Task> ListTokensAsync( long userId, string kind, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); var rows = await connection.QueryAsync( """ select id as "Id", label as "Label", kind as "Kind", created_at as "CreatedAt", last_used_at as "LastUsedAt", expires_at as "ExpiresAt" from auth_token where user_id = @userId and kind = @kind and revoked_at is null order by created_at desc """, new { userId, kind }); return rows.ToList(); } public async Task RevokeTokenAsync(long userId, long tokenId, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); var affected = await connection.ExecuteAsync( """ update auth_token set revoked_at = now() where id = @tokenId and user_id = @userId and revoked_at is null """, new { tokenId, userId }); return affected > 0; } public async Task RevokeOtherBrowserTokensAsync( long userId, long? exceptTokenId, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); await connection.ExecuteAsync( """ update auth_token set revoked_at = now() where user_id = @userId and kind = 'browser' and revoked_at is null and (@exceptTokenId::bigint is null or id <> @exceptTokenId) """, new { userId, exceptTokenId }); } public async Task<(User User, AuthToken Token)?> AuthenticateTokenAsync( string rawToken, CancellationToken cancellationToken = default) { var hash = HashToken(rawToken); await using var connection = await db.OpenAsync(cancellationToken); var row = await connection.QuerySingleOrDefaultAsync( """ select t.id as "TokenId", t.label as "Label", t.kind as "Kind", t.created_at as "TokenCreatedAt", t.last_used_at as "LastUsedAt", t.expires_at as "ExpiresAt", u.id as "UserId", u.username as "Username" from auth_token t join app_user u on u.id = t.user_id where t.token_hash = @hash and t.revoked_at is null and (t.expires_at is null or t.expires_at > now()) and u.is_active """, new { hash }); if (row is null) { return null; } var token = new AuthToken { Id = row.TokenId, Label = row.Label, Kind = row.Kind, CreatedAt = row.TokenCreatedAt, LastUsedAt = row.LastUsedAt, ExpiresAt = row.ExpiresAt }; return (new User(row.UserId, row.Username), token); } public async Task TouchTokenAsync(long tokenId, CancellationToken cancellationToken = default) { await using var connection = await db.OpenAsync(cancellationToken); await connection.ExecuteAsync( """ update auth_token set last_used_at = now() where id = @tokenId and (last_used_at is null or last_used_at < now() - interval '5 minutes') """, new { tokenId }); } static byte[] HashToken(string rawToken) => SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)); sealed class AuthLookup { public long TokenId { get; init; } public string Label { get; init; } = ""; public string Kind { get; init; } = ""; public DateTime TokenCreatedAt { get; init; } public DateTime? LastUsedAt { get; init; } public DateTime? ExpiresAt { get; init; } public long UserId { get; init; } public string Username { get; init; } = ""; } }