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

576 lines
16 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,
}
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 new user
isAdmin := h.config.IsAdmin(req.Email)
user, err := h.db.CreateUserWithApproval(req.Email, req.DisplayName, isAdmin)
if err != nil {
logrus.Errorf("Failed to create user: %v", err)
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
return
}
if isAdmin {
logrus.Infof("Admin user auto-approved: %s", req.Email)
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(user)
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
session, _ := h.store.Get(r, "webauthn-session")
session.Values["challenge"] = sessionData.Challenge
session.Values["user_id"] = user.ID
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")
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
}
// 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)))
user, err := h.db.GetUser(userID)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(user)
sessionData := webauthn.SessionData{
Challenge: challenge,
UserID: webAuthnUser.WebAuthnID(),
}
// Log the request details for debugging
logrus.Debugf("Finishing registration for user: %s", user.Email)
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
}
// Save credential to database
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
logrus.Errorf("Failed to save credential: %v", err)
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["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]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) {
session, _ := h.store.Get(r, "auth-session")
authenticated, ok := session.Values["authenticated"].(bool)
if !ok || !authenticated {
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)
}
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)
}
// 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
}