- 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
195 lines
5 KiB
Go
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)
|
|
}
|