mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
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
252 lines
8.7 KiB
Go
252 lines
8.7 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/backends"
|
|
)
|
|
|
|
// DatabasePasskeyProvider implements PasskeyProvider on top of the lookup package
|
|
// (stored procedures on Postgres by default, direct SQL elsewhere).
|
|
type DatabasePasskeyProvider struct {
|
|
src *lookupSource
|
|
rpID string // Relying Party ID (domain)
|
|
rpName string // Relying Party display name
|
|
rpOrigin string // Expected origin for WebAuthn
|
|
timeout int64 // Timeout in milliseconds (default: 60000)
|
|
}
|
|
|
|
// DatabasePasskeyProviderOptions configures the passkey provider
|
|
type DatabasePasskeyProviderOptions struct {
|
|
// RPID is the Relying Party ID (typically your domain, e.g., "example.com")
|
|
RPID string
|
|
// RPName is the display name for your relying party
|
|
RPName string
|
|
// RPOrigin is the expected origin (e.g., "https://example.com")
|
|
RPOrigin string
|
|
// Timeout is the timeout for operations in milliseconds (default: 60000)
|
|
Timeout int64
|
|
// Lookup selects dialect, query mode and procedure/table/column names.
|
|
Lookup lookup.Config
|
|
// LookupProvider, when set, is used instead of building one from Lookup and the db.
|
|
LookupProvider *lookup.Provider
|
|
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
|
// If nil, reconnection is disabled.
|
|
DBFactory func() (*sql.DB, error)
|
|
}
|
|
|
|
// NewDatabasePasskeyProvider creates a new database-backed passkey provider
|
|
func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions) *DatabasePasskeyProvider {
|
|
if opts.Timeout == 0 {
|
|
opts.Timeout = 60000 // 60 seconds default
|
|
}
|
|
src := newLookupSource(db)
|
|
src.cfg = opts.Lookup
|
|
src.provider = opts.LookupProvider
|
|
src.opts = backends.Options{DBFactory: opts.DBFactory}
|
|
return &DatabasePasskeyProvider{
|
|
src: src,
|
|
rpID: opts.RPID,
|
|
rpName: opts.RPName,
|
|
rpOrigin: opts.RPOrigin,
|
|
timeout: opts.Timeout,
|
|
}
|
|
}
|
|
|
|
func (p *DatabasePasskeyProvider) store() lookup.PasskeyStore { return p.src.get().Passkey }
|
|
|
|
// BeginRegistration creates registration options for a new passkey
|
|
func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) {
|
|
// Generate challenge
|
|
challenge := make([]byte, 32)
|
|
if _, err := rand.Read(challenge); err != nil {
|
|
return nil, fmt.Errorf("failed to generate challenge: %w", err)
|
|
}
|
|
|
|
// Get existing credentials to exclude
|
|
credentials, err := p.GetCredentials(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get existing credentials: %w", err)
|
|
}
|
|
|
|
excludeCredentials := make([]PasskeyCredentialDescriptor, 0, len(credentials))
|
|
for i := range credentials {
|
|
excludeCredentials = append(excludeCredentials, PasskeyCredentialDescriptor{
|
|
Type: "public-key",
|
|
ID: credentials[i].CredentialID,
|
|
Transports: credentials[i].Transports,
|
|
})
|
|
}
|
|
|
|
// Create user handle (persistent user ID)
|
|
userHandle := []byte(fmt.Sprintf("user_%d", userID))
|
|
|
|
return &PasskeyRegistrationOptions{
|
|
Challenge: challenge,
|
|
RelyingParty: PasskeyRelyingParty{
|
|
ID: p.rpID,
|
|
Name: p.rpName,
|
|
},
|
|
User: PasskeyUser{
|
|
ID: userHandle,
|
|
Name: username,
|
|
DisplayName: displayName,
|
|
},
|
|
PubKeyCredParams: []PasskeyCredentialParam{
|
|
{Type: "public-key", Alg: -7}, // ES256 (ECDSA with SHA-256)
|
|
{Type: "public-key", Alg: -257}, // RS256 (RSASSA-PKCS1-v1_5 with SHA-256)
|
|
},
|
|
Timeout: p.timeout,
|
|
ExcludeCredentials: excludeCredentials,
|
|
AuthenticatorSelection: &PasskeyAuthenticatorSelection{
|
|
RequireResidentKey: false,
|
|
ResidentKey: "preferred",
|
|
UserVerification: "preferred",
|
|
},
|
|
Attestation: "none",
|
|
}, nil
|
|
}
|
|
|
|
// CompleteRegistration verifies and stores a new passkey credential
|
|
// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library
|
|
// like github.com/go-webauthn/webauthn to properly verify attestation and parse credentials.
|
|
func (p *DatabasePasskeyProvider) CompleteRegistration(ctx context.Context, userID int, response PasskeyRegistrationResponse, expectedChallenge []byte) (*PasskeyCredential, error) {
|
|
// TODO: Implement full WebAuthn verification
|
|
// 1. Verify clientDataJSON contains correct challenge and origin
|
|
// 2. Parse and verify attestationObject
|
|
// 3. Extract public key and credential ID
|
|
// 4. Verify attestation signature (if not "none")
|
|
|
|
// For now, this is a placeholder that stores the credential data
|
|
// In production, you MUST use a proper WebAuthn library
|
|
|
|
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
|
|
pubKeyB64 := base64.StdEncoding.EncodeToString(response.Response.AttestationObject)
|
|
|
|
credentialID, err := p.store().Store(ctx, lookup.PasskeyCredentialRecord{
|
|
UserID: userID,
|
|
CredentialID: credIDB64,
|
|
PublicKey: pubKeyB64,
|
|
AttestationType: "none",
|
|
Transports: response.Transports,
|
|
Name: "Passkey",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &PasskeyCredential{
|
|
ID: fmt.Sprintf("%d", credentialID),
|
|
UserID: userID,
|
|
CredentialID: response.RawID,
|
|
PublicKey: response.Response.AttestationObject,
|
|
AttestationType: "none",
|
|
Transports: response.Transports,
|
|
CreatedAt: time.Now(),
|
|
LastUsedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
|
|
// BeginAuthentication creates authentication options for passkey login
|
|
func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, username string) (*PasskeyAuthenticationOptions, error) {
|
|
// Generate challenge
|
|
challenge := make([]byte, 32)
|
|
if _, err := rand.Read(challenge); err != nil {
|
|
return nil, fmt.Errorf("failed to generate challenge: %w", err)
|
|
}
|
|
|
|
// If username is provided, get user's credentials
|
|
var allowCredentials []PasskeyCredentialDescriptor
|
|
if username != "" {
|
|
_, refs, err := p.store().ByUsername(ctx, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
creds := refs
|
|
|
|
allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds))
|
|
for _, cred := range creds {
|
|
credID, err := base64.StdEncoding.DecodeString(cred.CredentialID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
allowCredentials = append(allowCredentials, PasskeyCredentialDescriptor{
|
|
Type: "public-key",
|
|
ID: credID,
|
|
Transports: cred.Transports,
|
|
})
|
|
}
|
|
}
|
|
|
|
return &PasskeyAuthenticationOptions{
|
|
Challenge: challenge,
|
|
Timeout: p.timeout,
|
|
RelyingPartyID: p.rpID,
|
|
AllowCredentials: allowCredentials,
|
|
UserVerification: "preferred",
|
|
}, nil
|
|
}
|
|
|
|
// CompleteAuthentication verifies a passkey assertion and returns the user ID
|
|
// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library
|
|
// like github.com/go-webauthn/webauthn to properly verify the assertion signature.
|
|
func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, response PasskeyAuthenticationResponse, expectedChallenge []byte) (int, error) {
|
|
// TODO: Implement full WebAuthn verification
|
|
// 1. Verify clientDataJSON contains correct challenge and origin
|
|
// 2. Verify authenticatorData
|
|
// 3. Verify signature using stored public key
|
|
// 4. Update sign counter and check for cloning
|
|
|
|
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
|
|
|
|
// TODO: Verify signature here
|
|
// For now, we'll just update the counter as a placeholder
|
|
store := p.store()
|
|
userID, signCount, err := store.Get(ctx, credIDB64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Update counter (in production, this should be done after successful verification)
|
|
cloneWarning, err := store.UpdateCounter(ctx, credIDB64, signCount+1)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to update counter: %w", err)
|
|
}
|
|
if cloneWarning {
|
|
return 0, fmt.Errorf("credential cloning detected")
|
|
}
|
|
|
|
return userID, nil
|
|
}
|
|
|
|
// GetCredentials returns all passkey credentials for a user
|
|
func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
|
return p.store().List(ctx, userID)
|
|
}
|
|
|
|
// DeleteCredential removes a passkey credential
|
|
func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID int, credentialID string) error {
|
|
_, err := base64.StdEncoding.DecodeString(credentialID)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid credential ID: %w", err)
|
|
}
|
|
|
|
return p.store().Delete(ctx, userID, credentialID)
|
|
}
|
|
|
|
// UpdateCredentialName updates the friendly name of a credential
|
|
func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error {
|
|
_, err := base64.StdEncoding.DecodeString(credentialID)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid credential ID: %w", err)
|
|
}
|
|
|
|
return p.store().Rename(ctx, userID, credentialID, name)
|
|
}
|