mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-04 10:21:40 +00:00
@@ -0,0 +1,248 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
// InitDB initializes the SQLite database connection
|
||||
func InitDB() error {
|
||||
// Create ~/.sonr directory if it doesn't exist
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
|
||||
sonrDir := filepath.Join(homeDir, ".sonr")
|
||||
if mkdirErr := os.MkdirAll(sonrDir, 0o750); mkdirErr != nil {
|
||||
return fmt.Errorf("failed to create .sonr directory: %w", mkdirErr)
|
||||
}
|
||||
|
||||
// Database file path
|
||||
dbPath := filepath.Join(sonrDir, "vault.db")
|
||||
|
||||
// Open SQLite database with GORM
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
// Disable GORM logging for cleaner CLI output
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Auto-migrate all models
|
||||
err = db.AutoMigrate(
|
||||
&StoredWebAuthnCredential{},
|
||||
&UnsignedTransaction{},
|
||||
&AccountInfo{},
|
||||
&VaultInfo{},
|
||||
&SessionInfo{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB returns the database instance
|
||||
func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection
|
||||
func CloseDB() error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// WebAuthnCredentialService provides database operations for WebAuthn credentials
|
||||
type WebAuthnCredentialService struct{}
|
||||
|
||||
// NewWebAuthnCredentialService creates a new WebAuthn credential service
|
||||
func NewWebAuthnCredentialService() *WebAuthnCredentialService {
|
||||
return &WebAuthnCredentialService{}
|
||||
}
|
||||
|
||||
// Store saves a WebAuthn credential to the database
|
||||
func (s *WebAuthnCredentialService) Store(credential *StoredWebAuthnCredential) error {
|
||||
return db.Create(credential).Error
|
||||
}
|
||||
|
||||
// GetByCredentialID retrieves a credential by its ID
|
||||
func (s *WebAuthnCredentialService) GetByCredentialID(
|
||||
credentialID string,
|
||||
) (*StoredWebAuthnCredential, error) {
|
||||
var credential StoredWebAuthnCredential
|
||||
err := db.Where("credential_id = ?", credentialID).First(&credential).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &credential, nil
|
||||
}
|
||||
|
||||
// GetByUsername retrieves all credentials for a username
|
||||
func (s *WebAuthnCredentialService) GetByUsername(
|
||||
username string,
|
||||
) ([]StoredWebAuthnCredential, error) {
|
||||
var credentials []StoredWebAuthnCredential
|
||||
err := db.Where("username = ?", username).Find(&credentials).Error
|
||||
return credentials, err
|
||||
}
|
||||
|
||||
// UsernameExists checks if a username already has registered WebAuthn credentials
|
||||
func (s *WebAuthnCredentialService) UsernameExists(username string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Model(&StoredWebAuthnCredential{}).Where("username = ?", username).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// AccountInfoService provides database operations for account information
|
||||
type AccountInfoService struct{}
|
||||
|
||||
// NewAccountInfoService creates a new account info service
|
||||
func NewAccountInfoService() *AccountInfoService {
|
||||
return &AccountInfoService{}
|
||||
}
|
||||
|
||||
// Store saves account information to the database
|
||||
func (s *AccountInfoService) Store(account *AccountInfo) error {
|
||||
return db.Create(account).Error
|
||||
}
|
||||
|
||||
// GetByUsername retrieves account info by username
|
||||
func (s *AccountInfoService) GetByUsername(username string) (*AccountInfo, error) {
|
||||
var account AccountInfo
|
||||
err := db.Where("username = ?", username).First(&account).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// UpdateSequence updates the account sequence number
|
||||
func (s *AccountInfoService) UpdateSequence(username string, sequence uint64) error {
|
||||
return db.Model(&AccountInfo{}).
|
||||
Where("username = ?", username).
|
||||
Update("sequence", sequence).
|
||||
Error
|
||||
}
|
||||
|
||||
// VaultInfoService provides database operations for vault information
|
||||
type VaultInfoService struct{}
|
||||
|
||||
// NewVaultInfoService creates a new vault info service
|
||||
func NewVaultInfoService() *VaultInfoService {
|
||||
return &VaultInfoService{}
|
||||
}
|
||||
|
||||
// Store saves vault information to the database
|
||||
func (s *VaultInfoService) Store(vault *VaultInfo) error {
|
||||
return db.Create(vault).Error
|
||||
}
|
||||
|
||||
// GetByVaultID retrieves vault info by vault ID
|
||||
func (s *VaultInfoService) GetByVaultID(vaultID string) (*VaultInfo, error) {
|
||||
var vault VaultInfo
|
||||
err := db.Where("vault_id = ?", vaultID).First(&vault).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vault, nil
|
||||
}
|
||||
|
||||
// GetByUsername retrieves all vaults for a username
|
||||
func (s *VaultInfoService) GetByUsername(username string) ([]VaultInfo, error) {
|
||||
var vaults []VaultInfo
|
||||
err := db.Where("username = ?", username).Find(&vaults).Error
|
||||
return vaults, err
|
||||
}
|
||||
|
||||
// UnsignedTransactionService provides database operations for unsigned transactions
|
||||
type UnsignedTransactionService struct{}
|
||||
|
||||
// NewUnsignedTransactionService creates a new unsigned transaction service
|
||||
func NewUnsignedTransactionService() *UnsignedTransactionService {
|
||||
return &UnsignedTransactionService{}
|
||||
}
|
||||
|
||||
// Store saves an unsigned transaction to the database
|
||||
func (s *UnsignedTransactionService) Store(tx *UnsignedTransaction) error {
|
||||
return db.Create(tx).Error
|
||||
}
|
||||
|
||||
// GetByTxID retrieves a transaction by its ID
|
||||
func (s *UnsignedTransactionService) GetByTxID(txID string) (*UnsignedTransaction, error) {
|
||||
var tx UnsignedTransaction
|
||||
err := db.Where("tx_id = ?", txID).First(&tx).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tx, nil
|
||||
}
|
||||
|
||||
// GetPendingByUsername retrieves all pending transactions for a username
|
||||
func (s *UnsignedTransactionService) GetPendingByUsername(
|
||||
username string,
|
||||
) ([]UnsignedTransaction, error) {
|
||||
var transactions []UnsignedTransaction
|
||||
err := db.Where("username = ? AND status = ?", username, "pending").Find(&transactions).Error
|
||||
return transactions, err
|
||||
}
|
||||
|
||||
// UpdateStatus updates the transaction status
|
||||
func (s *UnsignedTransactionService) UpdateStatus(txID, status string) error {
|
||||
return db.Model(&UnsignedTransaction{}).Where("tx_id = ?", txID).Update("status", status).Error
|
||||
}
|
||||
|
||||
// SessionInfoService provides database operations for session information
|
||||
type SessionInfoService struct{}
|
||||
|
||||
// NewSessionInfoService creates a new session info service
|
||||
func NewSessionInfoService() *SessionInfoService {
|
||||
return &SessionInfoService{}
|
||||
}
|
||||
|
||||
// Store saves session information to the database
|
||||
func (s *SessionInfoService) Store(session *SessionInfo) error {
|
||||
return db.Create(session).Error
|
||||
}
|
||||
|
||||
// GetBySessionID retrieves a session by its ID
|
||||
func (s *SessionInfoService) GetBySessionID(sessionID string) (*SessionInfo, error) {
|
||||
var session SessionInfo
|
||||
err := db.Where("session_id = ?", sessionID).First(&session).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates the session status
|
||||
func (s *SessionInfoService) UpdateStatus(sessionID, status string) error {
|
||||
return db.Model(&SessionInfo{}).
|
||||
Where("session_id = ?", sessionID).
|
||||
Update("status", status).
|
||||
Error
|
||||
}
|
||||
|
||||
// CleanupExpiredSessions removes expired sessions
|
||||
func (s *SessionInfoService) CleanupExpiredSessions() error {
|
||||
return db.Where("expires_at < ?", fmt.Sprintf("%d", os.Getpid())).Delete(&SessionInfo{}).Error
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"cosmossdk.io/log"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/types/webauthn"
|
||||
"github.com/sonr-io/sonr/types/webauthn/webauthncbor"
|
||||
)
|
||||
|
||||
var logger = log.NewLogger(os.Stderr)
|
||||
|
||||
// HandleIndex handles the index route
|
||||
func HandleIndex(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "Sonr Auth Server")
|
||||
}
|
||||
|
||||
// HandleHealth handles the health route
|
||||
func HandleHealth(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "OK")
|
||||
}
|
||||
|
||||
// HandleLogin handles the basic login route
|
||||
func HandleLogin(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "Login endpoint")
|
||||
}
|
||||
|
||||
// HandleWebAuthnLogin serves the WebAuthn login HTML page
|
||||
func HandleWebAuthnLogin(c echo.Context) error {
|
||||
// Support both username and identifier parameters
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
username = c.QueryParam("identifier")
|
||||
}
|
||||
if username == "" {
|
||||
return c.String(http.StatusBadRequest, "Username or identifier parameter required")
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
service := NewWebAuthnCredentialService()
|
||||
credentials, err := service.GetByUsername(username)
|
||||
if err != nil || len(credentials) == 0 {
|
||||
return c.String(
|
||||
http.StatusNotFound,
|
||||
fmt.Sprintf("No WebAuthn credentials found for user: %s", username),
|
||||
)
|
||||
}
|
||||
|
||||
// Render the WebAuthn login page
|
||||
tmpl := template.Must(template.New("webauthn-login").Parse(webAuthnLoginHTML))
|
||||
return tmpl.Execute(c.Response().Writer, map[string]any{
|
||||
"Username": username,
|
||||
"RPID": "localhost",
|
||||
"RPName": "Sonr Identity Platform",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleBeginLogin starts the WebAuthn authentication ceremony
|
||||
func HandleBeginLogin(c echo.Context) error {
|
||||
var username string
|
||||
|
||||
// Handle both GET and POST requests
|
||||
if c.Request().Method == "POST" {
|
||||
// For POST requests, try to get username from body
|
||||
var body map[string]string
|
||||
if err := c.Bind(&body); err == nil {
|
||||
username = body["username"]
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to query param for both GET and POST
|
||||
if username == "" {
|
||||
username = c.QueryParam("username")
|
||||
}
|
||||
|
||||
// Also check for identifier parameter
|
||||
if username == "" {
|
||||
username = c.QueryParam("identifier")
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Username or identifier parameter required"},
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info("Starting WebAuthn authentication", "username", username)
|
||||
|
||||
// Check if user exists and get their credentials
|
||||
service := NewWebAuthnCredentialService()
|
||||
credentials, err := service.GetByUsername(username)
|
||||
if err != nil || len(credentials) == 0 {
|
||||
return c.JSON(
|
||||
http.StatusNotFound,
|
||||
map[string]string{
|
||||
"error": fmt.Sprintf("No WebAuthn credentials found for user: %s", username),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Generate challenge
|
||||
challenge, err := generateChallenge()
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate challenge", "error", err)
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to generate challenge"},
|
||||
)
|
||||
}
|
||||
|
||||
// Create authentication options
|
||||
allowCredentials := make([]map[string]any, len(credentials))
|
||||
for i, cred := range credentials {
|
||||
allowCredentials[i] = map[string]any{
|
||||
"type": "public-key",
|
||||
"id": cred.CredentialID,
|
||||
}
|
||||
}
|
||||
|
||||
options := map[string]any{
|
||||
"challenge": challenge,
|
||||
"timeout": 60000,
|
||||
"rpId": "localhost",
|
||||
"allowCredentials": allowCredentials,
|
||||
"userVerification": "preferred", // Changed from required to preferred for broader compatibility
|
||||
}
|
||||
|
||||
// Store challenge in session
|
||||
if authServer != nil {
|
||||
if authServer.sessionStore == nil {
|
||||
authServer.sessionStore = make(map[string]string)
|
||||
}
|
||||
authServer.sessionStore[username] = challenge
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
"Sending authentication options",
|
||||
"username",
|
||||
username,
|
||||
"challenge",
|
||||
challenge,
|
||||
"credentialCount",
|
||||
len(credentials),
|
||||
)
|
||||
return c.JSON(http.StatusOK, options)
|
||||
}
|
||||
|
||||
// HandleFinishLogin completes the WebAuthn authentication ceremony
|
||||
func HandleFinishLogin(c echo.Context) error {
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Username parameter required"},
|
||||
)
|
||||
}
|
||||
|
||||
// Parse authentication response from client
|
||||
var authResponse map[string]any
|
||||
if err := c.Bind(&authResponse); err != nil {
|
||||
logger.Error("Failed to parse authentication response", "error", err)
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Invalid authentication response"},
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info("Received authentication response", "username", username)
|
||||
|
||||
// Get stored challenge
|
||||
var storedChallenge string
|
||||
if authServer != nil && authServer.sessionStore != nil {
|
||||
storedChallenge = authServer.sessionStore[username]
|
||||
}
|
||||
|
||||
if storedChallenge == "" {
|
||||
logger.Error("No stored challenge found", "username", username)
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "No challenge found for user"},
|
||||
)
|
||||
}
|
||||
|
||||
// Extract credential data from the response
|
||||
credentialID, ok := authResponse["id"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid credential ID"})
|
||||
}
|
||||
|
||||
response, ok := authResponse["response"].(map[string]any)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid response object"})
|
||||
}
|
||||
|
||||
clientDataJSON, ok := response["clientDataJSON"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid client data JSON"})
|
||||
}
|
||||
|
||||
// Verify client data and challenge for authentication
|
||||
if err := verifyClientDataForAuthentication(clientDataJSON, storedChallenge); err != nil {
|
||||
logger.Error("Client data verification failed for authentication", "error", err)
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Authentication verification failed"},
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the credential exists for this user
|
||||
service := NewWebAuthnCredentialService()
|
||||
credential, err := service.GetByCredentialID(credentialID)
|
||||
if err != nil {
|
||||
logger.Error("Credential not found", "error", err, "credentialID", credentialID)
|
||||
return c.JSON(
|
||||
http.StatusNotFound,
|
||||
map[string]string{"error": "Credential not found"},
|
||||
)
|
||||
}
|
||||
|
||||
if credential.Username != username {
|
||||
logger.Error(
|
||||
"Credential belongs to different user",
|
||||
"credentialUser",
|
||||
credential.Username,
|
||||
"requestedUser",
|
||||
username,
|
||||
)
|
||||
return c.JSON(
|
||||
http.StatusUnauthorized,
|
||||
map[string]string{"error": "Credential does not belong to this user"},
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up session
|
||||
if authServer != nil && authServer.sessionStore != nil {
|
||||
delete(authServer.sessionStore, username)
|
||||
}
|
||||
|
||||
// Signal completion to CLI
|
||||
if authServer != nil && authServer.registrationDone != nil {
|
||||
select {
|
||||
case authServer.registrationDone <- nil:
|
||||
logger.Info("Authentication completion signaled to CLI", "username", username)
|
||||
default:
|
||||
logger.Warn(
|
||||
"Failed to signal authentication completion - channel full",
|
||||
"username",
|
||||
username,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
"WebAuthn authentication completed successfully",
|
||||
"username",
|
||||
username,
|
||||
"credentialID",
|
||||
credentialID,
|
||||
)
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Authentication completed successfully",
|
||||
"credentialId": credentialID,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWebAuthnRegister serves the WebAuthn registration HTML page
|
||||
func HandleWebAuthnRegister(c echo.Context) error {
|
||||
// Support both username and identifier parameters
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
username = c.QueryParam("identifier")
|
||||
}
|
||||
if username == "" {
|
||||
return c.String(http.StatusBadRequest, "Username or identifier parameter required")
|
||||
}
|
||||
|
||||
// Render the WebAuthn registration page
|
||||
tmpl := template.Must(template.New("webauthn-register").Parse(webAuthnRegistrationHTML))
|
||||
return tmpl.Execute(c.Response().Writer, map[string]any{
|
||||
"Username": username,
|
||||
"RPID": "localhost",
|
||||
"RPName": "Sonr Identity Platform",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleBeginRegister starts the WebAuthn registration ceremony
|
||||
func HandleBeginRegister(c echo.Context) error {
|
||||
var username string
|
||||
|
||||
// Handle both GET and POST requests
|
||||
if c.Request().Method == "POST" {
|
||||
// For POST requests, try to get username from body
|
||||
var body map[string]string
|
||||
if err := c.Bind(&body); err == nil {
|
||||
username = body["username"]
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to query param for both GET and POST
|
||||
if username == "" {
|
||||
username = c.QueryParam("username")
|
||||
}
|
||||
|
||||
// Also check for identifier parameter
|
||||
if username == "" {
|
||||
username = c.QueryParam("identifier")
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Username or identifier parameter required"},
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info("Starting WebAuthn registration", "username", username)
|
||||
|
||||
// Generate challenge
|
||||
challenge, err := generateChallenge()
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate challenge", "error", err)
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to generate challenge"},
|
||||
)
|
||||
}
|
||||
|
||||
// Create registration options
|
||||
options := map[string]any{
|
||||
"challenge": challenge,
|
||||
"rp": map[string]string{
|
||||
"id": "localhost",
|
||||
"name": "Sonr Identity Platform",
|
||||
},
|
||||
"user": map[string]any{
|
||||
"id": base64.URLEncoding.EncodeToString([]byte(username)),
|
||||
"name": username,
|
||||
"displayName": username,
|
||||
},
|
||||
"pubKeyCredParams": []map[string]any{
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -7, // ES256 algorithm (most common)
|
||||
},
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -257, // RS256 algorithm
|
||||
},
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -8, // EdDSA algorithm
|
||||
},
|
||||
},
|
||||
"authenticatorSelection": map[string]any{
|
||||
// Remove authenticatorAttachment to allow both platform and cross-platform authenticators
|
||||
// "authenticatorAttachment": "platform", // Commented out to allow QR codes
|
||||
"userVerification": "preferred", // Changed from required to preferred for broader compatibility
|
||||
"residentKey": "preferred",
|
||||
"requireResidentKey": false, // Allow non-resident keys for broader compatibility
|
||||
},
|
||||
"timeout": 60000,
|
||||
"attestation": "none", // Changed from direct to none for broader compatibility
|
||||
}
|
||||
|
||||
// Store challenge in session (in production, use proper session store)
|
||||
if authServer != nil {
|
||||
if authServer.sessionStore == nil {
|
||||
authServer.sessionStore = make(map[string]string)
|
||||
}
|
||||
authServer.sessionStore[username] = challenge
|
||||
}
|
||||
|
||||
logger.Info("Sending registration options", "username", username, "challenge", challenge)
|
||||
return c.JSON(http.StatusOK, options)
|
||||
}
|
||||
|
||||
// HandleFinishRegister completes the WebAuthn registration ceremony
|
||||
func HandleFinishRegister(c echo.Context) error {
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Username parameter required"},
|
||||
)
|
||||
}
|
||||
|
||||
// Parse registration response from client
|
||||
var regResponse map[string]any
|
||||
if err := c.Bind(®Response); err != nil {
|
||||
logger.Error("Failed to parse registration response", "error", err)
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Invalid registration response"},
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info("Received registration response", "username", username)
|
||||
|
||||
// Get stored challenge
|
||||
var storedChallenge string
|
||||
if authServer != nil && authServer.sessionStore != nil {
|
||||
storedChallenge = authServer.sessionStore[username]
|
||||
}
|
||||
|
||||
if storedChallenge == "" {
|
||||
logger.Error("No stored challenge found", "username", username)
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "No challenge found for user"},
|
||||
)
|
||||
}
|
||||
|
||||
// Extract credential data from the response
|
||||
credentialID, ok := regResponse["id"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid credential ID"})
|
||||
}
|
||||
|
||||
rawID, ok := regResponse["rawId"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid raw ID"})
|
||||
}
|
||||
|
||||
response, ok := regResponse["response"].(map[string]any)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid response object"})
|
||||
}
|
||||
|
||||
clientDataJSON, ok := response["clientDataJSON"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid client data JSON"})
|
||||
}
|
||||
|
||||
attestationObject, ok := response["attestationObject"].(string)
|
||||
if !ok {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Invalid attestation object"},
|
||||
)
|
||||
}
|
||||
|
||||
// Verify client data and challenge
|
||||
if err := verifyClientData(clientDataJSON, storedChallenge); err != nil {
|
||||
logger.Error("Client data verification failed", "error", err)
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Client data verification failed"},
|
||||
)
|
||||
}
|
||||
|
||||
// Create WebAuthn credential record
|
||||
webAuthnCredential := &WebAuthnCredential{
|
||||
CredentialID: credentialID,
|
||||
RawID: rawID,
|
||||
ClientDataJSON: clientDataJSON,
|
||||
AttestationObject: attestationObject,
|
||||
Username: username,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Process the registration and store in database
|
||||
if err := processWebAuthnRegistration(webAuthnCredential); err != nil {
|
||||
logger.Error("Failed to process WebAuthn registration", "error", err)
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Registration processing failed"},
|
||||
)
|
||||
}
|
||||
|
||||
// Store WebAuthn credential in database
|
||||
if err := storeWebAuthnCredential(webAuthnCredential); err != nil {
|
||||
logger.Error("Failed to store WebAuthn credential in database", "error", err)
|
||||
// Don't fail the registration if database storage fails
|
||||
logger.Warn("Continuing registration despite database storage failure")
|
||||
}
|
||||
|
||||
// Clean up session
|
||||
if authServer != nil && authServer.sessionStore != nil {
|
||||
delete(authServer.sessionStore, username)
|
||||
}
|
||||
|
||||
// Send credential data to CLI if channel is available
|
||||
if authServer != nil && authServer.credentialData != nil {
|
||||
select {
|
||||
case authServer.credentialData <- webAuthnCredential:
|
||||
logger.Info(
|
||||
"WebAuthn credential data sent to CLI",
|
||||
"username",
|
||||
username,
|
||||
"credentialID",
|
||||
credentialID,
|
||||
)
|
||||
default:
|
||||
logger.Warn("Failed to send credential data - channel full", "username", username)
|
||||
}
|
||||
}
|
||||
|
||||
// Signal completion to CLI
|
||||
if authServer != nil && authServer.registrationDone != nil {
|
||||
select {
|
||||
case authServer.registrationDone <- nil:
|
||||
logger.Info("Registration completion signaled to CLI", "username", username)
|
||||
default:
|
||||
logger.Warn(
|
||||
"Failed to signal registration completion - channel full",
|
||||
"username",
|
||||
username,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
"WebAuthn registration completed successfully",
|
||||
"username",
|
||||
username,
|
||||
"credentialID",
|
||||
credentialID,
|
||||
)
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Registration completed successfully",
|
||||
"credentialId": credentialID,
|
||||
})
|
||||
}
|
||||
|
||||
// generateChallenge generates a cryptographically secure challenge
|
||||
func generateChallenge() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// verifyClientData verifies the client data JSON and challenge using centralized WebAuthn validation
|
||||
func verifyClientData(clientDataJSON, expectedChallenge string) error {
|
||||
// Parse client data using the centralized WebAuthn protocol parser
|
||||
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse client data: %w", err)
|
||||
}
|
||||
|
||||
// Verify challenge
|
||||
if clientData.Challenge != expectedChallenge {
|
||||
return fmt.Errorf("challenge mismatch")
|
||||
}
|
||||
|
||||
// Verify type
|
||||
if clientData.Type != "webauthn.create" {
|
||||
return fmt.Errorf("invalid client data type: %s", clientData.Type)
|
||||
}
|
||||
|
||||
// Verify origin (adjust for your domain)
|
||||
expectedOrigin := "http://localhost"
|
||||
if clientData.Origin != expectedOrigin &&
|
||||
!containsString(
|
||||
clientData.Origin,
|
||||
[]string{
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:8083",
|
||||
"http://localhost:8084",
|
||||
},
|
||||
) {
|
||||
return fmt.Errorf("invalid origin: %s", clientData.Origin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyClientDataForAuthentication verifies the client data JSON and challenge for authentication
|
||||
func verifyClientDataForAuthentication(clientDataJSON, expectedChallenge string) error {
|
||||
// Parse client data using the centralized WebAuthn protocol parser
|
||||
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse client data: %w", err)
|
||||
}
|
||||
|
||||
// Verify challenge
|
||||
if clientData.Challenge != expectedChallenge {
|
||||
return fmt.Errorf("challenge mismatch")
|
||||
}
|
||||
|
||||
// Verify type for authentication (webauthn.get instead of webauthn.create)
|
||||
if clientData.Type != "webauthn.get" {
|
||||
return fmt.Errorf("invalid client data type for authentication: %s", clientData.Type)
|
||||
}
|
||||
|
||||
// Verify origin (adjust for your domain)
|
||||
expectedOrigin := "http://localhost"
|
||||
if clientData.Origin != expectedOrigin &&
|
||||
!containsString(
|
||||
clientData.Origin,
|
||||
[]string{
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:8083",
|
||||
"http://localhost:8084",
|
||||
"http://localhost:8085",
|
||||
"http://localhost:8086",
|
||||
"http://localhost:8087",
|
||||
"http://localhost:8088",
|
||||
"http://localhost:8089",
|
||||
},
|
||||
) {
|
||||
return fmt.Errorf("invalid origin: %s", clientData.Origin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// containsString checks if a string is in a slice
|
||||
func containsString(str string, slice []string) bool {
|
||||
return slices.Contains(slice, str)
|
||||
}
|
||||
|
||||
// processWebAuthnRegistration processes the WebAuthn registration and extracts required fields
|
||||
func processWebAuthnRegistration(credential *WebAuthnCredential) error {
|
||||
logger.Info(
|
||||
"Processing WebAuthn registration",
|
||||
"username",
|
||||
credential.Username,
|
||||
"credentialID",
|
||||
credential.CredentialID,
|
||||
)
|
||||
|
||||
// Extract origin from client data JSON
|
||||
origin, err := extractOriginFromClientData(credential.ClientDataJSON)
|
||||
if err != nil {
|
||||
logger.Error("Failed to extract origin from client data", "error", err)
|
||||
return fmt.Errorf("failed to extract origin: %w", err)
|
||||
}
|
||||
credential.Origin = origin
|
||||
logger.Info("Extracted origin from client data", "origin", origin)
|
||||
|
||||
// Extract public key and algorithm from attestation object
|
||||
publicKey, algorithm, err := extractPublicKeyFromAttestation(credential.AttestationObject)
|
||||
if err != nil {
|
||||
logger.Error("Failed to extract public key from attestation", "error", err)
|
||||
return fmt.Errorf("failed to extract public key: %w", err)
|
||||
}
|
||||
credential.PublicKey = publicKey
|
||||
credential.Algorithm = algorithm
|
||||
logger.Info("Extracted public key from attestation",
|
||||
"algorithm", algorithm,
|
||||
"publicKeyLength", len(publicKey))
|
||||
|
||||
logger.Info(
|
||||
"WebAuthn credential data collected - ready for blockchain transaction",
|
||||
"credentialID",
|
||||
credential.CredentialID,
|
||||
"username",
|
||||
credential.Username,
|
||||
"origin",
|
||||
credential.Origin,
|
||||
"algorithm",
|
||||
credential.Algorithm,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractOriginFromClientData extracts the origin from client data JSON using centralized WebAuthn parsing
|
||||
func extractOriginFromClientData(clientDataJSON string) (string, error) {
|
||||
// Use the centralized WebAuthn client data parser
|
||||
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse client data: %w", err)
|
||||
}
|
||||
|
||||
if clientData.Origin == "" {
|
||||
return "", fmt.Errorf("origin not found in client data JSON")
|
||||
}
|
||||
|
||||
return clientData.Origin, nil
|
||||
}
|
||||
|
||||
// extractPublicKeyFromAttestation extracts public key and algorithm from attestation object using centralized WebAuthn parsing
|
||||
func extractPublicKeyFromAttestation(attestationObject string) ([]byte, int32, error) {
|
||||
// Use the centralized WebAuthn attestation validation first
|
||||
if err := webauthn.ValidateAttestationObjectFormat(attestationObject); err != nil {
|
||||
return nil, 0, fmt.Errorf("invalid attestation object format: %w", err)
|
||||
}
|
||||
|
||||
// Decode the attestation object
|
||||
attestationBytes, err := base64.RawURLEncoding.DecodeString(attestationObject)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to decode attestation object: %w", err)
|
||||
}
|
||||
|
||||
// Parse the attestation object using the centralized WebAuthn CBOR parsing
|
||||
var attestationObj webauthn.AttestationObject
|
||||
if err := webauthncbor.Unmarshal(attestationBytes, &attestationObj); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to unmarshal attestation object: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the authenticator data
|
||||
if err := attestationObj.AuthData.Unmarshal(attestationObj.RawAuthData); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to unmarshal authenticator data: %w", err)
|
||||
}
|
||||
|
||||
// Extract the attested credential data
|
||||
if !attestationObj.AuthData.Flags.HasAttestedCredentialData() {
|
||||
return nil, 0, fmt.Errorf("attestation object missing attested credential data")
|
||||
}
|
||||
|
||||
publicKey := attestationObj.AuthData.AttData.CredentialPublicKey
|
||||
if len(publicKey) == 0 {
|
||||
return nil, 0, fmt.Errorf("no public key found in attested credential data")
|
||||
}
|
||||
|
||||
// Assume ES256 algorithm for now. In the future, this could be extracted
|
||||
// from the COSE key format in the public key bytes
|
||||
algorithm := int32(-7) // ES256
|
||||
|
||||
return publicKey, algorithm, nil
|
||||
}
|
||||
|
||||
// WebAuthnCredential represents a WebAuthn credential for processing
|
||||
type WebAuthnCredential struct {
|
||||
CredentialID string
|
||||
RawID string
|
||||
ClientDataJSON string
|
||||
AttestationObject string
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
// Extracted fields
|
||||
Origin string
|
||||
PublicKey []byte
|
||||
Algorithm int32
|
||||
}
|
||||
|
||||
// storeWebAuthnCredential stores the WebAuthn credential in the database
|
||||
func storeWebAuthnCredential(credential *WebAuthnCredential) error {
|
||||
// Initialize database if not already done
|
||||
if db == nil {
|
||||
if err := InitDB(); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert WebAuthn credential to database model
|
||||
storedCredential := &StoredWebAuthnCredential{
|
||||
CredentialID: credential.CredentialID,
|
||||
RawID: credential.RawID,
|
||||
ClientDataJSON: credential.ClientDataJSON,
|
||||
AttestationObject: credential.AttestationObject,
|
||||
Username: credential.Username,
|
||||
Origin: "localhost", // Default for CLI registration
|
||||
RPID: "localhost",
|
||||
Algorithm: -7, // ES256 algorithm by default
|
||||
}
|
||||
|
||||
// Store using service
|
||||
service := NewWebAuthnCredentialService()
|
||||
return service.Store(storedCredential)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// StoredWebAuthnCredential represents a stored WebAuthn credential in database
|
||||
type StoredWebAuthnCredential struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CredentialID string `gorm:"uniqueIndex;not null"`
|
||||
RawID string `gorm:"not null"`
|
||||
ClientDataJSON string `gorm:"type:text;not null"`
|
||||
AttestationObject string `gorm:"type:text;not null"`
|
||||
Username string `gorm:"index;not null"`
|
||||
PublicKey []byte `gorm:"type:blob"`
|
||||
Algorithm int32 `gorm:"not null"`
|
||||
Origin string `gorm:"not null"`
|
||||
RPID string `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// UnsignedTransaction represents an unsigned transaction waiting to be signed
|
||||
type UnsignedTransaction struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
TxID string `gorm:"uniqueIndex;not null"`
|
||||
Username string `gorm:"index;not null"`
|
||||
TxData []byte `gorm:"type:blob;not null"` // Serialized transaction data
|
||||
TxType string `gorm:"not null"` // e.g., "MsgRegisterWebAuthnCredential", "MsgCreateRecord"
|
||||
Description string `gorm:"type:text"`
|
||||
Status string `gorm:"not null;default:pending"` // pending, signed, broadcast, failed
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// AccountInfo represents DWN wallet account information
|
||||
type AccountInfo struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex;not null"`
|
||||
Address string `gorm:"uniqueIndex;not null"`
|
||||
DID string `gorm:"uniqueIndex"`
|
||||
PublicKey []byte `gorm:"type:blob"`
|
||||
EncryptedPrivKey []byte `gorm:"type:blob"` // Encrypted with user's WebAuthn credential
|
||||
KeyType string `gorm:"not null"` // e.g., "secp256k1", "ed25519"
|
||||
ChainID string `gorm:"not null"`
|
||||
AccountNumber uint64 `gorm:"not null"`
|
||||
Sequence uint64 `gorm:"not null"`
|
||||
VaultID string `gorm:"index"`
|
||||
VaultPublicKey []byte `gorm:"type:blob"`
|
||||
EnclaveID string `gorm:"index"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// VaultInfo represents vault metadata and encryption keys
|
||||
type VaultInfo struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
VaultID string `gorm:"uniqueIndex;not null"`
|
||||
Username string `gorm:"index;not null"`
|
||||
EnclaveID string `gorm:"uniqueIndex;not null"`
|
||||
PublicKey []byte `gorm:"type:blob;not null"`
|
||||
EncryptedEnclave []byte `gorm:"type:blob;not null"` // MPC enclave data encrypted
|
||||
IPFSHash string `gorm:"index"` // IPFS hash for vault data
|
||||
Status string `gorm:"not null;default:active"` // active, rotated, deprecated
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// SessionInfo represents active WebAuthn sessions
|
||||
type SessionInfo struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"index;not null"`
|
||||
SessionID string `gorm:"uniqueIndex;not null"`
|
||||
Challenge string `gorm:"not null"`
|
||||
SessionType string `gorm:"not null"` // registration, authentication
|
||||
Status string `gorm:"not null;default:active"` // active, completed, expired
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Package server provides a spawnable HTTP server for Auth service.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrAuthServerAlreadyRunning = errors.New("auth server already running")
|
||||
ErrAuthServerNotRunning = errors.New("auth server not running")
|
||||
ErrFailedToStartAuthServer = errors.New("failed to start auth server")
|
||||
)
|
||||
|
||||
// AuthServer is a spawnable HTTP server for Auth service.
|
||||
type AuthServer struct {
|
||||
*echo.Echo
|
||||
Port int
|
||||
KillChan chan bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
sessionStore map[string]string // In-memory session store for WebAuthn challenges
|
||||
registrationDone chan error // Channel to signal registration completion
|
||||
credentialData chan *WebAuthnCredential // Channel to pass credential data to CLI
|
||||
username string // Current username being registered
|
||||
}
|
||||
|
||||
var authServer *AuthServer
|
||||
|
||||
// StartAuthServer starts the auth server
|
||||
func StartAuthServer() error {
|
||||
if authServer != nil {
|
||||
return ErrAuthServerAlreadyRunning
|
||||
}
|
||||
setupAuthServer()
|
||||
return authServer.Start()
|
||||
}
|
||||
|
||||
// StartAuthServerWithWebAuthn starts the auth server with WebAuthn support
|
||||
func StartAuthServerWithWebAuthn(port int, username string, done chan error) error {
|
||||
if authServer != nil {
|
||||
return ErrAuthServerAlreadyRunning
|
||||
}
|
||||
setupAuthServerWithWebAuthn(port, username, done)
|
||||
return authServer.Start()
|
||||
}
|
||||
|
||||
// StartAuthServerWithWebAuthnAndCredentialChannel starts auth server with WebAuthn and credential data channel
|
||||
func StartAuthServerWithWebAuthnAndCredentialChannel(
|
||||
port int,
|
||||
username string,
|
||||
done chan error,
|
||||
credentialData chan *WebAuthnCredential,
|
||||
) error {
|
||||
if authServer != nil {
|
||||
return ErrAuthServerAlreadyRunning
|
||||
}
|
||||
setupAuthServerWithWebAuthnAndCredentialChannel(port, username, done, credentialData)
|
||||
return authServer.Start()
|
||||
}
|
||||
|
||||
// StartAuthServerForLogin starts the auth server for WebAuthn login
|
||||
func StartAuthServerForLogin(port int, username string, done chan error) error {
|
||||
if authServer != nil {
|
||||
return ErrAuthServerAlreadyRunning
|
||||
}
|
||||
setupAuthServerForLogin(port, username, done)
|
||||
return authServer.Start()
|
||||
}
|
||||
|
||||
// StopAuthServer stops the auth server
|
||||
func StopAuthServer() error {
|
||||
if authServer == nil {
|
||||
return ErrAuthServerNotRunning
|
||||
}
|
||||
return authServer.Stop()
|
||||
}
|
||||
|
||||
func (s *AuthServer) Start() error {
|
||||
// Setup signal context
|
||||
s.ctx, s.cancel = signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := s.Echo.Start(fmt.Sprintf(":%d", s.Port)); err != nil &&
|
||||
err != http.ErrServerClosed {
|
||||
s.Logger.Fatal("shutting down the server")
|
||||
}
|
||||
}()
|
||||
|
||||
// Start kill signal handler in another goroutine
|
||||
go s.HandleKillSignal()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthServer) Stop() error {
|
||||
// Cancel the signal context to trigger shutdown
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
// Create shutdown context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Gracefully shutdown the server
|
||||
if err := s.Shutdown(ctx); err != nil {
|
||||
s.Logger.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up
|
||||
destroyAuthServer()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthServer) HandleKillSignal() {
|
||||
select {
|
||||
case <-s.KillChan:
|
||||
// Manual stop via KillChan
|
||||
s.Stop()
|
||||
case <-s.ctx.Done():
|
||||
// OS interrupt signal received
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); err != nil {
|
||||
s.Logger.Fatal(err)
|
||||
}
|
||||
destroyAuthServer()
|
||||
}
|
||||
}
|
||||
|
||||
// ╭───────────────────────────────────────────────────────────╮
|
||||
// │ Server Config │
|
||||
// ╰───────────────────────────────────────────────────────────╯
|
||||
|
||||
func setupRoutes(e *echo.Echo) {
|
||||
// Basic routes
|
||||
e.GET("/", HandleIndex)
|
||||
e.GET("/health", HandleHealth)
|
||||
e.POST("/login", HandleLogin)
|
||||
|
||||
// WebAuthn registration routes
|
||||
e.GET("/register", HandleWebAuthnRegister)
|
||||
e.GET("/begin-register", HandleBeginRegister) // GET for fetching options
|
||||
e.POST("/begin-register", HandleBeginRegister) // POST also supported for client compatibility
|
||||
e.POST("/finish-register", HandleFinishRegister)
|
||||
}
|
||||
|
||||
// setupMiddleware configures server middleware
|
||||
func setupMiddleware(e *echo.Echo) {
|
||||
// CORS middleware for browser compatibility
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"http://localhost:*", "https://localhost:*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// Security middleware
|
||||
e.Use(middleware.Secure())
|
||||
e.Use(middleware.RequestID())
|
||||
// Disable HTTP request logging for cleaner CLI output
|
||||
// e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
}
|
||||
|
||||
// destroyAuthServer destroys the auth server
|
||||
func destroyAuthServer() {
|
||||
authServer = nil
|
||||
}
|
||||
|
||||
// setupAuthServer sets up the auth server
|
||||
func setupAuthServer() {
|
||||
authServer = &AuthServer{
|
||||
Echo: echo.New(),
|
||||
Port: 8080,
|
||||
KillChan: make(chan bool),
|
||||
}
|
||||
// Disable Echo framework logging for cleaner CLI output
|
||||
authServer.HideBanner = true
|
||||
authServer.HidePort = true
|
||||
setupMiddleware(authServer.Echo)
|
||||
setupRoutes(authServer.Echo)
|
||||
}
|
||||
|
||||
// setupAuthServerWithWebAuthn sets up the auth server with WebAuthn context
|
||||
func setupAuthServerWithWebAuthn(port int, username string, done chan error) {
|
||||
// Initialize database for WebAuthn credential storage
|
||||
_ = InitDB() // Errors handled gracefully in storeWebAuthnCredential
|
||||
|
||||
authServer = &AuthServer{
|
||||
Echo: echo.New(),
|
||||
Port: port,
|
||||
KillChan: make(chan bool),
|
||||
sessionStore: make(map[string]string),
|
||||
registrationDone: done,
|
||||
username: username,
|
||||
}
|
||||
// Disable Echo framework logging for cleaner CLI output
|
||||
authServer.HideBanner = true
|
||||
authServer.HidePort = true
|
||||
setupMiddleware(authServer.Echo)
|
||||
setupRoutes(authServer.Echo)
|
||||
|
||||
// Set up automatic server shutdown after 15 seconds as failsafe
|
||||
go func() {
|
||||
time.Sleep(15 * time.Second)
|
||||
if authServer != nil {
|
||||
logger := authServer.Logger
|
||||
logger.Warn("Auto-shutting down auth server after 15 second timeout")
|
||||
select {
|
||||
case authServer.KillChan <- true:
|
||||
logger.Info("Server shutdown signal sent via KillChan")
|
||||
default:
|
||||
logger.Warn("KillChan full, server may already be shutting down")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// setupAuthServerWithWebAuthnAndCredentialChannel sets up auth server with WebAuthn and credential channel
|
||||
func setupAuthServerWithWebAuthnAndCredentialChannel(
|
||||
port int,
|
||||
username string,
|
||||
done chan error,
|
||||
credentialData chan *WebAuthnCredential,
|
||||
) {
|
||||
// Initialize database for WebAuthn credential storage
|
||||
_ = InitDB() // Errors handled gracefully in storeWebAuthnCredential
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
authServer = &AuthServer{
|
||||
Echo: e,
|
||||
Port: port,
|
||||
KillChan: make(chan bool),
|
||||
sessionStore: make(map[string]string),
|
||||
registrationDone: done,
|
||||
credentialData: credentialData,
|
||||
username: username,
|
||||
}
|
||||
// Disable Echo framework logging for cleaner CLI output
|
||||
authServer.HideBanner = true
|
||||
authServer.HidePort = true
|
||||
setupMiddleware(authServer.Echo)
|
||||
setupRoutes(authServer.Echo)
|
||||
|
||||
// Set up automatic server shutdown after 15 seconds as failsafe
|
||||
go func() {
|
||||
time.Sleep(15 * time.Second)
|
||||
if authServer != nil {
|
||||
logger := authServer.Logger
|
||||
logger.Warn("Auto-shutting down auth server after 15 second timeout")
|
||||
select {
|
||||
case authServer.KillChan <- true:
|
||||
logger.Info("Server shutdown signal sent via KillChan")
|
||||
default:
|
||||
logger.Warn("KillChan full, server may already be shutting down")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// setupAuthServerForLogin sets up the auth server for WebAuthn login
|
||||
func setupAuthServerForLogin(port int, username string, done chan error) {
|
||||
// Initialize database for WebAuthn credential verification
|
||||
_ = InitDB() // Errors handled gracefully in login handlers
|
||||
|
||||
authServer = &AuthServer{
|
||||
Echo: echo.New(),
|
||||
Port: port,
|
||||
KillChan: make(chan bool),
|
||||
sessionStore: make(map[string]string),
|
||||
registrationDone: done,
|
||||
username: username,
|
||||
}
|
||||
// Disable Echo framework logging for cleaner CLI output
|
||||
authServer.HideBanner = true
|
||||
authServer.HidePort = true
|
||||
setupMiddleware(authServer.Echo)
|
||||
setupLoginRoutes(authServer.Echo)
|
||||
|
||||
// Set up automatic server shutdown after 45 seconds as failsafe (longer for login)
|
||||
go func() {
|
||||
time.Sleep(45 * time.Second)
|
||||
if authServer != nil {
|
||||
logger := authServer.Logger
|
||||
logger.Warn("Auto-shutting down login auth server after 45 second timeout")
|
||||
select {
|
||||
case authServer.KillChan <- true:
|
||||
logger.Info("Login server shutdown signal sent via KillChan")
|
||||
default:
|
||||
logger.Warn("KillChan full, login server may already be shutting down")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// setupLoginRoutes configures routes specifically for login flow
|
||||
func setupLoginRoutes(e *echo.Echo) {
|
||||
// Basic routes
|
||||
e.GET("/", HandleIndex)
|
||||
e.GET("/health", HandleHealth)
|
||||
|
||||
// WebAuthn login routes
|
||||
e.GET("/login", HandleWebAuthnLogin)
|
||||
e.GET("/begin-login", HandleBeginLogin)
|
||||
e.POST("/begin-login", HandleBeginLogin) // POST also supported for client compatibility
|
||||
e.POST("/finish-login", HandleFinishLogin)
|
||||
e.POST("/login/verify", HandleFinishLogin) // Alternative endpoint for client compatibility
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package server
|
||||
|
||||
// webAuthnRegistrationHTML contains the HTML template for WebAuthn registration
|
||||
const webAuthnRegistrationHTML = `<!DOCTYPE html>
|
||||
<html class="dark">
|
||||
<head>
|
||||
<title>Sonr Local Registration</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<style>
|
||||
:root {
|
||||
--sonr-primary: #17c2ff;
|
||||
--sonr-primary-hover: #0ea5e9;
|
||||
--sonr-primary-glow: rgba(23, 194, 255, 0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
}
|
||||
|
||||
.glow {
|
||||
box-shadow: 0 0 20px var(--sonr-primary-glow);
|
||||
}
|
||||
|
||||
.pulse-primary {
|
||||
animation: pulse-primary 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-primary {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center bg-slate-900 text-white font-sans">
|
||||
<div class="bg-slate-800 rounded-xl p-8 shadow-2xl border border-slate-700 max-w-md w-full mx-4 glow">
|
||||
<div class="text-center space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-white">Sonr Registration</h1>
|
||||
<div class="h-1 bg-gradient-to-r from-[#17c2ff] to-[#0ea5e9] rounded-full mx-auto w-24"></div>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="bg-slate-700 rounded-lg p-4 border border-slate-600">
|
||||
<p class="text-slate-300 text-sm font-medium mb-1">Registering User</p>
|
||||
<p id="username-display" class="text-[#17c2ff] text-xl font-bold">{{.Username}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Section -->
|
||||
<div class="space-y-4">
|
||||
<div id="status" class="text-[#17c2ff] font-semibold text-lg pulse-primary">
|
||||
Initializing WebAuthn registration...
|
||||
</div>
|
||||
|
||||
<div id="instructions" class="text-slate-300 text-sm leading-relaxed">
|
||||
Please follow your browser and authenticator prompts.
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="w-full bg-slate-700 rounded-full h-2">
|
||||
<div id="progress" class="bg-gradient-to-r from-[#17c2ff] to-[#0ea5e9] h-2 rounded-full w-0 transition-all duration-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Timeout Display -->
|
||||
<div id="timeout" class="text-slate-400 text-xs font-mono"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load SimpleWebAuthn for WebAuthn operations -->
|
||||
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
|
||||
<!-- Load @sonr.io/es for presets and utilities -->
|
||||
<script type="module" src="https://unpkg.com/@sonr.io/es@latest/dist/autoloader.js"></script>
|
||||
|
||||
<script>
|
||||
// Support both username and identifier parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const username = urlParams.get('identifier') || urlParams.get('username') || '{{.Username}}';
|
||||
const rpId = '{{.RPID}}';
|
||||
const API_URL = window.location.origin; // Use current origin as API URL
|
||||
|
||||
// Update the username display if it's from URL params
|
||||
if (urlParams.get('identifier') || urlParams.get('username')) {
|
||||
document.getElementById('username-display').textContent = username;
|
||||
}
|
||||
|
||||
function updateStatus(message, type = 'info') {
|
||||
const statusEl = document.getElementById('status');
|
||||
const progressEl = document.getElementById('progress');
|
||||
|
||||
statusEl.textContent = message;
|
||||
|
||||
// Remove all existing classes and add new ones based on type
|
||||
statusEl.className = 'font-semibold text-lg';
|
||||
|
||||
switch(type) {
|
||||
case 'success':
|
||||
statusEl.className += ' text-green-400';
|
||||
statusEl.classList.remove('pulse-primary');
|
||||
progressEl.style.width = '100%';
|
||||
progressEl.className = 'bg-gradient-to-r from-green-400 to-green-500 h-2 rounded-full transition-all duration-500';
|
||||
break;
|
||||
case 'error':
|
||||
statusEl.className += ' text-red-400';
|
||||
statusEl.classList.remove('pulse-primary');
|
||||
progressEl.style.width = '100%';
|
||||
progressEl.className = 'bg-gradient-to-r from-red-400 to-red-500 h-2 rounded-full transition-all duration-500';
|
||||
break;
|
||||
case 'processing':
|
||||
statusEl.className += ' text-[#17c2ff] pulse-primary';
|
||||
progressEl.style.width = '75%';
|
||||
break;
|
||||
default: // info
|
||||
statusEl.className += ' text-[#17c2ff] pulse-primary';
|
||||
progressEl.style.width = '25%';
|
||||
}
|
||||
}
|
||||
|
||||
function updateInstructions(message) {
|
||||
const instructionsEl = document.getElementById('instructions');
|
||||
instructionsEl.textContent = message;
|
||||
instructionsEl.className = 'text-slate-300 text-sm leading-relaxed';
|
||||
}
|
||||
|
||||
function updateTimeout(seconds) {
|
||||
const timeoutEl = document.getElementById('timeout');
|
||||
if (seconds > 0) {
|
||||
timeoutEl.textContent = 'Timeout in ' + seconds + 's';
|
||||
timeoutEl.className = 'text-slate-400 text-xs font-mono';
|
||||
} else {
|
||||
timeoutEl.textContent = 'Registration timed out';
|
||||
timeoutEl.className = 'text-red-400 text-xs font-mono';
|
||||
}
|
||||
}
|
||||
|
||||
// Start countdown timer
|
||||
let timeoutSeconds = 30;
|
||||
const countdownInterval = setInterval(() => {
|
||||
updateTimeout(timeoutSeconds);
|
||||
timeoutSeconds--;
|
||||
if (timeoutSeconds < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
updateStatus('Registration timed out', 'error');
|
||||
updateInstructions('Please return to the CLI and try again.');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
async function startRegistration() {
|
||||
try {
|
||||
// Check if SimpleWebAuthn is loaded
|
||||
if (!window.SimpleWebAuthnBrowser) {
|
||||
throw new Error('Failed to load WebAuthn library');
|
||||
}
|
||||
|
||||
// Check WebAuthn support
|
||||
const isSupported = window.SimpleWebAuthnBrowser.browserSupportsWebAuthn();
|
||||
if (!isSupported) {
|
||||
throw new Error('WebAuthn is not supported in this browser. Please use a modern browser like Chrome, Firefox, Safari, or Edge.');
|
||||
}
|
||||
|
||||
// Check if platform authenticator is available
|
||||
const isAvailable = await window.SimpleWebAuthnBrowser.platformAuthenticatorIsAvailable();
|
||||
if (!isAvailable) {
|
||||
updateStatus('Platform authenticator not available', 'info');
|
||||
updateInstructions('You can use a security key or your phone via QR code to create a passkey.');
|
||||
}
|
||||
|
||||
updateStatus('Initializing passkey registration...', 'info');
|
||||
updateInstructions('Preparing your authentication request...');
|
||||
|
||||
// Hybrid approach: Use Sonr presets but local server endpoints
|
||||
updateStatus('Initializing passkey registration...', 'info');
|
||||
updateInstructions('Preparing your authentication request...');
|
||||
|
||||
// Step 1: Get registration options from local server
|
||||
const optionsResponse = await fetch(API_URL + '/begin-register?username=' + encodeURIComponent(username));
|
||||
if (!optionsResponse.ok) {
|
||||
const error = await optionsResponse.json();
|
||||
throw new Error(error.error || 'Failed to get registration options');
|
||||
}
|
||||
const registrationOptions = await optionsResponse.json();
|
||||
console.log('Registration options:', registrationOptions);
|
||||
|
||||
updateStatus('Please interact with your authenticator...', 'processing');
|
||||
updateInstructions('You can use: 1) This device\'s biometrics, 2) A security key, or 3) Your phone via QR code (if prompted)');
|
||||
|
||||
// Step 2: Use SimpleWebAuthn to create credential
|
||||
const credential = await window.SimpleWebAuthnBrowser.startRegistration(registrationOptions);
|
||||
console.log('Created credential:', credential);
|
||||
|
||||
// Step 3: Send credential to local server to complete registration
|
||||
updateStatus('Completing registration...', 'processing');
|
||||
const finishResponse = await fetch(API_URL + '/finish-register?username=' + encodeURIComponent(username), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credential)
|
||||
});
|
||||
|
||||
if (!finishResponse.ok) {
|
||||
const error = await finishResponse.json();
|
||||
throw new Error(error.error || 'Failed to complete registration');
|
||||
}
|
||||
|
||||
const result = await finishResponse.json();
|
||||
console.log('Registration result:', result);
|
||||
|
||||
// Clear the countdown timer
|
||||
clearInterval(countdownInterval);
|
||||
|
||||
if (result.success) {
|
||||
updateStatus('Registration successful!', 'success');
|
||||
updateInstructions('Your passkey has been registered. Credential ID: ' + (result.credentialId || 'Created') + '. You can now close this window and return to the CLI.');
|
||||
updateTimeout(0);
|
||||
|
||||
// Store credential ID if provided
|
||||
if (result.credentialId) {
|
||||
sessionStorage.setItem('sonr_credential_id', result.credentialId);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Registration failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Clear the countdown timer
|
||||
clearInterval(countdownInterval);
|
||||
|
||||
console.error('Registration failed:', error);
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = error.message;
|
||||
if (error.name === 'NotAllowedError') {
|
||||
errorMessage = 'Registration was cancelled or not allowed';
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
errorMessage = 'An authenticator is already registered';
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
errorMessage = 'This authenticator is not supported';
|
||||
}
|
||||
|
||||
updateStatus('Registration failed', 'error');
|
||||
updateInstructions(errorMessage);
|
||||
updateTimeout(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Start registration when page loads
|
||||
window.addEventListener('load', () => {
|
||||
// Add a small delay to ensure all resources are loaded
|
||||
setTimeout(startRegistration, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// webAuthnLoginHTML contains the HTML template for WebAuthn login
|
||||
const webAuthnLoginHTML = `<!DOCTYPE html>
|
||||
<html class="dark">
|
||||
<head>
|
||||
<title>Sonr Local Login</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<style>
|
||||
:root {
|
||||
--sonr-primary: #17c2ff;
|
||||
--sonr-primary-hover: #0ea5e9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div class="bg-gray-800 p-8 rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-400 rounded-full mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Welcome Back to Sonr</h1>
|
||||
<p class="text-gray-400">Authenticating as: <span id="login-username-display" class="font-semibold text-cyan-400">{{.Username}}</span></p>
|
||||
</div>
|
||||
|
||||
<div id="status-container" class="mb-6">
|
||||
<div id="status" class="p-4 rounded-lg bg-blue-900/50 text-blue-300 text-sm font-medium">
|
||||
Initializing WebAuthn authentication...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="instructions" class="text-center text-gray-300 mb-6">
|
||||
Use your passkey or security key to authenticate.
|
||||
</div>
|
||||
|
||||
<div id="timeout-container" class="text-center text-sm text-gray-500">
|
||||
<span id="timeout-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load SimpleWebAuthn for WebAuthn operations -->
|
||||
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
|
||||
<!-- Load @sonr.io/es for presets and utilities -->
|
||||
<script type="module" src="https://unpkg.com/@sonr.io/es@latest/dist/autoloader.js"></script>
|
||||
|
||||
<script>
|
||||
const TIMEOUT_SECONDS = 30;
|
||||
// Support both username and identifier parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const username = urlParams.get('identifier') || urlParams.get('username') || "{{.Username}}";
|
||||
const rpId = "{{.RPID}}";
|
||||
const rpName = "{{.RPName}}";
|
||||
const API_URL = window.location.origin; // Use current origin as API URL
|
||||
|
||||
// Update the username display if it's from URL params
|
||||
if (urlParams.get('identifier') || urlParams.get('username')) {
|
||||
document.getElementById('login-username-display').textContent = username;
|
||||
}
|
||||
|
||||
function updateStatus(message, type = 'info') {
|
||||
const statusEl = document.getElementById('status');
|
||||
statusEl.textContent = message;
|
||||
|
||||
statusEl.className = 'p-4 rounded-lg text-sm font-medium ';
|
||||
if (type === 'success') {
|
||||
statusEl.className += 'bg-green-900/50 text-green-300';
|
||||
} else if (type === 'error') {
|
||||
statusEl.className += 'bg-red-900/50 text-red-300';
|
||||
} else {
|
||||
statusEl.className += 'bg-blue-900/50 text-blue-300';
|
||||
}
|
||||
}
|
||||
|
||||
function updateInstructions(text) {
|
||||
document.getElementById('instructions').textContent = text;
|
||||
}
|
||||
|
||||
function updateTimeout(seconds) {
|
||||
const timeoutEl = document.getElementById('timeout-text');
|
||||
if (seconds > 0) {
|
||||
timeoutEl.textContent = 'Authentication will timeout in ' + seconds + ' seconds';
|
||||
} else {
|
||||
timeoutEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function startLogin() {
|
||||
let countdownInterval;
|
||||
let remainingSeconds = TIMEOUT_SECONDS;
|
||||
|
||||
try {
|
||||
// Wait for Sonr to be ready
|
||||
await new Promise((resolve) => {
|
||||
if (window.Sonr && window.Sonr.initialized) {
|
||||
resolve();
|
||||
} else {
|
||||
window.addEventListener('sonr:ready', resolve);
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (window.Sonr) resolve();
|
||||
else throw new Error('Failed to load Sonr library');
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
if (!window.Sonr) {
|
||||
throw new Error('Failed to load Sonr authentication library');
|
||||
}
|
||||
|
||||
// Check WebAuthn support
|
||||
const support = await window.Sonr.webauthn.checkSupport();
|
||||
if (!support.supported) {
|
||||
throw new Error('WebAuthn is not supported in this browser. Please use a modern browser like Chrome, Firefox, Safari, or Edge.');
|
||||
}
|
||||
|
||||
if (!support.platformAuthenticator) {
|
||||
updateStatus('Platform authenticator not available', 'info');
|
||||
updateInstructions('You can still use a security key or phone-based passkey to authenticate.');
|
||||
}
|
||||
|
||||
updateStatus('Initializing passkey authentication...', 'info');
|
||||
updateInstructions('Preparing your authentication request...');
|
||||
|
||||
// Start countdown timer
|
||||
countdownInterval = setInterval(() => {
|
||||
remainingSeconds--;
|
||||
updateTimeout(remainingSeconds);
|
||||
if (remainingSeconds <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
updateStatus('Authentication timed out', 'error');
|
||||
updateInstructions('Please refresh the page to try again.');
|
||||
}
|
||||
}, 1000);
|
||||
updateTimeout(remainingSeconds);
|
||||
|
||||
// Hybrid approach: Use Sonr presets but local server endpoints
|
||||
updateStatus('Preparing authentication...', 'info');
|
||||
|
||||
// Step 1: Get authentication options from local server
|
||||
const optionsResponse = await fetch(API_URL + '/begin-login?username=' + encodeURIComponent(username));
|
||||
if (!optionsResponse.ok) {
|
||||
const error = await optionsResponse.json();
|
||||
throw new Error(error.error || 'Failed to get authentication options');
|
||||
}
|
||||
const authOptions = await optionsResponse.json();
|
||||
console.log('Authentication options:', authOptions);
|
||||
|
||||
updateStatus('Waiting for your passkey authentication...', 'info');
|
||||
updateInstructions('Use your saved passkey from: 1) This device, 2) A security key, or 3) Your phone');
|
||||
|
||||
// Step 2: Use SimpleWebAuthn to authenticate
|
||||
const credential = await window.SimpleWebAuthnBrowser.startAuthentication(authOptions);
|
||||
console.log('Authentication credential:', credential);
|
||||
|
||||
// Step 3: Send credential to local server to complete authentication
|
||||
updateStatus('Verifying authentication...', 'info');
|
||||
const finishResponse = await fetch(API_URL + '/finish-login?username=' + encodeURIComponent(username), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credential)
|
||||
});
|
||||
|
||||
if (!finishResponse.ok) {
|
||||
const error = await finishResponse.json();
|
||||
throw new Error(error.error || 'Failed to complete authentication');
|
||||
}
|
||||
|
||||
const result = await finishResponse.json();
|
||||
console.log('Authentication result:', result);
|
||||
|
||||
// Clear the countdown timer
|
||||
clearInterval(countdownInterval);
|
||||
|
||||
if (result.success) {
|
||||
updateStatus('Authentication successful!', 'success');
|
||||
updateInstructions('Welcome back! Credential ID: ' + (result.credentialId || 'Authenticated') + '. You can close this window and return to the CLI.');
|
||||
updateTimeout(0);
|
||||
|
||||
// Store credential ID if provided
|
||||
if (result.credentialId) {
|
||||
sessionStorage.setItem('sonr_credential_id', result.credentialId);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Clear the countdown timer
|
||||
clearInterval(countdownInterval);
|
||||
|
||||
console.error('Authentication failed:', error);
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = error.message;
|
||||
if (error.name === 'NotAllowedError') {
|
||||
errorMessage = 'Authentication was cancelled or not allowed';
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
errorMessage = 'No matching credential found';
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
errorMessage = 'This authenticator is not supported';
|
||||
}
|
||||
|
||||
updateStatus('Authentication failed', 'error');
|
||||
updateInstructions(errorMessage);
|
||||
updateTimeout(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Start login when page loads
|
||||
window.addEventListener('load', () => {
|
||||
// Add a small delay to ensure all resources are loaded
|
||||
setTimeout(startLogin, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user