mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-02-01 07:24:25 +00:00
feat(security): ✨ add database-backed passkey provider
- Implement DatabasePasskeyProvider for WebAuthn/FIDO2 authentication. - Add methods for registration, authentication, and credential management. - Create unit tests for passkey provider functionalities. - Enhance DatabaseAuthenticator to support passkey authentication.
This commit is contained in:
208
pkg/security/PASSKEY_QUICK_REFERENCE.md
Normal file
208
pkg/security/PASSKEY_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Passkey Authentication Quick Reference
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Passkey authentication (WebAuthn/FIDO2) is now integrated into the DatabaseAuthenticator. This provides passwordless authentication using biometrics, security keys, or device credentials.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
Run the passkey SQL schema (in database_schema.sql):
|
||||||
|
- Creates `user_passkey_credentials` table
|
||||||
|
- Adds stored procedures for passkey operations
|
||||||
|
|
||||||
|
### Go Code
|
||||||
|
```go
|
||||||
|
// Create passkey provider
|
||||||
|
passkeyProvider := security.NewDatabasePasskeyProvider(db,
|
||||||
|
security.DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
RPOrigin: "https://example.com",
|
||||||
|
Timeout: 60000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create authenticator with passkey support
|
||||||
|
auth := security.NewDatabaseAuthenticatorWithOptions(db,
|
||||||
|
security.DatabaseAuthenticatorOptions{
|
||||||
|
PasskeyProvider: passkeyProvider,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or add passkey to existing authenticator
|
||||||
|
auth = security.NewDatabaseAuthenticator(db).WithPasskey(passkeyProvider)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registration Flow
|
||||||
|
|
||||||
|
### Backend - Step 1: Begin Registration
|
||||||
|
```go
|
||||||
|
options, err := auth.BeginPasskeyRegistration(ctx,
|
||||||
|
security.PasskeyBeginRegistrationRequest{
|
||||||
|
UserID: 1,
|
||||||
|
Username: "alice",
|
||||||
|
DisplayName: "Alice Smith",
|
||||||
|
})
|
||||||
|
// Send options to client as JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend - Step 2: Create Credential
|
||||||
|
```javascript
|
||||||
|
// Convert options from server
|
||||||
|
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||||
|
options.user.id = base64ToArrayBuffer(options.user.id);
|
||||||
|
|
||||||
|
// Create credential
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: options
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send credential back to server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend - Step 3: Complete Registration
|
||||||
|
```go
|
||||||
|
credential, err := auth.CompletePasskeyRegistration(ctx,
|
||||||
|
security.PasskeyRegisterRequest{
|
||||||
|
UserID: 1,
|
||||||
|
Response: clientResponse,
|
||||||
|
ExpectedChallenge: storedChallenge,
|
||||||
|
CredentialName: "My iPhone",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
### Backend - Step 1: Begin Authentication
|
||||||
|
```go
|
||||||
|
options, err := auth.BeginPasskeyAuthentication(ctx,
|
||||||
|
security.PasskeyBeginAuthenticationRequest{
|
||||||
|
Username: "alice", // Optional for resident key
|
||||||
|
})
|
||||||
|
// Send options to client as JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend - Step 2: Get Credential
|
||||||
|
```javascript
|
||||||
|
// Convert options from server
|
||||||
|
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||||
|
|
||||||
|
// Get credential
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: options
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send assertion back to server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend - Step 3: Complete Authentication
|
||||||
|
```go
|
||||||
|
loginResponse, err := auth.LoginWithPasskey(ctx,
|
||||||
|
security.PasskeyLoginRequest{
|
||||||
|
Response: clientAssertion,
|
||||||
|
ExpectedChallenge: storedChallenge,
|
||||||
|
Claims: map[string]any{
|
||||||
|
"ip_address": "192.168.1.1",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Returns session token and user info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credential Management
|
||||||
|
|
||||||
|
### List Credentials
|
||||||
|
```go
|
||||||
|
credentials, err := auth.GetPasskeyCredentials(ctx, userID)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Credential Name
|
||||||
|
```go
|
||||||
|
err := auth.UpdatePasskeyCredentialName(ctx, userID, credentialID, "New Name")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Credential
|
||||||
|
```go
|
||||||
|
err := auth.DeletePasskeyCredential(ctx, userID, credentialID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP Endpoints Example
|
||||||
|
|
||||||
|
### POST /api/passkey/register/begin
|
||||||
|
Request: `{user_id, username, display_name}`
|
||||||
|
Response: PasskeyRegistrationOptions
|
||||||
|
|
||||||
|
### POST /api/passkey/register/complete
|
||||||
|
Request: `{user_id, response, credential_name}`
|
||||||
|
Response: PasskeyCredential
|
||||||
|
|
||||||
|
### POST /api/passkey/login/begin
|
||||||
|
Request: `{username}` (optional)
|
||||||
|
Response: PasskeyAuthenticationOptions
|
||||||
|
|
||||||
|
### POST /api/passkey/login/complete
|
||||||
|
Request: `{response}`
|
||||||
|
Response: LoginResponse with session token
|
||||||
|
|
||||||
|
### GET /api/passkey/credentials
|
||||||
|
Response: Array of PasskeyCredential
|
||||||
|
|
||||||
|
### DELETE /api/passkey/credentials/{id}
|
||||||
|
Request: `{credential_id}`
|
||||||
|
Response: 204 No Content
|
||||||
|
|
||||||
|
## Database Stored Procedures
|
||||||
|
|
||||||
|
- `resolvespec_passkey_store_credential` - Store new credential
|
||||||
|
- `resolvespec_passkey_get_credential` - Get credential by ID
|
||||||
|
- `resolvespec_passkey_get_user_credentials` - Get all user credentials
|
||||||
|
- `resolvespec_passkey_update_counter` - Update sign counter (clone detection)
|
||||||
|
- `resolvespec_passkey_delete_credential` - Delete credential
|
||||||
|
- `resolvespec_passkey_update_name` - Update credential name
|
||||||
|
- `resolvespec_passkey_get_credentials_by_username` - Get credentials for login
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- **Clone Detection**: Sign counter validation detects credential cloning
|
||||||
|
- **Attestation Support**: Stores attestation type (none, indirect, direct)
|
||||||
|
- **Transport Options**: Tracks authenticator transports (usb, nfc, ble, internal)
|
||||||
|
- **Backup State**: Tracks if credential is backed up/synced
|
||||||
|
- **User Verification**: Supports preferred/required user verification
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **WebAuthn Library**: Current implementation is simplified. For production, use a proper WebAuthn library like `github.com/go-webauthn/webauthn` for full verification.
|
||||||
|
|
||||||
|
2. **Challenge Storage**: Store challenges securely in session/cache. Never expose challenges to client beyond initial request.
|
||||||
|
|
||||||
|
3. **HTTPS Required**: Passkeys only work over HTTPS (except localhost).
|
||||||
|
|
||||||
|
4. **Browser Support**: Check browser compatibility for WebAuthn API.
|
||||||
|
|
||||||
|
5. **Relying Party ID**: Must match your domain exactly.
|
||||||
|
|
||||||
|
## Client-Side Helper Functions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function base64ToArrayBuffer(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests: `go test -v ./pkg/security -run Passkey`
|
||||||
|
|
||||||
|
All passkey functionality includes comprehensive tests using sqlmock.
|
||||||
@@ -1075,3 +1075,325 @@ $$ LANGUAGE plpgsql;
|
|||||||
|
|
||||||
-- Validate backup code
|
-- Validate backup code
|
||||||
-- SELECT * FROM resolvespec_totp_validate_backup_code(1, 'abc123');
|
-- SELECT * FROM resolvespec_totp_validate_backup_code(1, 'abc123');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Passkey/WebAuthn Credentials Table
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Passkey credentials table for WebAuthn/FIDO2 authentication
|
||||||
|
CREATE TABLE IF NOT EXISTS user_passkey_credentials (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
credential_id BYTEA NOT NULL UNIQUE, -- Raw credential ID from authenticator
|
||||||
|
public_key BYTEA NOT NULL, -- COSE public key
|
||||||
|
attestation_type VARCHAR(50) DEFAULT 'none', -- none, indirect, direct
|
||||||
|
aaguid BYTEA, -- Authenticator AAGUID
|
||||||
|
sign_count INTEGER DEFAULT 0, -- Signature counter for clone detection
|
||||||
|
clone_warning BOOLEAN DEFAULT false, -- True if cloning detected
|
||||||
|
transports TEXT[], -- Array of transports: usb, nfc, ble, internal
|
||||||
|
backup_eligible BOOLEAN DEFAULT false, -- Credential can be backed up
|
||||||
|
backup_state BOOLEAN DEFAULT false, -- Credential is currently backed up
|
||||||
|
name VARCHAR(255), -- User-friendly name for the credential
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON user_passkey_credentials(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON user_passkey_credentials(credential_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Stored Procedures for Passkey Authentication
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. resolvespec_passkey_store_credential - Store a new passkey credential
|
||||||
|
-- Input: p_credential (jsonb) {user_id: int, credential_id: bytea, public_key: bytea, attestation_type: string, aaguid: bytea, sign_count: int, transports: array, backup_eligible: bool, backup_state: bool, name: string}
|
||||||
|
-- Output: p_success (bool), p_error (text), p_credential_id (int)
|
||||||
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_store_credential(p_credential jsonb)
|
||||||
|
RETURNS TABLE(p_success boolean, p_error text, p_credential_id integer) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_credential_id INTEGER;
|
||||||
|
v_user_id INTEGER;
|
||||||
|
v_cred_id BYTEA;
|
||||||
|
v_public_key BYTEA;
|
||||||
|
v_attestation_type TEXT;
|
||||||
|
v_aaguid BYTEA;
|
||||||
|
v_sign_count INTEGER;
|
||||||
|
v_transports TEXT[];
|
||||||
|
v_backup_eligible BOOLEAN;
|
||||||
|
v_backup_state BOOLEAN;
|
||||||
|
v_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Extract credential data
|
||||||
|
v_user_id := (p_credential->>'user_id')::integer;
|
||||||
|
v_cred_id := decode(p_credential->>'credential_id', 'base64');
|
||||||
|
v_public_key := decode(p_credential->>'public_key', 'base64');
|
||||||
|
v_attestation_type := COALESCE(p_credential->>'attestation_type', 'none');
|
||||||
|
v_aaguid := decode(COALESCE(p_credential->>'aaguid', ''), 'base64');
|
||||||
|
v_sign_count := COALESCE((p_credential->>'sign_count')::integer, 0);
|
||||||
|
v_backup_eligible := COALESCE((p_credential->>'backup_eligible')::boolean, false);
|
||||||
|
v_backup_state := COALESCE((p_credential->>'backup_state')::boolean, false);
|
||||||
|
v_name := p_credential->>'name';
|
||||||
|
|
||||||
|
-- Convert transports array
|
||||||
|
IF p_credential->'transports' IS NOT NULL THEN
|
||||||
|
SELECT ARRAY(SELECT jsonb_array_elements_text(p_credential->'transports'))
|
||||||
|
INTO v_transports;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check if user exists
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN
|
||||||
|
RETURN QUERY SELECT false, 'User not found'::text, NULL::integer;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Insert credential
|
||||||
|
INSERT INTO user_passkey_credentials (
|
||||||
|
user_id, credential_id, public_key, attestation_type, aaguid,
|
||||||
|
sign_count, transports, backup_eligible, backup_state, name, created_at, last_used_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
v_user_id, v_cred_id, v_public_key, v_attestation_type, v_aaguid,
|
||||||
|
v_sign_count, v_transports, v_backup_eligible, v_backup_state, v_name, now(), now()
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_credential_id;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT true, NULL::text, v_credential_id;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN unique_violation THEN
|
||||||
|
RETURN QUERY SELECT false, 'Credential already exists'::text, NULL::integer;
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN QUERY SELECT false, SQLERRM::text, NULL::integer;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 2. resolvespec_passkey_get_credential - Get credential by credential_id
|
||||||
|
-- Input: p_credential_id (bytea)
|
||||||
|
-- Output: p_success (bool), p_error (text), p_credential (jsonb)
|
||||||
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_get_credential(p_credential_id bytea)
|
||||||
|
RETURNS TABLE(p_success boolean, p_error text, p_credential jsonb) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_credential jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'user_id', user_id,
|
||||||
|
'credential_id', encode(credential_id, 'base64'),
|
||||||
|
'public_key', encode(public_key, 'base64'),
|
||||||
|
'attestation_type', attestation_type,
|
||||||
|
'aaguid', encode(COALESCE(aaguid, ''::bytea), 'base64'),
|
||||||
|
'sign_count', sign_count,
|
||||||
|
'clone_warning', clone_warning,
|
||||||
|
'transports', COALESCE(to_jsonb(transports), '[]'::jsonb),
|
||||||
|
'backup_eligible', backup_eligible,
|
||||||
|
'backup_state', backup_state,
|
||||||
|
'name', name,
|
||||||
|
'created_at', created_at,
|
||||||
|
'last_used_at', last_used_at
|
||||||
|
)
|
||||||
|
INTO v_credential
|
||||||
|
FROM user_passkey_credentials
|
||||||
|
WHERE credential_id = p_credential_id;
|
||||||
|
|
||||||
|
IF v_credential IS NULL THEN
|
||||||
|
RETURN QUERY SELECT false, 'Credential not found'::text, NULL::jsonb;
|
||||||
|
ELSE
|
||||||
|
RETURN QUERY SELECT true, NULL::text, v_credential;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 3. resolvespec_passkey_get_user_credentials - Get all credentials for a user
|
||||||
|
-- Input: p_user_id (integer)
|
||||||
|
-- Output: p_success (bool), p_error (text), p_credentials (jsonb array)
|
||||||
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_get_user_credentials(p_user_id integer)
|
||||||
|
RETURNS TABLE(p_success boolean, p_error text, p_credentials jsonb) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_credentials jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'user_id', user_id,
|
||||||
|
'credential_id', encode(credential_id, 'base64'),
|
||||||
|
'public_key', encode(public_key, 'base64'),
|
||||||
|
'attestation_type', attestation_type,
|
||||||
|
'aaguid', encode(COALESCE(aaguid, ''::bytea), 'base64'),
|
||||||
|
'sign_count', sign_count,
|
||||||
|
'clone_warning', clone_warning,
|
||||||
|
'transports', COALESCE(to_jsonb(transports), '[]'::jsonb),
|
||||||
|
'backup_eligible', backup_eligible,
|
||||||
|
'backup_state', backup_state,
|
||||||
|
'name', name,
|
||||||
|
'created_at', created_at,
|
||||||
|
'last_used_at', last_used_at
|
||||||
|
)
|
||||||
|
), '[]'::jsonb)
|
||||||
|
INTO v_credentials
|
||||||
|
FROM user_passkey_credentials
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT true, NULL::text, v_credentials;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN QUERY SELECT false, SQLERRM::text, '[]'::jsonb;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 4. resolvespec_passkey_update_counter - Update sign counter and check for cloning
|
||||||
|
-- Input: p_credential_id (bytea), p_new_counter (integer)
|
||||||
|
-- Output: p_success (bool), p_error (text), p_clone_warning (bool)
|
||||||
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_update_counter(
|
||||||
|
p_credential_id bytea,
|
||||||
|
p_new_counter integer
|
||||||
|
)
|
||||||
|
RETURNS TABLE(p_success boolean, p_error text, p_clone_warning boolean) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_old_counter INTEGER;
|
||||||
|
v_clone_warning BOOLEAN := false;
|
||||||
|
BEGIN
|
||||||
|
-- Get current counter
|
||||||
|
SELECT sign_count INTO v_old_counter
|
||||||
|
FROM user_passkey_credentials
|
||||||
|
WHERE credential_id = p_credential_id;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN QUERY SELECT false, 'Credential not found'::text, false;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check for cloning (counter should always increase)
|
||||||
|
IF p_new_counter <= v_old_counter THEN
|
||||||
|
v_clone_warning := true;
|
||||||
|
|
||||||
|
-- Update clone warning flag
|
||||||
|
UPDATE user_passkey_credentials
|
||||||
|
SET clone_warning = true
|
||||||
|
WHERE credential_id = p_credential_id;
|
||||||
|
ELSE
|
||||||
|
-- Normal counter update
|
||||||
|
UPDATE user_passkey_credentials
|
||||||
|
SET sign_count = p_new_counter,
|
||||||
|
last_used_at = now()
|
||||||
|
WHERE credential_id = p_credential_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT true, NULL::text, v_clone_warning;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN QUERY SELECT false, SQLERRM::text, false;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 5. resolvespec_passkey_delete_credential - Delete a passkey credential
|
||||||
|
-- Input: p_user_id (integer), p_credential_id (bytea)
|
||||||
|
-- Output: p_success (bool), p_error (text)
|
||||||
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_delete_credential(
|
||||||
|
p_user_id integer,
|
||||||
|
p_credential_id bytea
|
||||||
|
)
|
||||||
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_deleted INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM user_passkey_credentials
|
||||||
|
WHERE user_id = p_user_id AND credential_id = p_credential_id;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
||||||
|
|
||||||
|
IF v_deleted = 0 THEN
|
||||||
|
RETURN QUERY SELECT false, 'Credential not found'::text;
|
||||||
|
ELSE
|
||||||
|
RETURN QUERY SELECT true, NULL::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 6. resolvespec_passkey_update_name - Update credential friendly name
|
||||||
|
-- Input: p_user_id (integer), p_credential_id (bytea), p_name (text)
|
||||||
|
-- Output: p_success (bool), p_error (text)
|
||||||
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_update_name(
|
||||||
|
p_user_id integer,
|
||||||
|
p_credential_id bytea,
|
||||||
|
p_name text
|
||||||
|
)
|
||||||
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_updated INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE user_passkey_credentials
|
||||||
|
SET name = p_name
|
||||||
|
WHERE user_id = p_user_id AND credential_id = p_credential_id;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
||||||
|
|
||||||
|
IF v_updated = 0 THEN
|
||||||
|
RETURN QUERY SELECT false, 'Credential not found'::text;
|
||||||
|
ELSE
|
||||||
|
RETURN QUERY SELECT true, NULL::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 7. resolvespec_passkey_get_credentials_by_username - Get credentials for passkey authentication
|
||||||
|
-- Input: p_username (text)
|
||||||
|
-- Output: p_success (bool), p_error (text), p_user_id (int), p_credentials (jsonb array)
|
||||||
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_get_credentials_by_username(p_username text)
|
||||||
|
RETURNS TABLE(p_success boolean, p_error text, p_user_id integer, p_credentials jsonb) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id INTEGER;
|
||||||
|
v_credentials jsonb;
|
||||||
|
BEGIN
|
||||||
|
-- Get user ID
|
||||||
|
SELECT id INTO v_user_id
|
||||||
|
FROM users
|
||||||
|
WHERE username = p_username AND is_active = true;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN QUERY SELECT false, 'User not found'::text, NULL::integer, NULL::jsonb;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get user's credentials
|
||||||
|
SELECT COALESCE(jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'credential_id', encode(credential_id, 'base64'),
|
||||||
|
'transports', COALESCE(to_jsonb(transports), '[]'::jsonb)
|
||||||
|
)
|
||||||
|
), '[]'::jsonb)
|
||||||
|
INTO v_credentials
|
||||||
|
FROM user_passkey_credentials
|
||||||
|
WHERE user_id = v_user_id;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT true, NULL::text, v_user_id, v_credentials;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN QUERY SELECT false, SQLERRM::text, NULL::integer, NULL::jsonb;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Example: Test Passkey stored procedures
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Store credential
|
||||||
|
-- SELECT * FROM resolvespec_passkey_store_credential('{"user_id": 1, "credential_id": "YWJjZGVmMTIzNDU2", "public_key": "MIIBIjAN...", "attestation_type": "none", "sign_count": 0, "transports": ["internal"], "backup_eligible": true, "backup_state": false, "name": "My Phone"}'::jsonb);
|
||||||
|
|
||||||
|
-- Get credential
|
||||||
|
-- SELECT * FROM resolvespec_passkey_get_credential(decode('YWJjZGVmMTIzNDU2', 'base64'));
|
||||||
|
|
||||||
|
-- Get user credentials
|
||||||
|
-- SELECT * FROM resolvespec_passkey_get_user_credentials(1);
|
||||||
|
|
||||||
|
-- Update counter
|
||||||
|
-- SELECT * FROM resolvespec_passkey_update_counter(decode('YWJjZGVmMTIzNDU2', 'base64'), 1);
|
||||||
|
|
||||||
|
-- Delete credential
|
||||||
|
-- SELECT * FROM resolvespec_passkey_delete_credential(1, decode('YWJjZGVmMTIzNDU2', 'base64'));
|
||||||
|
|
||||||
|
-- Update name
|
||||||
|
-- SELECT * FROM resolvespec_passkey_update_name(1, decode('YWJjZGVmMTIzNDU2', 'base64'), 'New Name');
|
||||||
|
|
||||||
|
-- Get credentials by username
|
||||||
|
-- SELECT * FROM resolvespec_passkey_get_credentials_by_username('admin');
|
||||||
|
|||||||
185
pkg/security/passkey.go
Normal file
185
pkg/security/passkey.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasskeyCredential represents a stored WebAuthn/FIDO2 credential
|
||||||
|
type PasskeyCredential struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
CredentialID []byte `json:"credential_id"` // Raw credential ID from authenticator
|
||||||
|
PublicKey []byte `json:"public_key"` // COSE public key
|
||||||
|
AttestationType string `json:"attestation_type"` // none, indirect, direct
|
||||||
|
AAGUID []byte `json:"aaguid"` // Authenticator AAGUID
|
||||||
|
SignCount uint32 `json:"sign_count"` // Signature counter
|
||||||
|
CloneWarning bool `json:"clone_warning"` // True if cloning detected
|
||||||
|
Transports []string `json:"transports,omitempty"` // usb, nfc, ble, internal
|
||||||
|
BackupEligible bool `json:"backup_eligible"` // Credential can be backed up
|
||||||
|
BackupState bool `json:"backup_state"` // Credential is currently backed up
|
||||||
|
Name string `json:"name,omitempty"` // User-friendly name
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt time.Time `json:"last_used_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRegistrationOptions contains options for beginning passkey registration
|
||||||
|
type PasskeyRegistrationOptions struct {
|
||||||
|
Challenge []byte `json:"challenge"`
|
||||||
|
RelyingParty PasskeyRelyingParty `json:"rp"`
|
||||||
|
User PasskeyUser `json:"user"`
|
||||||
|
PubKeyCredParams []PasskeyCredentialParam `json:"pubKeyCredParams"`
|
||||||
|
Timeout int64 `json:"timeout,omitempty"` // Milliseconds
|
||||||
|
ExcludeCredentials []PasskeyCredentialDescriptor `json:"excludeCredentials,omitempty"`
|
||||||
|
AuthenticatorSelection *PasskeyAuthenticatorSelection `json:"authenticatorSelection,omitempty"`
|
||||||
|
Attestation string `json:"attestation,omitempty"` // none, indirect, direct, enterprise
|
||||||
|
Extensions map[string]any `json:"extensions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyAuthenticationOptions contains options for beginning passkey authentication
|
||||||
|
type PasskeyAuthenticationOptions struct {
|
||||||
|
Challenge []byte `json:"challenge"`
|
||||||
|
Timeout int64 `json:"timeout,omitempty"`
|
||||||
|
RelyingPartyID string `json:"rpId,omitempty"`
|
||||||
|
AllowCredentials []PasskeyCredentialDescriptor `json:"allowCredentials,omitempty"`
|
||||||
|
UserVerification string `json:"userVerification,omitempty"` // required, preferred, discouraged
|
||||||
|
Extensions map[string]any `json:"extensions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRelyingParty identifies the relying party
|
||||||
|
type PasskeyRelyingParty struct {
|
||||||
|
ID string `json:"id"` // Domain (e.g., "example.com")
|
||||||
|
Name string `json:"name"` // Display name
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyUser identifies the user
|
||||||
|
type PasskeyUser struct {
|
||||||
|
ID []byte `json:"id"` // User handle (unique, persistent)
|
||||||
|
Name string `json:"name"` // Username
|
||||||
|
DisplayName string `json:"displayName"` // Display name
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyCredentialParam specifies supported public key algorithm
|
||||||
|
type PasskeyCredentialParam struct {
|
||||||
|
Type string `json:"type"` // "public-key"
|
||||||
|
Alg int `json:"alg"` // COSE algorithm identifier (e.g., -7 for ES256, -257 for RS256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyCredentialDescriptor describes a credential
|
||||||
|
type PasskeyCredentialDescriptor struct {
|
||||||
|
Type string `json:"type"` // "public-key"
|
||||||
|
ID []byte `json:"id"` // Credential ID
|
||||||
|
Transports []string `json:"transports,omitempty"` // usb, nfc, ble, internal
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyAuthenticatorSelection specifies authenticator requirements
|
||||||
|
type PasskeyAuthenticatorSelection struct {
|
||||||
|
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"` // platform, cross-platform
|
||||||
|
RequireResidentKey bool `json:"requireResidentKey,omitempty"`
|
||||||
|
ResidentKey string `json:"residentKey,omitempty"` // discouraged, preferred, required
|
||||||
|
UserVerification string `json:"userVerification,omitempty"` // required, preferred, discouraged
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRegistrationResponse contains the client's registration response
|
||||||
|
type PasskeyRegistrationResponse struct {
|
||||||
|
ID string `json:"id"` // Base64URL encoded credential ID
|
||||||
|
RawID []byte `json:"rawId"` // Raw credential ID
|
||||||
|
Type string `json:"type"` // "public-key"
|
||||||
|
Response PasskeyAuthenticatorAttestationResponse `json:"response"`
|
||||||
|
ClientExtensionResults map[string]any `json:"clientExtensionResults,omitempty"`
|
||||||
|
Transports []string `json:"transports,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyAuthenticatorAttestationResponse contains attestation data
|
||||||
|
type PasskeyAuthenticatorAttestationResponse struct {
|
||||||
|
ClientDataJSON []byte `json:"clientDataJSON"`
|
||||||
|
AttestationObject []byte `json:"attestationObject"`
|
||||||
|
Transports []string `json:"transports,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyAuthenticationResponse contains the client's authentication response
|
||||||
|
type PasskeyAuthenticationResponse struct {
|
||||||
|
ID string `json:"id"` // Base64URL encoded credential ID
|
||||||
|
RawID []byte `json:"rawId"` // Raw credential ID
|
||||||
|
Type string `json:"type"` // "public-key"
|
||||||
|
Response PasskeyAuthenticatorAssertionResponse `json:"response"`
|
||||||
|
ClientExtensionResults map[string]any `json:"clientExtensionResults,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyAuthenticatorAssertionResponse contains assertion data
|
||||||
|
type PasskeyAuthenticatorAssertionResponse struct {
|
||||||
|
ClientDataJSON []byte `json:"clientDataJSON"`
|
||||||
|
AuthenticatorData []byte `json:"authenticatorData"`
|
||||||
|
Signature []byte `json:"signature"`
|
||||||
|
UserHandle []byte `json:"userHandle,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyProvider handles passkey registration and authentication
|
||||||
|
type PasskeyProvider interface {
|
||||||
|
// BeginRegistration creates registration options for a new passkey
|
||||||
|
BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error)
|
||||||
|
|
||||||
|
// CompleteRegistration verifies and stores a new passkey credential
|
||||||
|
CompleteRegistration(ctx context.Context, userID int, response PasskeyRegistrationResponse, expectedChallenge []byte) (*PasskeyCredential, error)
|
||||||
|
|
||||||
|
// BeginAuthentication creates authentication options for passkey login
|
||||||
|
BeginAuthentication(ctx context.Context, username string) (*PasskeyAuthenticationOptions, error)
|
||||||
|
|
||||||
|
// CompleteAuthentication verifies a passkey assertion and returns the user
|
||||||
|
CompleteAuthentication(ctx context.Context, response PasskeyAuthenticationResponse, expectedChallenge []byte) (int, error)
|
||||||
|
|
||||||
|
// GetCredentials returns all passkey credentials for a user
|
||||||
|
GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error)
|
||||||
|
|
||||||
|
// DeleteCredential removes a passkey credential
|
||||||
|
DeleteCredential(ctx context.Context, userID int, credentialID string) error
|
||||||
|
|
||||||
|
// UpdateCredentialName updates the friendly name of a credential
|
||||||
|
UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyLoginRequest contains passkey authentication data
|
||||||
|
type PasskeyLoginRequest struct {
|
||||||
|
Response PasskeyAuthenticationResponse `json:"response"`
|
||||||
|
ExpectedChallenge []byte `json:"expected_challenge"`
|
||||||
|
Claims map[string]any `json:"claims"` // Additional login data
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRegisterRequest contains passkey registration data
|
||||||
|
type PasskeyRegisterRequest struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Response PasskeyRegistrationResponse `json:"response"`
|
||||||
|
ExpectedChallenge []byte `json:"expected_challenge"`
|
||||||
|
CredentialName string `json:"credential_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyBeginRegistrationRequest contains options for starting passkey registration
|
||||||
|
type PasskeyBeginRegistrationRequest struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyBeginAuthenticationRequest contains options for starting passkey authentication
|
||||||
|
type PasskeyBeginAuthenticationRequest struct {
|
||||||
|
Username string `json:"username,omitempty"` // Optional for resident key flow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePasskeyRegistrationResponse parses a JSON passkey registration response
|
||||||
|
func ParsePasskeyRegistrationResponse(data []byte) (*PasskeyRegistrationResponse, error) {
|
||||||
|
var response PasskeyRegistrationResponse
|
||||||
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePasskeyAuthenticationResponse parses a JSON passkey authentication response
|
||||||
|
func ParsePasskeyAuthenticationResponse(data []byte) (*PasskeyAuthenticationResponse, error) {
|
||||||
|
var response PasskeyAuthenticationResponse
|
||||||
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
431
pkg/security/passkey_examples.go
Normal file
431
pkg/security/passkey_examples.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasskeyAuthenticationExample demonstrates passkey (WebAuthn/FIDO2) authentication
|
||||||
|
func PasskeyAuthenticationExample() {
|
||||||
|
// Setup database connection
|
||||||
|
db, _ := sql.Open("postgres", "postgres://user:pass@localhost/db")
|
||||||
|
|
||||||
|
// Create passkey provider
|
||||||
|
passkeyProvider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com", // Your domain
|
||||||
|
RPName: "Example Application", // Display name
|
||||||
|
RPOrigin: "https://example.com", // Expected origin
|
||||||
|
Timeout: 60000, // 60 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create authenticator with passkey support
|
||||||
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
|
||||||
|
PasskeyProvider: passkeyProvider,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or use WithPasskey method
|
||||||
|
auth = NewDatabaseAuthenticator(db).WithPasskey(passkeyProvider)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// === REGISTRATION FLOW ===
|
||||||
|
|
||||||
|
// Step 1: Begin registration
|
||||||
|
regOptions, _ := auth.BeginPasskeyRegistration(ctx, PasskeyBeginRegistrationRequest{
|
||||||
|
UserID: 1,
|
||||||
|
Username: "alice",
|
||||||
|
DisplayName: "Alice Smith",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send regOptions to client as JSON
|
||||||
|
// Client will call navigator.credentials.create() with these options
|
||||||
|
_ = regOptions
|
||||||
|
|
||||||
|
// Step 2: Complete registration (after client returns credential)
|
||||||
|
// This would come from the client's navigator.credentials.create() response
|
||||||
|
clientResponse := PasskeyRegistrationResponse{
|
||||||
|
ID: "base64-credential-id",
|
||||||
|
RawID: []byte("raw-credential-id"),
|
||||||
|
Type: "public-key",
|
||||||
|
Response: PasskeyAuthenticatorAttestationResponse{
|
||||||
|
ClientDataJSON: []byte("..."),
|
||||||
|
AttestationObject: []byte("..."),
|
||||||
|
},
|
||||||
|
Transports: []string{"internal"},
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, _ := auth.CompletePasskeyRegistration(ctx, PasskeyRegisterRequest{
|
||||||
|
UserID: 1,
|
||||||
|
Response: clientResponse,
|
||||||
|
ExpectedChallenge: regOptions.Challenge,
|
||||||
|
CredentialName: "My iPhone",
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("Registered credential: %s\n", credential.ID)
|
||||||
|
|
||||||
|
// === AUTHENTICATION FLOW ===
|
||||||
|
|
||||||
|
// Step 1: Begin authentication
|
||||||
|
authOptions, _ := auth.BeginPasskeyAuthentication(ctx, PasskeyBeginAuthenticationRequest{
|
||||||
|
Username: "alice", // Optional - omit for resident key flow
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send authOptions to client as JSON
|
||||||
|
// Client will call navigator.credentials.get() with these options
|
||||||
|
_ = authOptions
|
||||||
|
|
||||||
|
// Step 2: Complete authentication (after client returns assertion)
|
||||||
|
// This would come from the client's navigator.credentials.get() response
|
||||||
|
clientAssertion := PasskeyAuthenticationResponse{
|
||||||
|
ID: "base64-credential-id",
|
||||||
|
RawID: []byte("raw-credential-id"),
|
||||||
|
Type: "public-key",
|
||||||
|
Response: PasskeyAuthenticatorAssertionResponse{
|
||||||
|
ClientDataJSON: []byte("..."),
|
||||||
|
AuthenticatorData: []byte("..."),
|
||||||
|
Signature: []byte("..."),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResponse, _ := auth.LoginWithPasskey(ctx, PasskeyLoginRequest{
|
||||||
|
Response: clientAssertion,
|
||||||
|
ExpectedChallenge: authOptions.Challenge,
|
||||||
|
Claims: map[string]any{
|
||||||
|
"ip_address": "192.168.1.1",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("Logged in user: %s with token: %s\n",
|
||||||
|
loginResponse.User.UserName, loginResponse.Token)
|
||||||
|
|
||||||
|
// === CREDENTIAL MANAGEMENT ===
|
||||||
|
|
||||||
|
// Get all credentials for a user
|
||||||
|
credentials, _ := auth.GetPasskeyCredentials(ctx, 1)
|
||||||
|
for _, cred := range credentials {
|
||||||
|
fmt.Printf("Credential: %s (created: %s, last used: %s)\n",
|
||||||
|
cred.Name, cred.CreatedAt, cred.LastUsedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update credential name
|
||||||
|
_ = auth.UpdatePasskeyCredentialName(ctx, 1, credential.ID, "My New iPhone")
|
||||||
|
|
||||||
|
// Delete credential
|
||||||
|
_ = auth.DeletePasskeyCredential(ctx, 1, credential.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyHTTPHandlersExample shows HTTP handlers for passkey authentication
|
||||||
|
func PasskeyHTTPHandlersExample(auth *DatabaseAuthenticator) {
|
||||||
|
// Store challenges in session/cache in production
|
||||||
|
challenges := make(map[string][]byte)
|
||||||
|
|
||||||
|
// Begin registration endpoint
|
||||||
|
http.HandleFunc("/api/passkey/register/begin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
options, err := auth.BeginPasskeyRegistration(r.Context(), PasskeyBeginRegistrationRequest{
|
||||||
|
UserID: req.UserID,
|
||||||
|
Username: req.Username,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store challenge for verification (use session ID as key in production)
|
||||||
|
sessionID := "session-123"
|
||||||
|
challenges[sessionID] = options.Challenge
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Complete registration endpoint
|
||||||
|
http.HandleFunc("/api/passkey/register/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Response PasskeyRegistrationResponse `json:"response"`
|
||||||
|
CredentialName string `json:"credential_name"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
// Get stored challenge (from session in production)
|
||||||
|
sessionID := "session-123"
|
||||||
|
challenge := challenges[sessionID]
|
||||||
|
delete(challenges, sessionID)
|
||||||
|
|
||||||
|
credential, err := auth.CompletePasskeyRegistration(r.Context(), PasskeyRegisterRequest{
|
||||||
|
UserID: req.UserID,
|
||||||
|
Response: req.Response,
|
||||||
|
ExpectedChallenge: challenge,
|
||||||
|
CredentialName: req.CredentialName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(credential)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Begin authentication endpoint
|
||||||
|
http.HandleFunc("/api/passkey/login/begin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"` // Optional
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
options, err := auth.BeginPasskeyAuthentication(r.Context(), PasskeyBeginAuthenticationRequest{
|
||||||
|
Username: req.Username,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store challenge for verification (use session ID as key in production)
|
||||||
|
sessionID := "session-456"
|
||||||
|
challenges[sessionID] = options.Challenge
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Complete authentication endpoint
|
||||||
|
http.HandleFunc("/api/passkey/login/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Response PasskeyAuthenticationResponse `json:"response"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
// Get stored challenge (from session in production)
|
||||||
|
sessionID := "session-456"
|
||||||
|
challenge := challenges[sessionID]
|
||||||
|
delete(challenges, sessionID)
|
||||||
|
|
||||||
|
loginResponse, err := auth.LoginWithPasskey(r.Context(), PasskeyLoginRequest{
|
||||||
|
Response: req.Response,
|
||||||
|
ExpectedChallenge: challenge,
|
||||||
|
Claims: map[string]any{
|
||||||
|
"ip_address": r.RemoteAddr,
|
||||||
|
"user_agent": r.UserAgent(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session cookie
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session_token",
|
||||||
|
Value: loginResponse.Token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(loginResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
// List credentials endpoint
|
||||||
|
http.HandleFunc("/api/passkey/credentials", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get user from authenticated session
|
||||||
|
userCtx, err := auth.Authenticate(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials, err := auth.GetPasskeyCredentials(r.Context(), userCtx.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(credentials)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete credential endpoint
|
||||||
|
http.HandleFunc("/api/passkey/credentials/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userCtx, err := auth.Authenticate(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
CredentialID string `json:"credential_id"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
err = auth.DeletePasskeyCredential(r.Context(), userCtx.UserID, req.CredentialID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyClientSideExample shows the client-side JavaScript code needed
|
||||||
|
func PasskeyClientSideExample() string {
|
||||||
|
return `
|
||||||
|
// === CLIENT-SIDE JAVASCRIPT FOR PASSKEY AUTHENTICATION ===
|
||||||
|
|
||||||
|
// Helper function to convert base64 to ArrayBuffer
|
||||||
|
function base64ToArrayBuffer(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert ArrayBuffer to base64
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === REGISTRATION ===
|
||||||
|
|
||||||
|
async function registerPasskey(userId, username, displayName) {
|
||||||
|
// Step 1: Get registration options from server
|
||||||
|
const optionsResponse = await fetch('/api/passkey/register/begin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: userId, username, display_name: displayName })
|
||||||
|
});
|
||||||
|
const options = await optionsResponse.json();
|
||||||
|
|
||||||
|
// Convert base64 strings to ArrayBuffers
|
||||||
|
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||||
|
options.user.id = base64ToArrayBuffer(options.user.id);
|
||||||
|
if (options.excludeCredentials) {
|
||||||
|
options.excludeCredentials = options.excludeCredentials.map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: base64ToArrayBuffer(cred.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create credential using WebAuthn API
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: options
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Send credential to server
|
||||||
|
const credentialResponse = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: arrayBufferToBase64(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||||
|
attestationObject: arrayBufferToBase64(credential.response.attestationObject)
|
||||||
|
},
|
||||||
|
transports: credential.response.getTransports ? credential.response.getTransports() : []
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeResponse = await fetch('/api/passkey/register/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: userId,
|
||||||
|
response: credentialResponse,
|
||||||
|
credential_name: 'My Device'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return await completeResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AUTHENTICATION ===
|
||||||
|
|
||||||
|
async function loginWithPasskey(username) {
|
||||||
|
// Step 1: Get authentication options from server
|
||||||
|
const optionsResponse = await fetch('/api/passkey/login/begin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username })
|
||||||
|
});
|
||||||
|
const options = await optionsResponse.json();
|
||||||
|
|
||||||
|
// Convert base64 strings to ArrayBuffers
|
||||||
|
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||||
|
if (options.allowCredentials) {
|
||||||
|
options.allowCredentials = options.allowCredentials.map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: base64ToArrayBuffer(cred.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Get credential using WebAuthn API
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: options
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Send assertion to server
|
||||||
|
const assertionResponse = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: arrayBufferToBase64(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||||
|
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
|
||||||
|
signature: arrayBufferToBase64(credential.response.signature),
|
||||||
|
userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginResponse = await fetch('/api/passkey/login/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ response: assertionResponse })
|
||||||
|
});
|
||||||
|
|
||||||
|
return await loginResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === USAGE ===
|
||||||
|
|
||||||
|
// Register a new passkey
|
||||||
|
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const result = await registerPasskey(1, 'alice', 'Alice Smith');
|
||||||
|
console.log('Passkey registered:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login with passkey
|
||||||
|
document.getElementById('login-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const result = await loginWithPasskey('alice');
|
||||||
|
console.log('Logged in:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`
|
||||||
|
}
|
||||||
404
pkg/security/passkey_provider.go
Normal file
404
pkg/security/passkey_provider.go
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DatabasePasskeyProvider implements PasskeyProvider using database storage
|
||||||
|
type DatabasePasskeyProvider struct {
|
||||||
|
db *sql.DB
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DatabasePasskeyProvider{
|
||||||
|
db: db,
|
||||||
|
rpID: opts.RPID,
|
||||||
|
rpName: opts.RPName,
|
||||||
|
rpOrigin: opts.RPOrigin,
|
||||||
|
timeout: opts.Timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 _, cred := range credentials {
|
||||||
|
excludeCredentials = append(excludeCredentials, PasskeyCredentialDescriptor{
|
||||||
|
Type: "public-key",
|
||||||
|
ID: cred.CredentialID,
|
||||||
|
Transports: cred.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
|
||||||
|
|
||||||
|
credData := map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"credential_id": base64.StdEncoding.EncodeToString(response.RawID),
|
||||||
|
"public_key": base64.StdEncoding.EncodeToString(response.Response.AttestationObject),
|
||||||
|
"attestation_type": "none",
|
||||||
|
"sign_count": 0,
|
||||||
|
"transports": response.Transports,
|
||||||
|
"backup_eligible": false,
|
||||||
|
"backup_state": false,
|
||||||
|
"name": "Passkey",
|
||||||
|
}
|
||||||
|
|
||||||
|
credJSON, err := json.Marshal(credData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal credential data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var success bool
|
||||||
|
var errorMsg sql.NullString
|
||||||
|
var credentialID sql.NullInt64
|
||||||
|
|
||||||
|
query := `SELECT p_success, p_error, p_credential_id FROM resolvespec_passkey_store_credential($1::jsonb)`
|
||||||
|
err = p.db.QueryRowContext(ctx, query, string(credJSON)).Scan(&success, &errorMsg, &credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to store credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
if errorMsg.Valid {
|
||||||
|
return nil, fmt.Errorf("%s", errorMsg.String)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to store credential")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PasskeyCredential{
|
||||||
|
ID: fmt.Sprintf("%d", credentialID.Int64),
|
||||||
|
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 != "" {
|
||||||
|
var success bool
|
||||||
|
var errorMsg sql.NullString
|
||||||
|
var userID sql.NullInt64
|
||||||
|
var credentialsJSON sql.NullString
|
||||||
|
|
||||||
|
query := `SELECT p_success, p_error, p_user_id, p_credentials::text FROM resolvespec_passkey_get_credentials_by_username($1)`
|
||||||
|
err := p.db.QueryRowContext(ctx, query, username).Scan(&success, &errorMsg, &userID, &credentialsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
if errorMsg.Valid {
|
||||||
|
return nil, fmt.Errorf("%s", errorMsg.String)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse credentials
|
||||||
|
var creds []struct {
|
||||||
|
ID string `json:"credential_id"`
|
||||||
|
Transports []string `json:"transports"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds))
|
||||||
|
for _, cred := range creds {
|
||||||
|
credID, err := base64.StdEncoding.DecodeString(cred.ID)
|
||||||
|
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
|
||||||
|
|
||||||
|
// Get credential from database
|
||||||
|
var success bool
|
||||||
|
var errorMsg sql.NullString
|
||||||
|
var credentialJSON sql.NullString
|
||||||
|
|
||||||
|
query := `SELECT p_success, p_error, p_credential::text FROM resolvespec_passkey_get_credential($1)`
|
||||||
|
err := p.db.QueryRowContext(ctx, query, response.RawID).Scan(&success, &errorMsg, &credentialJSON)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
if errorMsg.Valid {
|
||||||
|
return 0, fmt.Errorf("%s", errorMsg.String)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("credential not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse credential
|
||||||
|
var cred struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
SignCount uint32 `json:"sign_count"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(credentialJSON.String), &cred); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to parse credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Verify signature here
|
||||||
|
// For now, we'll just update the counter as a placeholder
|
||||||
|
|
||||||
|
// Update counter (in production, this should be done after successful verification)
|
||||||
|
newCounter := cred.SignCount + 1
|
||||||
|
var updateSuccess bool
|
||||||
|
var updateError sql.NullString
|
||||||
|
var cloneWarning sql.NullBool
|
||||||
|
|
||||||
|
updateQuery := `SELECT p_success, p_error, p_clone_warning FROM resolvespec_passkey_update_counter($1, $2)`
|
||||||
|
err = p.db.QueryRowContext(ctx, updateQuery, response.RawID, newCounter).Scan(&updateSuccess, &updateError, &cloneWarning)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to update counter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cloneWarning.Valid && cloneWarning.Bool {
|
||||||
|
return 0, fmt.Errorf("credential cloning detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cred.UserID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentials returns all passkey credentials for a user
|
||||||
|
func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
||||||
|
var success bool
|
||||||
|
var errorMsg sql.NullString
|
||||||
|
var credentialsJSON sql.NullString
|
||||||
|
|
||||||
|
query := `SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials($1)`
|
||||||
|
err := p.db.QueryRowContext(ctx, query, userID).Scan(&success, &errorMsg, &credentialsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
if errorMsg.Valid {
|
||||||
|
return nil, fmt.Errorf("%s", errorMsg.String)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse credentials
|
||||||
|
var rawCreds []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
CredentialID string `json:"credential_id"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
AttestationType string `json:"attestation_type"`
|
||||||
|
AAGUID string `json:"aaguid"`
|
||||||
|
SignCount uint32 `json:"sign_count"`
|
||||||
|
CloneWarning bool `json:"clone_warning"`
|
||||||
|
Transports []string `json:"transports"`
|
||||||
|
BackupEligible bool `json:"backup_eligible"`
|
||||||
|
BackupState bool `json:"backup_state"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt time.Time `json:"last_used_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(credentialsJSON.String), &rawCreds); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := make([]PasskeyCredential, 0, len(rawCreds))
|
||||||
|
for _, raw := range rawCreds {
|
||||||
|
credID, err := base64.StdEncoding.DecodeString(raw.CredentialID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pubKey, err := base64.StdEncoding.DecodeString(raw.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
aaguid, _ := base64.StdEncoding.DecodeString(raw.AAGUID)
|
||||||
|
|
||||||
|
credentials = append(credentials, PasskeyCredential{
|
||||||
|
ID: fmt.Sprintf("%d", raw.ID),
|
||||||
|
UserID: raw.UserID,
|
||||||
|
CredentialID: credID,
|
||||||
|
PublicKey: pubKey,
|
||||||
|
AttestationType: raw.AttestationType,
|
||||||
|
AAGUID: aaguid,
|
||||||
|
SignCount: raw.SignCount,
|
||||||
|
CloneWarning: raw.CloneWarning,
|
||||||
|
Transports: raw.Transports,
|
||||||
|
BackupEligible: raw.BackupEligible,
|
||||||
|
BackupState: raw.BackupState,
|
||||||
|
Name: raw.Name,
|
||||||
|
CreatedAt: raw.CreatedAt,
|
||||||
|
LastUsedAt: raw.LastUsedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentials, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCredential removes a passkey credential
|
||||||
|
func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID int, credentialID string) error {
|
||||||
|
credID, err := base64.StdEncoding.DecodeString(credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid credential ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var success bool
|
||||||
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
|
query := `SELECT p_success, p_error FROM resolvespec_passkey_delete_credential($1, $2)`
|
||||||
|
err = p.db.QueryRowContext(ctx, query, userID, credID).Scan(&success, &errorMsg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
if errorMsg.Valid {
|
||||||
|
return fmt.Errorf("%s", errorMsg.String)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to delete credential")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCredentialName updates the friendly name of a credential
|
||||||
|
func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error {
|
||||||
|
credID, err := base64.StdEncoding.DecodeString(credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid credential ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var success bool
|
||||||
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
|
query := `SELECT p_success, p_error FROM resolvespec_passkey_update_name($1, $2, $3)`
|
||||||
|
err = p.db.QueryRowContext(ctx, query, userID, credID, name).Scan(&success, &errorMsg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update credential name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
if errorMsg.Valid {
|
||||||
|
return fmt.Errorf("%s", errorMsg.String)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to update credential name")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
330
pkg/security/passkey_test.go
Normal file
330
pkg/security/passkey_test.go
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDatabasePasskeyProvider_BeginRegistration(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create mock db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
RPOrigin: "https://example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Mock get credentials query
|
||||||
|
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}).
|
||||||
|
AddRow(true, nil, "[]")
|
||||||
|
mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`).
|
||||||
|
WithArgs(1).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
opts, err := provider.BeginRegistration(ctx, 1, "testuser", "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginRegistration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.RelyingParty.ID != "example.com" {
|
||||||
|
t.Errorf("expected RP ID 'example.com', got '%s'", opts.RelyingParty.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.User.Name != "testuser" {
|
||||||
|
t.Errorf("expected username 'testuser', got '%s'", opts.User.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.Challenge) != 32 {
|
||||||
|
t.Errorf("expected challenge length 32, got %d", len(opts.Challenge))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.PubKeyCredParams) != 2 {
|
||||||
|
t.Errorf("expected 2 credential params, got %d", len(opts.PubKeyCredParams))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("unfulfilled expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabasePasskeyProvider_BeginAuthentication(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create mock db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
RPOrigin: "https://example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Mock get credentials by username query
|
||||||
|
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user_id", "p_credentials"}).
|
||||||
|
AddRow(true, nil, 1, `[{"credential_id":"YWJjZGVm","transports":["internal"]}]`)
|
||||||
|
mock.ExpectQuery(`SELECT p_success, p_error, p_user_id, p_credentials::text FROM resolvespec_passkey_get_credentials_by_username`).
|
||||||
|
WithArgs("testuser").
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
opts, err := provider.BeginAuthentication(ctx, "testuser")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginAuthentication failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.RelyingPartyID != "example.com" {
|
||||||
|
t.Errorf("expected RP ID 'example.com', got '%s'", opts.RelyingPartyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.Challenge) != 32 {
|
||||||
|
t.Errorf("expected challenge length 32, got %d", len(opts.Challenge))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.AllowCredentials) != 1 {
|
||||||
|
t.Errorf("expected 1 allowed credential, got %d", len(opts.AllowCredentials))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("unfulfilled expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabasePasskeyProvider_GetCredentials(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create mock db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
credentialsJSON := `[{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 1,
|
||||||
|
"credential_id": "YWJjZGVmMTIzNDU2",
|
||||||
|
"public_key": "cHVibGlja2V5",
|
||||||
|
"attestation_type": "none",
|
||||||
|
"aaguid": "",
|
||||||
|
"sign_count": 5,
|
||||||
|
"clone_warning": false,
|
||||||
|
"transports": ["internal"],
|
||||||
|
"backup_eligible": true,
|
||||||
|
"backup_state": false,
|
||||||
|
"name": "My Phone",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"last_used_at": "2026-01-31T00:00:00Z"
|
||||||
|
}]`
|
||||||
|
|
||||||
|
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}).
|
||||||
|
AddRow(true, nil, credentialsJSON)
|
||||||
|
mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`).
|
||||||
|
WithArgs(1).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
credentials, err := provider.GetCredentials(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCredentials failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(credentials) != 1 {
|
||||||
|
t.Fatalf("expected 1 credential, got %d", len(credentials))
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := credentials[0]
|
||||||
|
if cred.UserID != 1 {
|
||||||
|
t.Errorf("expected user ID 1, got %d", cred.UserID)
|
||||||
|
}
|
||||||
|
if cred.Name != "My Phone" {
|
||||||
|
t.Errorf("expected name 'My Phone', got '%s'", cred.Name)
|
||||||
|
}
|
||||||
|
if cred.SignCount != 5 {
|
||||||
|
t.Errorf("expected sign count 5, got %d", cred.SignCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("unfulfilled expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabasePasskeyProvider_DeleteCredential(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create mock db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows := sqlmock.NewRows([]string{"p_success", "p_error"}).
|
||||||
|
AddRow(true, nil)
|
||||||
|
mock.ExpectQuery(`SELECT p_success, p_error FROM resolvespec_passkey_delete_credential`).
|
||||||
|
WithArgs(1, sqlmock.AnyArg()).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
err = provider.DeleteCredential(ctx, 1, "YWJjZGVmMTIzNDU2")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("DeleteCredential failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("unfulfilled expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabasePasskeyProvider_UpdateCredentialName(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create mock db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows := sqlmock.NewRows([]string{"p_success", "p_error"}).
|
||||||
|
AddRow(true, nil)
|
||||||
|
mock.ExpectQuery(`SELECT p_success, p_error FROM resolvespec_passkey_update_name`).
|
||||||
|
WithArgs(1, sqlmock.AnyArg(), "New Name").
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
err = provider.UpdateCredentialName(ctx, 1, "YWJjZGVmMTIzNDU2", "New Name")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("UpdateCredentialName failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("unfulfilled expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabaseAuthenticator_PasskeyMethods(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create mock db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
passkeyProvider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
})
|
||||||
|
|
||||||
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
|
||||||
|
PasskeyProvider: passkeyProvider,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("BeginPasskeyRegistration", func(t *testing.T) {
|
||||||
|
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}).
|
||||||
|
AddRow(true, nil, "[]")
|
||||||
|
mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`).
|
||||||
|
WithArgs(1).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
opts, err := auth.BeginPasskeyRegistration(ctx, PasskeyBeginRegistrationRequest{
|
||||||
|
UserID: 1,
|
||||||
|
Username: "testuser",
|
||||||
|
DisplayName: "Test User",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("BeginPasskeyRegistration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts == nil {
|
||||||
|
t.Error("expected options, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetPasskeyCredentials", func(t *testing.T) {
|
||||||
|
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}).
|
||||||
|
AddRow(true, nil, "[]")
|
||||||
|
mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`).
|
||||||
|
WithArgs(1).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
credentials, err := auth.GetPasskeyCredentials(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetPasskeyCredentials failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if credentials == nil {
|
||||||
|
t.Error("expected credentials slice, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("unfulfilled expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabaseAuthenticator_WithoutPasskey(t *testing.T) {
|
||||||
|
db, _, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create mock db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
auth := NewDatabaseAuthenticator(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err = auth.BeginPasskeyRegistration(ctx, PasskeyBeginRegistrationRequest{
|
||||||
|
UserID: 1,
|
||||||
|
Username: "testuser",
|
||||||
|
DisplayName: "Test User",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when passkey provider not configured, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedMsg := "passkey provider not configured"
|
||||||
|
if err.Error() != expectedMsg {
|
||||||
|
t.Errorf("expected error '%s', got '%s'", expectedMsg, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyProvider_NilDB(t *testing.T) {
|
||||||
|
// This test verifies that the provider can be created with nil DB
|
||||||
|
// but operations will fail. In production, always provide a valid DB.
|
||||||
|
var db *sql.DB
|
||||||
|
provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||||
|
RPID: "example.com",
|
||||||
|
RPName: "Example App",
|
||||||
|
})
|
||||||
|
|
||||||
|
if provider == nil {
|
||||||
|
t.Error("expected provider to be created even with nil DB")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the provider has the correct configuration
|
||||||
|
if provider.rpID != "example.com" {
|
||||||
|
t.Errorf("expected RP ID 'example.com', got '%s'", provider.rpID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*UserContext, error
|
|||||||
// resolvespec_session_update, resolvespec_refresh_token
|
// resolvespec_session_update, resolvespec_refresh_token
|
||||||
// See database_schema.sql for procedure definitions
|
// See database_schema.sql for procedure definitions
|
||||||
// Also supports multiple OAuth2 providers configured with WithOAuth2()
|
// Also supports multiple OAuth2 providers configured with WithOAuth2()
|
||||||
|
// Also supports passkey authentication configured with WithPasskey()
|
||||||
type DatabaseAuthenticator struct {
|
type DatabaseAuthenticator struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
@@ -70,6 +71,9 @@ type DatabaseAuthenticator struct {
|
|||||||
// OAuth2 providers registry (multiple providers supported)
|
// OAuth2 providers registry (multiple providers supported)
|
||||||
oauth2Providers map[string]*OAuth2Provider
|
oauth2Providers map[string]*OAuth2Provider
|
||||||
oauth2ProvidersMutex sync.RWMutex
|
oauth2ProvidersMutex sync.RWMutex
|
||||||
|
|
||||||
|
// Passkey provider (optional)
|
||||||
|
passkeyProvider PasskeyProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseAuthenticatorOptions configures the database authenticator
|
// DatabaseAuthenticatorOptions configures the database authenticator
|
||||||
@@ -79,6 +83,8 @@ type DatabaseAuthenticatorOptions struct {
|
|||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
// Cache is an optional cache instance. If nil, uses the default cache
|
// Cache is an optional cache instance. If nil, uses the default cache
|
||||||
Cache *cache.Cache
|
Cache *cache.Cache
|
||||||
|
// PasskeyProvider is an optional passkey provider for WebAuthn/FIDO2 authentication
|
||||||
|
PasskeyProvider PasskeyProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDatabaseAuthenticator(db *sql.DB) *DatabaseAuthenticator {
|
func NewDatabaseAuthenticator(db *sql.DB) *DatabaseAuthenticator {
|
||||||
@@ -98,9 +104,10 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &DatabaseAuthenticator{
|
return &DatabaseAuthenticator{
|
||||||
db: db,
|
db: db,
|
||||||
cache: cacheInstance,
|
cache: cacheInstance,
|
||||||
cacheTTL: opts.CacheTTL,
|
cacheTTL: opts.CacheTTL,
|
||||||
|
passkeyProvider: opts.PasskeyProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,3 +702,135 @@ func generateRandomString(length int) string {
|
|||||||
// }
|
// }
|
||||||
// return ""
|
// return ""
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
// Passkey authentication methods
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
// WithPasskey configures the DatabaseAuthenticator with a passkey provider
|
||||||
|
func (a *DatabaseAuthenticator) WithPasskey(provider PasskeyProvider) *DatabaseAuthenticator {
|
||||||
|
a.passkeyProvider = provider
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginPasskeyRegistration initiates passkey registration for a user
|
||||||
|
func (a *DatabaseAuthenticator) BeginPasskeyRegistration(ctx context.Context, req PasskeyBeginRegistrationRequest) (*PasskeyRegistrationOptions, error) {
|
||||||
|
if a.passkeyProvider == nil {
|
||||||
|
return nil, fmt.Errorf("passkey provider not configured")
|
||||||
|
}
|
||||||
|
return a.passkeyProvider.BeginRegistration(ctx, req.UserID, req.Username, req.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletePasskeyRegistration completes passkey registration
|
||||||
|
func (a *DatabaseAuthenticator) CompletePasskeyRegistration(ctx context.Context, req PasskeyRegisterRequest) (*PasskeyCredential, error) {
|
||||||
|
if a.passkeyProvider == nil {
|
||||||
|
return nil, fmt.Errorf("passkey provider not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
cred, err := a.passkeyProvider.CompleteRegistration(ctx, req.UserID, req.Response, req.ExpectedChallenge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update credential name if provided
|
||||||
|
if req.CredentialName != "" && cred.ID != "" {
|
||||||
|
_ = a.passkeyProvider.UpdateCredentialName(ctx, req.UserID, cred.ID, req.CredentialName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cred, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginPasskeyAuthentication initiates passkey authentication
|
||||||
|
func (a *DatabaseAuthenticator) BeginPasskeyAuthentication(ctx context.Context, req PasskeyBeginAuthenticationRequest) (*PasskeyAuthenticationOptions, error) {
|
||||||
|
if a.passkeyProvider == nil {
|
||||||
|
return nil, fmt.Errorf("passkey provider not configured")
|
||||||
|
}
|
||||||
|
return a.passkeyProvider.BeginAuthentication(ctx, req.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithPasskey authenticates a user using a passkey and creates a session
|
||||||
|
func (a *DatabaseAuthenticator) LoginWithPasskey(ctx context.Context, req PasskeyLoginRequest) (*LoginResponse, error) {
|
||||||
|
if a.passkeyProvider == nil {
|
||||||
|
return nil, fmt.Errorf("passkey provider not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify passkey assertion
|
||||||
|
userID, err := a.passkeyProvider.CompleteAuthentication(ctx, req.Response, req.ExpectedChallenge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("passkey authentication failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data from database
|
||||||
|
var username, email, roles string
|
||||||
|
var userLevel int
|
||||||
|
query := `SELECT username, email, user_level, COALESCE(roles, '') FROM users WHERE id = $1 AND is_active = true`
|
||||||
|
err = a.db.QueryRowContext(ctx, query, userID).Scan(&username, &email, &userLevel, &roles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate session token
|
||||||
|
sessionToken := "sess_" + generateRandomString(32) + "_" + fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
|
||||||
|
// Extract IP and user agent from claims
|
||||||
|
ipAddress := ""
|
||||||
|
userAgent := ""
|
||||||
|
if req.Claims != nil {
|
||||||
|
if ip, ok := req.Claims["ip_address"].(string); ok {
|
||||||
|
ipAddress = ip
|
||||||
|
}
|
||||||
|
if ua, ok := req.Claims["user_agent"].(string); ok {
|
||||||
|
userAgent = ua
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
insertQuery := `INSERT INTO user_sessions (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, now())`
|
||||||
|
_, err = a.db.ExecContext(ctx, insertQuery, sessionToken, userID, expiresAt, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
updateQuery := `UPDATE users SET last_login_at = now() WHERE id = $1`
|
||||||
|
_, _ = a.db.ExecContext(ctx, updateQuery, userID)
|
||||||
|
|
||||||
|
// Return login response
|
||||||
|
return &LoginResponse{
|
||||||
|
Token: sessionToken,
|
||||||
|
User: &UserContext{
|
||||||
|
UserID: userID,
|
||||||
|
UserName: username,
|
||||||
|
Email: email,
|
||||||
|
UserLevel: userLevel,
|
||||||
|
SessionID: sessionToken,
|
||||||
|
Roles: parseRoles(roles),
|
||||||
|
},
|
||||||
|
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPasskeyCredentials returns all passkey credentials for a user
|
||||||
|
func (a *DatabaseAuthenticator) GetPasskeyCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
||||||
|
if a.passkeyProvider == nil {
|
||||||
|
return nil, fmt.Errorf("passkey provider not configured")
|
||||||
|
}
|
||||||
|
return a.passkeyProvider.GetCredentials(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePasskeyCredential removes a passkey credential
|
||||||
|
func (a *DatabaseAuthenticator) DeletePasskeyCredential(ctx context.Context, userID int, credentialID string) error {
|
||||||
|
if a.passkeyProvider == nil {
|
||||||
|
return fmt.Errorf("passkey provider not configured")
|
||||||
|
}
|
||||||
|
return a.passkeyProvider.DeleteCredential(ctx, userID, credentialID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePasskeyCredentialName updates the friendly name of a credential
|
||||||
|
func (a *DatabaseAuthenticator) UpdatePasskeyCredentialName(ctx context.Context, userID int, credentialID string, name string) error {
|
||||||
|
if a.passkeyProvider == nil {
|
||||||
|
return fmt.Errorf("passkey provider not configured")
|
||||||
|
}
|
||||||
|
return a.passkeyProvider.UpdateCredentialName(ctx, userID, credentialID, name)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user