Files
ResolveSpec/pkg/security/lookup/direct/passkey.go
T
Hein c9fa8c60f2 refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).

- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
  podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
  parameter, JSON null arrays, expires_at timezone casts, passkey list
  GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
2026-10-01 13:19:44 +02:00

295 lines
10 KiB
Go

package direct
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
)
// Passkey implements lookup.PasskeyStore. credential_id, public_key and aaguid are base64
// TEXT (not native bytea) and transports is JSON, so one schema works on every dialect.
type Passkey struct{ *Base }
var _ lookup.PasskeyStore = (*Passkey)(nil)
// NewPasskey creates the direct PasskeyStore.
func NewPasskey(b *Base) *Passkey { return &Passkey{Base: b} }
// Store implements lookup.PasskeyStore.
func (p *Passkey) Store(ctx context.Context, rec lookup.PasskeyCredentialRecord) (int64, error) {
transports, err := p.d.EncodeJSON(rec.Transports)
if err != nil {
return 0, fmt.Errorf("failed to marshal transports: %w", err)
}
var id int64
err = p.tx(ctx, func(q Querier) error {
exists, err := p.From(lookup.EntityUserPasskeyCredentials).Cols(lookup.PasskeyID).
Where(Eq(lookup.PasskeyCredentialID, rec.CredentialID)).Exists(ctx, q)
if err != nil {
return err
}
if exists {
return fmt.Errorf("credential already exists")
}
userExists, err := p.From(lookup.EntityUsers).Cols(lookup.UsersID).Where(Eq(lookup.UsersID, rec.UserID)).Exists(ctx, q)
if err != nil {
return err
}
if !userExists {
return fmt.Errorf("user not found")
}
now := p.Now()
id, err = p.Insert(lookup.EntityUserPasskeyCredentials).Set(
Set(lookup.PasskeyUserID, rec.UserID),
Set(lookup.PasskeyCredentialID, rec.CredentialID),
Set(lookup.PasskeyPublicKey, rec.PublicKey),
Set(lookup.PasskeyAttestationType, rec.AttestationType),
Set(lookup.PasskeyAAGUID, ""),
Set(lookup.PasskeySignCount, int64(rec.SignCount)),
Set(lookup.PasskeyTransports, transports),
Set(lookup.PasskeyBackupEligible, rec.BackupEligible),
Set(lookup.PasskeyBackupState, rec.BackupState),
Set(lookup.PasskeyName, rec.Name),
Set(lookup.PasskeyCreatedAt, now),
Set(lookup.PasskeyLastUsedAt, now),
).ExecID(ctx, q, lookup.PasskeyID)
return err
})
if err != nil {
return 0, err
}
return id, nil
}
// Get implements lookup.PasskeyStore.
func (p *Passkey) Get(ctx context.Context, credentialID string) (int, uint32, error) {
var userID int
var count int64
err := p.do(func(q Querier) error {
return p.From(lookup.EntityUserPasskeyCredentials).Cols(lookup.PasskeyUserID, lookup.PasskeySignCount).
Where(Eq(lookup.PasskeyCredentialID, credentialID)).QueryRow(ctx, q, &userID, &count)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, fmt.Errorf("credential not found")
}
return 0, 0, fmt.Errorf("failed to get credential: %w", err)
}
return userID, uint32(count), nil //nolint:gosec // sign counters are stored from uint32 values
}
// UpdateCounter implements lookup.PasskeyStore. A counter that did not advance flags the
// credential as possibly cloned and leaves the stored counter unchanged.
func (p *Passkey) UpdateCounter(ctx context.Context, credentialID string, newCounter uint32) (bool, error) {
var clone bool
err := p.tx(ctx, func(q Querier) error {
match := Eq(lookup.PasskeyCredentialID, credentialID)
var old int64
if err := p.From(lookup.EntityUserPasskeyCredentials).Cols(lookup.PasskeySignCount).Where(match).QueryRow(ctx, q, &old); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("credential not found")
}
return err
}
if int64(newCounter) <= old {
clone = true
_, err := p.Update(lookup.EntityUserPasskeyCredentials).Set(Set(lookup.PasskeyCloneWarning, true)).Where(match).Exec(ctx, q)
return err
}
_, err := p.Update(lookup.EntityUserPasskeyCredentials).
Set(Set(lookup.PasskeySignCount, int64(newCounter)), Set(lookup.PasskeyLastUsedAt, p.Now())).
Where(match).Exec(ctx, q)
return err
})
return clone, err
}
// List implements lookup.PasskeyStore, newest first.
func (p *Passkey) List(ctx context.Context, userID int) ([]sectypes.PasskeyCredential, error) {
var out []sectypes.PasskeyCredential
err := p.do(func(q Querier) error {
out = make([]sectypes.PasskeyCredential, 0)
rows, err := p.From(lookup.EntityUserPasskeyCredentials).
Cols(lookup.PasskeyID, lookup.PasskeyUserID, lookup.PasskeyCredentialID, lookup.PasskeyPublicKey,
lookup.PasskeyAttestationType, lookup.PasskeyAAGUID, lookup.PasskeySignCount, lookup.PasskeyCloneWarning,
lookup.PasskeyTransports, lookup.PasskeyBackupEligible, lookup.PasskeyBackupState, lookup.PasskeyName,
lookup.PasskeyCreatedAt, lookup.PasskeyLastUsedAt).
Where(Eq(lookup.PasskeyUserID, userID)).OrderBy(lookup.PasskeyCreatedAt).Query(ctx, q)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id, uid int
var credB64, pubB64 string
var attestation, aaguidB64, name sql.NullString
var count sql.NullInt64
var clone, eligible, state bool
var transports any
var created, last time.Time
if err := rows.Scan(&id, &uid, &credB64, &pubB64, &attestation, &aaguidB64, &count, p.boolDest(&clone),
&transports, p.boolDest(&eligible), p.boolDest(&state), &name, p.timeDest(&created), p.timeDest(&last)); err != nil {
return err
}
credID, err := base64.StdEncoding.DecodeString(credB64)
if err != nil {
continue
}
pub, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
continue
}
aaguid, _ := base64.StdEncoding.DecodeString(aaguidB64.String)
c := sectypes.PasskeyCredential{
ID: fmt.Sprintf("%d", id),
UserID: uid,
CredentialID: credID,
PublicKey: pub,
AttestationType: attestation.String,
AAGUID: aaguid,
SignCount: uint32(count.Int64), //nolint:gosec // stored from uint32 values
CloneWarning: clone,
BackupEligible: eligible,
BackupState: state,
Name: name.String,
CreatedAt: created,
LastUsedAt: last,
}
_ = p.d.DecodeJSON(transports, &c.Transports)
out = append(out, c)
}
return rows.Err()
})
if err != nil {
return nil, fmt.Errorf("failed to get credentials: %w", err)
}
// newest first
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
}
// Delete implements lookup.PasskeyStore.
func (p *Passkey) Delete(ctx context.Context, userID int, credentialID string) error {
var rows int64
err := p.do(func(q Querier) error {
var err error
rows, err = p.Base.Delete(lookup.EntityUserPasskeyCredentials).
Where(Eq(lookup.PasskeyUserID, userID), Eq(lookup.PasskeyCredentialID, credentialID)).Exec(ctx, q)
return err
})
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("credential not found")
}
return nil
}
// Rename implements lookup.PasskeyStore.
func (p *Passkey) Rename(ctx context.Context, userID int, credentialID, name string) error {
var rows int64
err := p.do(func(q Querier) error {
var err error
rows, err = p.Update(lookup.EntityUserPasskeyCredentials).Set(Set(lookup.PasskeyName, name)).
Where(Eq(lookup.PasskeyUserID, userID), Eq(lookup.PasskeyCredentialID, credentialID)).Exec(ctx, q)
return err
})
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("credential not found")
}
return nil
}
// ByUsername implements lookup.PasskeyStore.
func (p *Passkey) ByUsername(ctx context.Context, username string) (int, []lookup.PasskeyCredentialRef, error) {
var userID int
var creds []lookup.PasskeyCredentialRef
err := p.do(func(q Querier) error {
creds = make([]lookup.PasskeyCredentialRef, 0)
if err := p.From(lookup.EntityUsers).Cols(lookup.UsersID).
Where(Eq(lookup.UsersUsername, username), Eq(lookup.UsersIsActive, true)).QueryRow(ctx, q, &userID); err != nil {
return err
}
rows, err := p.From(lookup.EntityUserPasskeyCredentials).Cols(lookup.PasskeyCredentialID, lookup.PasskeyTransports).
Where(Eq(lookup.PasskeyUserID, userID)).Query(ctx, q)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var ref lookup.PasskeyCredentialRef
var transports any
if err := rows.Scan(&ref.CredentialID, &transports); err != nil {
return err
}
_ = p.d.DecodeJSON(transports, &ref.Transports)
creds = append(creds, ref)
}
return rows.Err()
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil, fmt.Errorf("user not found")
}
return 0, nil, fmt.Errorf("failed to get credentials: %w", err)
}
return userID, creds, nil
}
// Login implements lookup.PasskeyStore: it creates the session for a user whose passkey
// assertion was already verified.
func (p *Passkey) Login(ctx context.Context, userID int, claims map[string]any) (*sectypes.LoginResponse, error) {
var u userRow
err := p.do(func(q Querier) error {
return p.From(lookup.EntityUsers).
Cols(lookup.UsersUsername, lookup.UsersEmail, lookup.UsersUserLevel, lookup.UsersRoles,
lookup.UsersProgramUserID, lookup.UsersProgramUserTable).
Where(Eq(lookup.UsersID, userID), Eq(lookup.UsersIsActive, true)).
QueryRow(ctx, q, &u.username, &u.email, &u.userLevel, &u.roles, &u.programUserID, &u.programUserTable)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("passkey login query failed: %w", err)
}
u.id = userID
token, err := GenerateSessionToken()
if err != nil {
return nil, fmt.Errorf("failed to generate session token: %w", err)
}
now := p.Now()
ip, ua := claimStrings(claims)
err = p.tx(ctx, func(q Querier) error {
if err := p.Insert(lookup.EntityUserSessions).Set(
Set(lookup.SessionsToken, token),
Set(lookup.SessionsUserID, userID),
Set(lookup.SessionsExpiresAt, now.Add(sessionLifetime)),
Set(lookup.SessionsIPAddress, ip),
Set(lookup.SessionsUserAgent, ua),
Set(lookup.SessionsLastActivityAt, now),
Set(lookup.SessionsCreatedAt, now),
).Exec(ctx, q); err != nil {
return err
}
_, err := p.Update(lookup.EntityUsers).Set(Set(lookup.UsersLastLoginAt, now)).Where(Eq(lookup.UsersID, userID)).Exec(ctx, q)
return err
})
if err != nil {
return nil, fmt.Errorf("passkey login query failed: %w", err)
}
return &sectypes.LoginResponse{Token: token, User: u.context(token), ExpiresIn: int64(sessionLifetime.Seconds())}, nil
}