Build flashsharp flashcard app
This commit is contained in:
commit
c97759b04c
69 files changed
+6959
No files matched your search
@@ -0,0 +1,272 @@
|
||||
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; } = "";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user