165 lines
6.3 KiB
Go
165 lines
6.3 KiB
Go
|
|
package store
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"encoding/base64"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Passkey — ключ доступа (WebAuthn) пользователя. Полная запись credential
|
||
|
|
// лежит в CredentialJSON: библиотека go-webauthn обновляет в ней счётчик
|
||
|
|
// подписей и флаги, поэтому структуру не разбираем на колонки.
|
||
|
|
type Passkey struct {
|
||
|
|
ID uint64
|
||
|
|
UserID uint64
|
||
|
|
CredentialID string
|
||
|
|
Name string
|
||
|
|
CredentialJSON []byte
|
||
|
|
SignCount uint32
|
||
|
|
Transports string
|
||
|
|
CreatedAt time.Time
|
||
|
|
LastUsedAt *time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// CreatePasskeyParams — параметры нового ключа.
|
||
|
|
type CreatePasskeyParams struct {
|
||
|
|
UserID uint64
|
||
|
|
CredentialID string
|
||
|
|
Name string
|
||
|
|
CredentialJSON []byte
|
||
|
|
SignCount uint32
|
||
|
|
Transports string
|
||
|
|
}
|
||
|
|
|
||
|
|
const passkeyColumns = `id, user_id, credential_id, name, credential_json, sign_count, transports, created_at, last_used_at`
|
||
|
|
|
||
|
|
func scanPasskey(scanner interface{ Scan(...any) error }) (*Passkey, error) {
|
||
|
|
var (
|
||
|
|
passkey Passkey
|
||
|
|
credential string
|
||
|
|
lastUsed sql.NullString
|
||
|
|
createdAt string
|
||
|
|
)
|
||
|
|
if err := scanner.Scan(&passkey.ID, &passkey.UserID, &passkey.CredentialID, &passkey.Name,
|
||
|
|
&credential, &passkey.SignCount, &passkey.Transports, &createdAt, &lastUsed); err != nil {
|
||
|
|
return nil, mapError(err)
|
||
|
|
}
|
||
|
|
passkey.CredentialJSON = []byte(credential)
|
||
|
|
passkey.CreatedAt = parseTimestamp(createdAt)
|
||
|
|
if lastUsed.Valid {
|
||
|
|
value := parseTimestamp(lastUsed.String)
|
||
|
|
passkey.LastUsedAt = &value
|
||
|
|
}
|
||
|
|
return &passkey, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// CreatePasskey сохраняет новый ключ доступа.
|
||
|
|
func (s *Store) CreatePasskey(ctx context.Context, params CreatePasskeyParams) (*Passkey, error) {
|
||
|
|
id := s.NextID()
|
||
|
|
_, err := s.writer.ExecContext(ctx, `
|
||
|
|
INSERT INTO webauthn_credentials
|
||
|
|
(id, user_id, credential_id, name, credential_json, sign_count, transports, created_at, updated_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
|
|
int64(id), int64(params.UserID), params.CredentialID, params.Name,
|
||
|
|
string(params.CredentialJSON), int64(params.SignCount), params.Transports, s.Now(), s.Now())
|
||
|
|
if err != nil {
|
||
|
|
return nil, mapError(err)
|
||
|
|
}
|
||
|
|
return s.GetPasskey(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetPasskey возвращает ключ по идентификатору.
|
||
|
|
func (s *Store) GetPasskey(ctx context.Context, id uint64) (*Passkey, error) {
|
||
|
|
row := s.reader.QueryRowContext(ctx, `SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE id = ?`, int64(id))
|
||
|
|
return scanPasskey(row)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetPasskeyByCredentialID ищет ключ по идентификатору credential: он приходит
|
||
|
|
// от аутентификатора при входе, поэтому поиск идёт по уникальному индексу.
|
||
|
|
func (s *Store) GetPasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
|
||
|
|
row := s.reader.QueryRowContext(ctx,
|
||
|
|
`SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE credential_id = ?`, credentialID)
|
||
|
|
return scanPasskey(row)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListPasskeys возвращает ключи пользователя: свежие сверху.
|
||
|
|
func (s *Store) ListPasskeys(ctx context.Context, userID uint64) ([]Passkey, error) {
|
||
|
|
rows, err := s.reader.QueryContext(ctx,
|
||
|
|
`SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC, id DESC`,
|
||
|
|
int64(userID))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
passkeys := make([]Passkey, 0, 4)
|
||
|
|
for rows.Next() {
|
||
|
|
passkey, err := scanPasskey(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
passkeys = append(passkeys, *passkey)
|
||
|
|
}
|
||
|
|
return passkeys, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// CountPasskeys считает ключи пользователя: ограничиваем их число, чтобы
|
||
|
|
// список не разрастался (AGENT.md 7.1 — несколько ключей на аккаунт).
|
||
|
|
func (s *Store) CountPasskeys(ctx context.Context, userID uint64) (int, error) {
|
||
|
|
var count int
|
||
|
|
err := s.reader.QueryRowContext(ctx,
|
||
|
|
`SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = ?`, int64(userID)).Scan(&count)
|
||
|
|
return count, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// RenamePasskey меняет отображаемое имя ключа.
|
||
|
|
func (s *Store) RenamePasskey(ctx context.Context, id, userID uint64, name string) error {
|
||
|
|
result, err := s.writer.ExecContext(ctx,
|
||
|
|
`UPDATE webauthn_credentials SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
|
||
|
|
name, s.Now(), int64(id), int64(userID))
|
||
|
|
if err != nil {
|
||
|
|
return mapError(err)
|
||
|
|
}
|
||
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||
|
|
return ErrNotFound
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeletePasskey удаляет ключ пользователя: чужой ключ удалить нельзя.
|
||
|
|
func (s *Store) DeletePasskey(ctx context.Context, id, userID uint64) error {
|
||
|
|
result, err := s.writer.ExecContext(ctx,
|
||
|
|
`DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?`, int64(id), int64(userID))
|
||
|
|
if err != nil {
|
||
|
|
return mapError(err)
|
||
|
|
}
|
||
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||
|
|
return ErrNotFound
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeletePasskeysForUser удаляет все ключи пользователя (сброс администратором).
|
||
|
|
func (s *Store) DeletePasskeysForUser(ctx context.Context, userID uint64) error {
|
||
|
|
_, err := s.writer.ExecContext(ctx, `DELETE FROM webauthn_credentials WHERE user_id = ?`, int64(userID))
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// UpdatePasskeyUsage сохраняет обновлённую запись credential после успешного
|
||
|
|
// входа: библиотека увеличивает счётчик подписей и обновляет флаги бэкапа.
|
||
|
|
func (s *Store) UpdatePasskeyUsage(ctx context.Context, id uint64, credentialJSON []byte, signCount uint32) error {
|
||
|
|
_, err := s.writer.ExecContext(ctx, `
|
||
|
|
UPDATE webauthn_credentials
|
||
|
|
SET credential_json = ?, sign_count = ?, last_used_at = ?, updated_at = ?
|
||
|
|
WHERE id = ?`,
|
||
|
|
string(credentialJSON), int64(signCount), s.Now(), s.Now(), int64(id))
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// EncodeCredentialID кодирует raw credential id в base64url без паддинга —
|
||
|
|
// в таком виде его отдаёт браузер (PublicKeyCredential.id).
|
||
|
|
func EncodeCredentialID(raw []byte) string {
|
||
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
||
|
|
}
|