273 lines
11 KiB
C#
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; } = "";
|
|
}
|
|
}
|