mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
294 lines
10 KiB
Go
294 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) (userID int, signCount uint32, err error) {
|
|
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 §ypes.LoginResponse{Token: token, User: u.context(token), ExpiresIn: int64(sessionLifetime.Seconds())}, nil
|
|
}
|