Files
cairnquire/apps/server/internal/httpserver/auth_handlers.go
2026-05-28 08:35:50 -04:00

478 lines
15 KiB
Go

package httpserver
import (
"encoding/json"
"html/template"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/auth"
)
type passwordRegisterRequest struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
Role string `json:"role"`
}
type passwordLoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type passkeyBeginRequest struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
}
type passkeyFinishRequest struct {
ChallengeID string `json:"challengeId"`
}
type createTokenRequest struct {
Name string `json:"name"`
Scopes []auth.Scope `json:"scopes"`
ExpiresAt *time.Time `json:"expiresAt"`
}
type deviceStartRequest struct {
Name string `json:"name"`
Scopes []auth.Scope `json:"scopes"`
}
type changePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
type deleteAccountRequest struct {
CurrentPassword string `json:"currentPassword"`
}
type updateUserRoleRequest struct {
Role string `json:"role"`
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if _, ok := requirePrincipal(r); ok {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
nextPath := r.URL.Query().Get("next")
if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
nextPath = "/account"
}
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
Title: "Sign in",
WebEnabled: s.webEnabled,
BodyClass: "page-auth",
BodyTemplate: "login_content",
Data: struct {
Next string
}{Next: nextPath},
})
}
func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
Title: "Account",
WebEnabled: s.webEnabled,
BodyClass: "page-account",
BodyTemplate: "account_content",
Data: principal,
})
}
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handlePasswordRegister(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordRegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
user, err := s.auth.RegisterPasswordUser(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": publicUser(user)})
}
func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
if err != nil {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
setSessionCookie(w, r, token, principal.ExpiresAt)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if principal.SessionID != "" {
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
}
clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.ChangePassword(r.Context(), principal, req.CurrentPassword, req.NewPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
}
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req deleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.DeleteAccount(r.Context(), principal, req.CurrentPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "account_deleted"})
}
func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passkeyBeginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
options, challengeID, err := s.auth.BeginPasskeyRegistration(r.Context(), req.Email, req.DisplayName, req.Role)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
}
func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
challengeID := r.URL.Query().Get("challengeId")
if challengeID == "" {
var req passkeyFinishRequest
_ = json.NewDecoder(r.Body).Decode(&req)
challengeID = req.ChallengeID
}
if challengeID == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
return
}
user, err := s.auth.FinishPasskeyRegistration(r.Context(), challengeID, r)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
}
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passkeyBeginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
options, challengeID, err := s.auth.BeginPasskeyLogin(r.Context(), req.Email)
if err != nil {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
}
func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
challengeID := r.URL.Query().Get("challengeId")
if challengeID == "" {
var req passkeyFinishRequest
_ = json.NewDecoder(r.Body).Decode(&req)
challengeID = req.ChallengeID
}
if challengeID == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
return
}
principal, token, err := s.auth.FinishPasskeyLogin(r.Context(), challengeID, r)
if err != nil {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
setSessionCookie(w, r, token, principal.ExpiresAt)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
keys, err := s.auth.ListAPIKeys(r.Context(), principal.UserID)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": keys})
}
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req createTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
created, err := s.auth.CreateAPIKey(r.Context(), principal.UserID, req.Name, req.Scopes, req.ExpiresAt)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusCreated, created)
}
func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
keyID := chi.URLParam(r, "id")
if !strings.HasPrefix(keyID, "api:") {
keyID = "api:" + keyID
}
if err := s.auth.RevokeAPIKey(r.Context(), principal.UserID, keyID); err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "token not found"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req deviceStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
start, err := s.auth.StartDeviceFlow(r.Context(), req.Name, req.Scopes)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, start)
}
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req struct {
UserCode string `json:"userCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
device, err := s.auth.ApproveDeviceFlow(r.Context(), principal, req.UserCode)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired code"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "approved", "device": map[string]any{"name": device.Name, "scopes": device.Scopes}})
}
func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req struct {
DeviceCode string `json:"deviceCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
created, code, err := s.auth.PollDeviceFlow(r.Context(), req.DeviceCode)
if err != nil {
status := http.StatusBadRequest
if code == "authorization_pending" {
status = http.StatusAccepted
}
writeJSONWithStatus(w, status, map[string]string{"error": code})
return
}
writeJSON(w, http.StatusOK, created)
}
func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
userCode := template.HTMLEscapeString(r.URL.Query().Get("user_code"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><html><body><main><h1>Approve Device</h1><p>Sign in, then approve this code with <code>POST /api/device/approve</code>.</p><p>User code: <strong>` + userCode + `</strong></p></main></body></html>`))
}
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
Title: "Approve device",
WebEnabled: s.webEnabled,
BodyClass: "page-auth",
BodyTemplate: "device_verify_content",
Data: struct {
UserCode string
}{UserCode: userCode},
})
}
func (s *Server) handleAdminUpdateUserRole(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
userID := chi.URLParam(r, "id")
var req updateUserRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
user, err := s.auth.UpdateUserRole(r.Context(), principal, userID, req.Role)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
}
func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.auth.ListUsers(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
public := make([]map[string]any, 0, len(users))
for _, user := range users {
public = append(public, publicUser(user))
}
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
if s.authLimiter.Allow(authRateLimitKey(r)) {
return true
}
writeJSONWithStatus(w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"})
return false
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func publicUser(user auth.User) map[string]any {
return map[string]any{
"id": user.ID,
"email": user.Email,
"displayName": user.DisplayName,
"role": user.Role,
"createdAt": user.CreatedAt,
"lastSeenAt": user.LastSeenAt,
}
}
func requireSessionPrincipal(w http.ResponseWriter, principal auth.Principal) bool {
if principal.Method == "session" {
return true
}
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "browser session required"})
return false
}
func requestIP(r *http.Request) string {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
}
return r.RemoteAddr
}
func isSecureRequest(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}