ultisuite-backend/internal/api/auth/handlers.go
R3D347HR4Y e4549f29b2
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(authentik): implement password recovery flow and API integration
- Added a new blueprint for password recovery (`05-ulti-recovery.yaml`) to facilitate user password reset via email.
- Introduced a new API handler for managing Authentik flow sessions, including starting and responding to flows.
- Implemented flow session management with in-memory storage for tracking user sessions during the recovery process.
- Enhanced error handling for flow session operations and added unit tests for the new functionalities.
- Updated README to include the new recovery flow in the Authentik blueprints documentation.
2026-06-19 22:34:29 +02:00

179 lines
4.7 KiB
Go

package authapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/authentik"
)
const (
FlowEnrollment = "ulti-enrollment"
FlowRecovery = "ulti-recovery"
flowSessionCookie = "ulti_flow_session"
flowSessionMaxAge = 20 * time.Minute
)
var allowedFlowSlugs = map[string]struct{}{
FlowEnrollment: {},
FlowRecovery: {},
}
// Handler exposes public Authentik flow executor endpoints for custom auth UI.
type Handler struct {
flows *authentik.FlowSessionStore
}
func NewHandler(baseURL string) *Handler {
return &Handler{
flows: authentik.NewFlowSessionStore(baseURL),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/flows/{slug}/start", h.StartFlow)
r.Post("/flows/{slug}/respond", h.RespondFlow)
return r
}
type flowStartResponse struct {
SessionID string `json:"sessionId"`
Challenge authentik.FlowChallenge `json:"challenge"`
Done bool `json:"done"`
Denied bool `json:"denied"`
}
type flowRespondRequest struct {
Payload map[string]any `json:"payload"`
}
func (h *Handler) StartFlow(w http.ResponseWriter, r *http.Request) {
slug, ok := validateFlowSlug(w, r, chi.URLParam(r, "slug"))
if !ok {
return
}
query := strings.TrimSpace(r.URL.Query().Get("query"))
sessionID, challenge, err := h.flows.Start(r.Context(), slug, query)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadGateway, "flow_start_failed", err.Error(), nil)
return
}
done, denied := authentik.FlowDone(challenge)
if !done {
setFlowSessionCookie(w, sessionID)
} else {
clearFlowSessionCookie(w)
}
writeFlowJSON(w, http.StatusOK, flowStartResponse{
SessionID: sessionID,
Challenge: challenge,
Done: done,
Denied: denied,
})
}
func (h *Handler) RespondFlow(w http.ResponseWriter, r *http.Request) {
slug, ok := validateFlowSlug(w, r, chi.URLParam(r, "slug"))
if !ok {
return
}
sessionID := readFlowSessionCookie(r)
if sessionID == "" {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "flow_session_missing", "flow session cookie required", nil)
return
}
var req flowRespondRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_json", "invalid request body", nil)
return
}
if req.Payload == nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_payload", "payload required", nil)
return
}
query := strings.TrimSpace(r.URL.Query().Get("query"))
challenge, err := h.flows.Respond(r.Context(), sessionID, slug, query, req.Payload)
if err != nil {
if errors.Is(err, authentik.ErrFlowSessionNotFound) {
clearFlowSessionCookie(w)
apiresponse.WriteError(w, r, http.StatusGone, "flow_session_expired", "flow session expired; restart the flow", nil)
return
}
if errors.Is(err, authentik.ErrFlowSessionSlugMismatch) {
apiresponse.WriteError(w, r, http.StatusConflict, "flow_session_mismatch", "flow slug does not match active session", nil)
return
}
apiresponse.WriteError(w, r, http.StatusBadGateway, "flow_respond_failed", err.Error(), nil)
return
}
done, denied := authentik.FlowDone(challenge)
if done {
clearFlowSessionCookie(w)
}
writeFlowJSON(w, http.StatusOK, flowStartResponse{
SessionID: sessionID,
Challenge: challenge,
Done: done,
Denied: denied,
})
}
func validateFlowSlug(w http.ResponseWriter, r *http.Request, slug string) (string, bool) {
slug = strings.TrimSpace(slug)
if slug == "" {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_flow", "flow slug required", nil)
return "", false
}
if _, ok := allowedFlowSlugs[slug]; !ok {
apiresponse.WriteError(w, r, http.StatusNotFound, "flow_not_allowed", "flow slug not allowed", nil)
return "", false
}
return slug, true
}
func setFlowSessionCookie(w http.ResponseWriter, sessionID string) {
http.SetCookie(w, &http.Cookie{
Name: flowSessionCookie,
Value: sessionID,
Path: "/api/v1/auth/flows",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(flowSessionMaxAge.Seconds()),
})
}
func clearFlowSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: flowSessionCookie,
Value: "",
Path: "/api/v1/auth/flows",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func readFlowSessionCookie(r *http.Request) string {
c, err := r.Cookie(flowSessionCookie)
if err != nil || c == nil {
return ""
}
return strings.TrimSpace(c.Value)
}
func writeFlowJSON(w http.ResponseWriter, status int, payload flowStartResponse) {
apiresponse.WriteJSON(w, status, payload)
}