mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-02-01 15:34:25 +00:00
- 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.
432 lines
13 KiB
Go
432 lines
13 KiB
Go
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);
|
|
}
|
|
});
|
|
`
|
|
}
|