Files
flashsharp/src/Flashsharp/Data/Store.Auth.cs
T
2026-10-22 21:18:32 -04:00

273 lines
11 KiB
C#

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<int> CountUsersAsync(CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>("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<User> 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<User> InsertUserAsync(string username, string passwordHash, CancellationToken cancellationToken)
{
await using var connection = await db.OpenAsync(cancellationToken);
var id = await connection.ExecuteScalarAsync<long>(
"""
insert into app_user (username, password_hash)
values (@username, @passwordHash)
returning id
""",
new { username, passwordHash });
return new User(id, username);
}
public async Task<User?> GetUserByUsernameAsync(string username, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<User>(
"""select id as "Id", username as "Username" from app_user where lower(username) = lower(@username) and is_active""",
new { username });
}
public async Task<User?> GetUserAsync(long id, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<User>(
"""select id as "Id", username as "Username" from app_user where id = @id and is_active""",
new { id });
}
public async Task<IReadOnlyList<User>> ListUsersAsync(CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<User>(
"""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<bool> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var hash = await connection.QuerySingleOrDefaultAsync<string>(
"""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<AuthToken>(
"""
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<IReadOnlyList<AuthToken>> ListTokensAsync(
long userId,
string kind,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<AuthToken>(
"""
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<bool> 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<AuthLookup>(
"""
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; } = "";
}
}