478 lines
15 KiB
Go
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")
|
|
}
|