package handlers import ( "crypto/rand" "encoding/base64" "encoding/json" "fmt" "net/http" "time" "golang.org/x/crypto/bcrypt" "git.soup.land/soup/lookbook/internal/data/auth" "git.soup.land/soup/lookbook/internal/data/session" ) const sessionCookieName = "lookbook_session" const sessionDuration = 30 * 24 * time.Hour type authRequest struct { Password string `json:"password"` } type authStatus struct { Authenticated bool `json:"authenticated"` HasPassword bool `json:"has_password"` } func HandlePostAuthLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { var req authRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return nil } if req.Password == "" { http.Error(w, "Password required", http.StatusBadRequest) return nil } existing, err := auth.QGet(r.Context(), rc.DB) if err != nil { return fmt.Errorf("get auth: %w", err) } if existing == nil { hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash password: %w", err) } if _, err := auth.QCreate(r.Context(), rc.DB, hash); err != nil { return fmt.Errorf("create password: %w", err) } } else { if err := bcrypt.CompareHashAndPassword(existing.PasswordHash, []byte(req.Password)); err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return nil } } token, err := newToken(32) if err != nil { return fmt.Errorf("token: %w", err) } expiresAt := time.Now().Add(sessionDuration) if _, err := session.QCreate(r.Context(), rc.DB, token, expiresAt); err != nil { return fmt.Errorf("create session: %w", err) } setSessionCookie(w, r, token, expiresAt) w.WriteHeader(http.StatusNoContent) return nil } func HandlePostAuthLogout(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { cookie, err := r.Cookie(sessionCookieName) if err == nil { _ = session.QDelete(r.Context(), rc.DB, cookie.Value) } clearSessionCookie(w, r) w.WriteHeader(http.StatusNoContent) return nil } func HandleGetAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { status := authStatus{} existing, err := auth.QGet(r.Context(), rc.DB) if err != nil { return fmt.Errorf("get auth: %w", err) } status.HasPassword = existing != nil authed, err := isAuthenticated(r, rc) if err != nil { return fmt.Errorf("auth status: %w", err) } status.Authenticated = authed w.Header().Set("Content-Type", "application/json") return json.NewEncoder(w).Encode(status) } func isAuthenticated(r *http.Request, rc *RequestContext) (bool, error) { cookie, err := r.Cookie(sessionCookieName) if err != nil { return false, nil } row, err := session.QFindByToken(r.Context(), rc.DB, cookie.Value) if err != nil { return false, err } if row == nil { return false, nil } if time.Now().After(row.ExpiresAt) { _ = session.QDelete(r.Context(), rc.DB, cookie.Value) return false, nil } return true, nil } func requireAuth(w http.ResponseWriter, r *http.Request, rc *RequestContext) bool { authed, err := isAuthenticated(r, rc) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return false } if !authed { http.Error(w, "Unauthorized", http.StatusUnauthorized) return false } return true } func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: expiresAt, }) } func clearSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: time.Unix(0, 0), }) } func newToken(length int) (string, error) { buf := make([]byte, length) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil }