Files
passkey-auth/internal/handlers/handlers.go
T

633 lines
18 KiB
Go

package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/sirupsen/logrus"
"passkey-auth/internal/auth"
"passkey-auth/internal/config"
"passkey-auth/internal/database"
)
type Handlers struct {
db *database.DB
webAuthn *auth.WebAuthn
config *config.Config
store *sessions.CookieStore
}
func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handlers {
webAuthn.SetDB(db)
store := sessions.NewCookieStore([]byte(config.Auth.SessionSecret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
Domain: config.Auth.CookieDomain, // Share cookies across subdomains if configured
}
return &Handlers{
db: db,
webAuthn: webAuthn,
config: config,
store: store,
}
}
func (h *Handlers) writeError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(map[string]string{"error": message}); err != nil {
// If we can't encode the error response, log it
// Don't try to write another response as headers are already sent
log.Printf("Failed to encode error response: %v", err)
}
}
func (h *Handlers) writeJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
// If encoding fails, try to send a simple error response
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// BeginRegistration starts the passkey registration process
func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" || req.DisplayName == "" {
h.writeError(w, "Email and display name are required", http.StatusBadRequest)
return
}
// Check if email is allowed
if !h.config.IsEmailAllowed(req.Email) {
h.writeError(w, "Email address not allowed", http.StatusForbidden)
return
}
// Check if user already exists
existingUser, err := h.db.GetUserByEmail(req.Email)
if err == nil && existingUser != nil {
h.writeError(w, "User already exists", http.StatusConflict)
return
}
// Create a temporary WebAuthn user for registration without saving to DB yet
isAdmin := h.config.IsAdmin(req.Email)
tempUser := &database.User{
Email: req.Email,
DisplayName: req.DisplayName,
Approved: isAdmin, // Auto-approve admins
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(tempUser)
options, sessionData, err := h.webAuthn.BeginRegistration(webAuthnUser)
if err != nil {
logrus.Errorf("Failed to begin registration: %v", err)
h.writeError(w, "Failed to begin registration", http.StatusInternalServerError)
return
}
// Store session data with user details for later creation
session, _ := h.store.Get(r, "webauthn-session")
session.Values["challenge"] = sessionData.Challenge
session.Values["pending_email"] = req.Email
session.Values["pending_display_name"] = req.DisplayName
session.Values["pending_is_admin"] = isAdmin
if err := session.Save(r, w); err != nil {
h.writeError(w, "Failed to save session", http.StatusInternalServerError)
return
}
// Debug: log the options structure
logrus.Debugf("WebAuthn options: %+v", options)
logrus.Debugf("Challenge type: %T", options.Response.Challenge)
logrus.Debugf("Challenge value: %v", options.Response.Challenge)
logrus.Debugf("User ID type: %T", options.Response.User.ID)
logrus.Debugf("User ID value: %v", options.Response.User.ID)
h.writeJSON(w, options)
}
// FinishRegistration completes the passkey registration process
func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "webauthn-session")
// Get pending user data from session instead of user_id
pendingEmail, ok := session.Values["pending_email"].(string)
if !ok {
h.writeError(w, "Invalid session - no pending registration", http.StatusBadRequest)
return
}
pendingDisplayName, ok := session.Values["pending_display_name"].(string)
if !ok {
h.writeError(w, "Invalid session - missing display name", http.StatusBadRequest)
return
}
pendingIsAdmin, ok := session.Values["pending_is_admin"].(bool)
if !ok {
h.writeError(w, "Invalid session - missing admin flag", http.StatusBadRequest)
return
}
challenge, ok := session.Values["challenge"].(string)
if !ok {
h.writeError(w, "Invalid session - missing challenge", http.StatusBadRequest)
return
}
// Read and log the request body for debugging
body, err := io.ReadAll(r.Body)
if err != nil {
h.writeError(w, "Failed to read request body", http.StatusBadRequest)
return
}
logrus.Debugf("Received credential response body: %s", string(body))
// Create a new reader from the body for the WebAuthn library
r.Body = io.NopCloser(strings.NewReader(string(body)))
// Create temporary user for WebAuthn verification
tempUser := &database.User{
Email: pendingEmail,
DisplayName: pendingDisplayName,
Approved: pendingIsAdmin,
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(tempUser)
sessionData := webauthn.SessionData{
Challenge: challenge,
UserID: webAuthnUser.WebAuthnID(),
}
// Log the request details for debugging
logrus.Debugf("Finishing registration for user: %s", pendingEmail)
logrus.Debugf("Session challenge: %s", challenge)
logrus.Debugf("Session user ID: %v", webAuthnUser.WebAuthnID())
credential, err := h.webAuthn.FinishRegistration(webAuthnUser, sessionData, r)
if err != nil {
logrus.Errorf("Failed to finish registration: %v", err)
h.writeError(w, "Failed to finish registration", http.StatusInternalServerError)
return
}
// Only NOW create the user in the database after successful passkey registration
user, err := h.db.CreateUserWithApproval(pendingEmail, pendingDisplayName, pendingIsAdmin)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
h.writeError(w, "User already exists", http.StatusConflict)
return
}
logrus.Errorf("Failed to create user: %v", err)
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
return
}
if pendingIsAdmin {
logrus.Infof("Admin user auto-approved: %s", pendingEmail)
}
// Save credential to database
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
logrus.Errorf("Failed to save credential: %v", err)
// If we can't save the credential, we should remove the user we just created
if deleteErr := h.db.DeleteUser(user.ID); deleteErr != nil {
logrus.Errorf("Failed to cleanup user after credential save failure: %v", deleteErr)
}
h.writeError(w, "Failed to save credential", http.StatusInternalServerError)
return
}
// Set authenticated session after successful registration
authSession, _ := h.store.Get(r, "auth-session")
authSession.Values["authenticated"] = true
authSession.Values["user_id"] = user.ID
authSession.Values["user_email"] = user.Email
if err := authSession.Save(r, w); err != nil {
h.writeError(w, "Failed to save auth session", http.StatusInternalServerError)
return
}
// Clear webauthn session
session.Values["challenge"] = nil
session.Values["pending_email"] = nil
session.Values["pending_display_name"] = nil
session.Values["pending_is_admin"] = nil
if err := session.Save(r, w); err != nil {
log.Printf("Failed to save session: %v", err)
// Don't return error here as the main operation succeeded
}
h.writeJSON(w, map[string]string{"status": "success"})
}
// BeginLogin starts the passkey authentication process
func (h *Handlers) BeginLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
webAuthnUser, err := h.webAuthn.GetUserByEmail(req.Email)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
// Check if user is approved (if required)
if h.config.Auth.RequireApproval && !webAuthnUser.GetUser().Approved {
h.writeError(w, "User not approved", http.StatusForbidden)
return
}
options, sessionData, err := h.webAuthn.BeginLogin(webAuthnUser)
if err != nil {
logrus.Errorf("Failed to begin login: %v", err)
h.writeError(w, "Failed to begin login", http.StatusInternalServerError)
return
}
// Debug: log the login options structure
logrus.Debugf("Login options: %+v", options)
logrus.Debugf("Login challenge type: %T", options.Response.Challenge)
logrus.Debugf("Login challenge value: %v", options.Response.Challenge)
if len(options.Response.AllowedCredentials) > 0 {
logrus.Debugf("AllowedCredentials count: %d", len(options.Response.AllowedCredentials))
for i, cred := range options.Response.AllowedCredentials {
logrus.Debugf("Credential %d ID type: %T", i, cred.CredentialID)
logrus.Debugf("Credential %d ID value: %v", i, cred.CredentialID)
}
}
// Store session data
session, _ := h.store.Get(r, "webauthn-session")
session.Values["challenge"] = sessionData.Challenge
session.Values["user_id"] = webAuthnUser.GetUser().ID
if err := session.Save(r, w); err != nil {
h.writeError(w, "Failed to save session", http.StatusInternalServerError)
return
}
h.writeJSON(w, options)
}
// FinishLogin completes the passkey authentication process
func (h *Handlers) FinishLogin(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "webauthn-session")
userID, ok := session.Values["user_id"].(int)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
return
}
challenge, ok := session.Values["challenge"].(string)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
return
}
user, err := h.db.GetUser(userID)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
webAuthnUser, err := h.webAuthn.GetUserByEmail(user.Email)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
sessionData := webauthn.SessionData{
Challenge: challenge,
UserID: webAuthnUser.WebAuthnID(),
}
credential, err := h.webAuthn.FinishLogin(webAuthnUser, sessionData, r)
if err != nil {
logrus.Errorf("Failed to finish login: %v", err)
h.writeError(w, "Authentication failed", http.StatusUnauthorized)
return
}
// Update credential sign count
if err := h.webAuthn.UpdateCredentialSignCount(credential.ID, credential.Authenticator.SignCount); err != nil {
logrus.Errorf("Failed to update sign count: %v", err)
}
// Set authenticated session
authSession, _ := h.store.Get(r, "auth-session")
authSession.Values["authenticated"] = true
authSession.Values["user_id"] = user.ID
authSession.Values["user_email"] = user.Email
if err := authSession.Save(r, w); err != nil {
h.writeError(w, "Failed to save auth session", http.StatusInternalServerError)
return
}
// Clear webauthn session
session.Values["challenge"] = nil
session.Values["user_id"] = nil
if err := session.Save(r, w); err != nil {
log.Printf("Failed to save session: %v", err)
// Don't return error here as the main operation succeeded
}
h.writeJSON(w, map[string]interface{}{
"status": "success",
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"display_name": user.DisplayName,
},
})
}
// Logout clears the authentication session
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "auth-session")
session.Values["authenticated"] = false
session.Values["user_id"] = nil
session.Values["user_email"] = nil
session.Options.MaxAge = -1
if err := session.Save(r, w); err != nil {
log.Printf("Failed to save session during logout: %v", err)
// Don't return error here as logout should still succeed
}
h.writeJSON(w, map[string]string{"status": "success"})
}
// AuthCheck implements the nginx auth_request protocol
func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
// Debug logging
logrus.Debugf("AuthCheck request from %s", r.RemoteAddr)
logrus.Debugf("AuthCheck headers: %+v", r.Header)
logrus.Debugf("AuthCheck cookies: %+v", r.Cookies())
session, err := h.store.Get(r, "auth-session")
if err != nil {
logrus.Errorf("Failed to get auth session: %v", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
authenticated, ok := session.Values["authenticated"].(bool)
logrus.Debugf("Session authenticated: %v, ok: %v", authenticated, ok)
logrus.Debugf("Session values: %+v", session.Values)
if !ok || !authenticated {
logrus.Debugf("User not authenticated, returning 401")
w.WriteHeader(http.StatusUnauthorized)
return
}
// Optional: Add user info to response headers
if userID, ok := session.Values["user_id"].(int); ok {
w.Header().Set("X-Auth-User-ID", strconv.Itoa(userID))
}
if userEmail, ok := session.Values["user_email"].(string); ok {
w.Header().Set("X-Auth-User", userEmail)
}
logrus.Debugf("User authenticated, returning 200")
w.WriteHeader(http.StatusOK)
}
// GetAuthStatus returns the current authentication status
func (h *Handlers) GetAuthStatus(w http.ResponseWriter, r *http.Request) {
session, err := h.store.Get(r, "auth-session")
if err != nil {
h.writeError(w, "Failed to get session", http.StatusInternalServerError)
return
}
userEmail, ok := session.Values["user_email"].(string)
if !ok || userEmail == "" {
h.writeError(w, "Not authenticated", http.StatusUnauthorized)
return
}
// Get user details from database
user, err := h.db.GetUserByEmail(userEmail)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
// Check if user is approved
if !user.Approved {
h.writeError(w, "User not approved", http.StatusForbidden)
return
}
response := map[string]interface{}{
"authenticated": true,
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"display_name": user.DisplayName,
"approved": user.Approved,
"is_admin": h.config.IsAdmin(user.Email),
},
}
h.writeJSON(w, response)
}
// GetConfig returns public configuration data
func (h *Handlers) GetConfig(w http.ResponseWriter, r *http.Request) {
configData := map[string]interface{}{
"default_email": h.config.Auth.DefaultEmail,
}
h.writeJSON(w, configData)
}
// Admin endpoints
// ListUsers returns all users (admin endpoint)
func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
users, err := h.db.ListUsers()
if err != nil {
logrus.Errorf("Failed to list users: %v", err)
h.writeError(w, "Failed to list users", http.StatusInternalServerError)
return
}
h.writeJSON(w, users)
}
// CreateUser creates a new user (admin endpoint)
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
var req struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Approved bool `json:"approved"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Check if email is allowed
if !h.config.IsEmailAllowed(req.Email) {
h.writeError(w, "Email address not allowed", http.StatusForbidden)
return
}
user, err := h.db.CreateUserWithApproval(req.Email, req.DisplayName, req.Approved)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
h.writeError(w, "User already exists", http.StatusConflict)
return
}
logrus.Errorf("Failed to create user: %v", err)
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
return
}
h.writeJSON(w, user)
}
// UpdateUser updates a user (admin endpoint)
func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
idStr, ok := vars["id"]
if !ok {
h.writeError(w, "User ID required", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
h.writeError(w, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
Approved *bool `json:"approved"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get existing user
user, err := h.db.GetUser(id)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
// Update approval status if provided
if req.Approved != nil && *req.Approved {
if err := h.db.ApproveUser(id); err != nil {
logrus.Errorf("Failed to approve user: %v", err)
h.writeError(w, "Failed to approve user", http.StatusInternalServerError)
return
}
user.Approved = true
logrus.Infof("User approved: %s", user.Email)
}
h.writeJSON(w, user)
}
// DeleteUser deletes a user (admin endpoint)
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
idStr, ok := vars["id"]
if !ok {
h.writeError(w, "User ID required", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
h.writeError(w, "Invalid user ID", http.StatusBadRequest)
return
}
if err := h.db.DeleteUser(id); err != nil {
logrus.Errorf("Failed to delete user: %v", err)
h.writeError(w, "Failed to delete user", http.StatusInternalServerError)
return
}
h.writeJSON(w, map[string]string{"status": "success"})
}
func (h *Handlers) isAdmin(r *http.Request) bool {
session, err := h.store.Get(r, "auth-session")
if err != nil {
return false
}
userEmail, ok := session.Values["user_email"].(string)
if !ok {
return false
}
return h.config.IsAdmin(userEmail)
}
func (h *Handlers) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if !h.isAdmin(r) {
h.writeError(w, "Admin access required", http.StatusForbidden)
return false
}
return true
}