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:
2026-01-31 22:53:33 +02:00
parent fdf9e118c5
commit 2e7b3e7abd
7 changed files with 2022 additions and 3 deletions

View 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);
}
});
`
}