lookbook/internal/handlers/api_auth.go
soup 523831cb8d
Add password change functionality
- Add POST /api/auth/password API endpoint
  - Requires authentication and current password verification
  - Invalidates all other sessions after password change
  - Keeps current session active

- Add window.changePassword() console function
  - Matches existing login flow pattern
  - Usage: changePassword("current", "new")

- Add 'lookbook set-password' CLI command
  - Interactive password reset (no current password required)
  - Useful for recovery scenarios
  - Invalidates all sessions

- Add session.QDeleteAllExcept() and session.QDeleteAll()
  - Support for invalidating sessions after password change
2026-01-17 22:28:13 -05:00

195 lines
5 KiB
Go

package handlers
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
"lookbook/internal/data/admin"
"lookbook/internal/data/session"
)
const sessionDuration = 30 * 24 * time.Hour // 30 days
type loginRequest struct {
Password string `json:"password"`
}
type loginResponse struct {
FirstTime bool `json:"firstTime,omitempty"`
Error string `json:"error,omitempty"`
}
type changePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
// HandleLogin handles POST /api/auth/login
// If no password is set, it sets the password. Otherwise, it verifies the password.
func HandleLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, loginResponse{Error: "invalid request"})
}
if req.Password == "" {
return writeJSON(w, http.StatusBadRequest, loginResponse{Error: "password required"})
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
adm, err := admin.QGet(ctx, rc.DB)
if err != nil {
return err
}
firstTime := adm.PasswordHash == nil
if firstTime {
// First login: set the password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := admin.QSetPassword(ctx, rc.DB, hash); err != nil {
return err
}
} else {
// Verify password
if err := bcrypt.CompareHashAndPassword(adm.PasswordHash, []byte(req.Password)); err != nil {
return writeJSON(w, http.StatusUnauthorized, loginResponse{Error: "invalid password"})
}
}
// Create session
sessionID, err := generateSessionID()
if err != nil {
return err
}
expiresAt := time.Now().Add(sessionDuration)
if _, err := session.QCreate(ctx, rc.DB, sessionID, expiresAt); err != nil {
return err
}
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
})
return writeJSON(w, http.StatusOK, loginResponse{FirstTime: firstTime})
}
// HandleLogout handles POST /api/auth/logout
func HandleLogout(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
cookie, err := r.Cookie("session_id")
if err == nil {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
session.QDelete(ctx, rc.DB, cookie.Value)
}
// Clear cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// HandleAuthStatus handles GET /api/auth/status
func HandleAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
hasPassword, err := admin.QHasPassword(ctx, rc.DB)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, map[string]any{
"isAdmin": rc.IsAdmin,
"passwordSet": hasPassword,
})
}
// HandleChangePassword handles POST /api/auth/password
func HandleChangePassword(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.CurrentPassword == "" || req.NewPassword == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "both passwords required"})
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Get current password hash
adm, err := admin.QGet(ctx, rc.DB)
if err != nil {
return err
}
// Verify current password
if err := bcrypt.CompareHashAndPassword(adm.PasswordHash, []byte(req.CurrentPassword)); err != nil {
return writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid current password"})
}
// Hash new password
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
// Update password
if err := admin.QSetPassword(ctx, rc.DB, hash); err != nil {
return err
}
// Invalidate all other sessions (keep current session)
cookie, _ := r.Cookie("session_id")
if cookie != nil {
session.QDeleteAllExcept(ctx, rc.DB, cookie.Value)
}
return writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func generateSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func writeJSON(w http.ResponseWriter, status int, v any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(v)
}