feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]
This commit is contained in:
1 parent
ddb6608e32
commit
6385c1b9a3
31 files changed
+2296
-42
No files matched your search
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 11;
|
||||
public const int CurrentVersion = 12;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -161,6 +161,28 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 11");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 12)
|
||||
{
|
||||
// passkey 登录:每个用户可以注册若干 passkey(凭证公钥 SPKI、签名计数器);会话记录是怎么登录的,只有 passkey 登录的会话以后能提权
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("""
|
||||
CREATE TABLE passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
)
|
||||
""");
|
||||
connection.Exec("CREATE INDEX ix_passkeys_user ON passkeys (user_id)");
|
||||
connection.Exec("ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'");
|
||||
connection.Exec("PRAGMA user_version = 12");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -46,13 +46,13 @@ public sealed class SqliteAuthStore : IAuthStore
|
||||
public ValueTask CreateSessionAsync(SessionRecord session, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(
|
||||
c => c.Exec(
|
||||
"INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at) VALUES (@id, @u, @csrf, @c, @e)",
|
||||
("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs)),
|
||||
"INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at, auth_method) VALUES (@id, @u, @csrf, @c, @e, @m)",
|
||||
("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs), ("@m", session.AuthMethod)),
|
||||
ct);
|
||||
|
||||
public ValueTask<SessionRecord?> GetSessionAsync(byte[] idSha256, CancellationToken ct) =>
|
||||
_db.ReadAsync(
|
||||
c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4)), ("@id", idSha256)),
|
||||
c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at, auth_method FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4), r.GetString(5)), ("@id", idSha256)),
|
||||
ct);
|
||||
|
||||
public ValueTask DeleteSessionAsync(byte[] idSha256, CancellationToken ct) =>
|
||||
@@ -68,6 +68,27 @@ public sealed class SqliteAuthStore : IAuthStore
|
||||
("@id", token.Id), ("@n", token.Name), ("@caps", StorageJson.ToJson(token.Capabilities)), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs), ("@exp", token.ExpiresAtMs)),
|
||||
ct);
|
||||
|
||||
public ValueTask<bool> AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct) =>
|
||||
_db.WriteAsync(
|
||||
c => c.Scalar<long>("SELECT COUNT(*) FROM passkeys WHERE id = @id", ("@id", passkey.Id)) > 0
|
||||
? false
|
||||
: c.Exec(
|
||||
"INSERT INTO passkeys (id, user_id, name, public_key, sign_count, transports, created_at) VALUES (@id, @u, @n, @k, @s, @t, @at)",
|
||||
("@id", passkey.Id), ("@u", passkey.UserId), ("@n", passkey.Name), ("@k", passkey.PublicKeySpki), ("@s", passkey.SignCount), ("@t", StorageJson.ToJson(passkey.Transports)), ("@at", passkey.CreatedAtMs)) == 1,
|
||||
ct);
|
||||
|
||||
public ValueTask<PasskeyRecord?> FindPasskeyAsync(string credentialId, CancellationToken ct) =>
|
||||
_db.ReadAsync(c => c.QueryOne($"SELECT {PasskeyColumns} FROM passkeys WHERE id = @id", ReadPasskey, ("@id", credentialId)), ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<PasskeyRecord>>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct);
|
||||
|
||||
public ValueTask<bool> DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct) =>
|
||||
_db.WriteAsync(c => c.Exec("DELETE FROM passkeys WHERE id = @id AND user_id = @u", ("@id", credentialId), ("@u", userId)) == 1, ct);
|
||||
|
||||
public ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(c => c.Exec("UPDATE passkeys SET sign_count = @s, last_used_at = @now WHERE id = @id", ("@id", credentialId), ("@s", signCount), ("@now", nowMs)), ct);
|
||||
|
||||
public ValueTask<ApiTokenRecord?> FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct) =>
|
||||
_db.ReadAsync(c => c.QueryOne($"SELECT {TokenColumns} FROM api_tokens WHERE token_sha256 = @h", ReadToken, ("@h", tokenSha256)), ct);
|
||||
|
||||
@@ -80,6 +101,10 @@ public sealed class SqliteAuthStore : IAuthStore
|
||||
public ValueTask TouchApiTokenAsync(string id, long nowMs, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(c => c.Exec("UPDATE api_tokens SET last_used_at = @now WHERE id = @id", ("@id", id), ("@now", nowMs)), ct);
|
||||
|
||||
private const string PasskeyColumns = "id, user_id, name, public_key, sign_count, transports, created_at, last_used_at";
|
||||
|
||||
private static PasskeyRecord ReadPasskey(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), StorageJson.Strings(r.GetString(5)), r.GetInt64(6), r.NullableLong(7));
|
||||
|
||||
private static UserRecord ReadUser(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.Bytes(2), r.Bytes(3), r.GetInt32(4), r.GetInt64(5));
|
||||
|
||||
private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), StorageJson.Strings(r.GetString(2)), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6), r.NullableLong(7));
|
||||
|
||||
Reference in new issue
Block a user