From e4549f29b220c4fdfdf767db060459eb75fcb300 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 19 Jun 2026 22:34:29 +0200 Subject: [PATCH] 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. --- deploy/authentik/README.md | 1 + .../blueprints/05-ulti-recovery.yaml | 170 ++++++++++++++++ internal/api/auth/handlers.go | 178 +++++++++++++++++ internal/authentik/api_base.go | 15 ++ internal/authentik/api_base_test.go | 19 ++ internal/authentik/client.go | 2 +- internal/authentik/flow_errors.go | 8 + internal/authentik/flow_executor.go | 183 ++++++++++++++++++ internal/authentik/flow_executor_test.go | 45 +++++ internal/authentik/flow_session_store.go | 116 +++++++++++ internal/authentik/users_test.go | 4 +- internal/server/bootstrap.go | 2 + 12 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 deploy/authentik/blueprints/05-ulti-recovery.yaml create mode 100644 internal/api/auth/handlers.go create mode 100644 internal/authentik/api_base.go create mode 100644 internal/authentik/api_base_test.go create mode 100644 internal/authentik/flow_errors.go create mode 100644 internal/authentik/flow_executor.go create mode 100644 internal/authentik/flow_executor_test.go create mode 100644 internal/authentik/flow_session_store.go diff --git a/deploy/authentik/README.md b/deploy/authentik/README.md index a7c9ea8..f663eb2 100644 --- a/deploy/authentik/README.md +++ b/deploy/authentik/README.md @@ -8,6 +8,7 @@ Blueprints in `blueprints/` are mounted into Authentik at `/blueprints/custom` a | `02-ulti-brand.yaml` | Branding UltiSuite + lien « Créer un compte » sur login | | `03-ulti-suite-groups.yaml` | Claim OIDC `groups` (RBAC contacts/calendar/drive/photos) | | `04-ulti-post-migration-security.yaml` | Flow WebAuthn/TOTP post-migration (`ulti-post-migration-security`) | +| `05-ulti-recovery.yaml` | Mot de passe oublié (`ulti-recovery`) | | `ulti-oidc.yaml` | App OIDC Ultimail | | `nextcloud-oidc.yaml` | App OIDC Nextcloud | | `onlyoffice-oidc.yaml` | App OIDC OnlyOffice | diff --git a/deploy/authentik/blueprints/05-ulti-recovery.yaml b/deploy/authentik/blueprints/05-ulti-recovery.yaml new file mode 100644 index 0000000..cd89603 --- /dev/null +++ b/deploy/authentik/blueprints/05-ulti-recovery.yaml @@ -0,0 +1,170 @@ +# UltiSuite — réinitialisation mot de passe (identification e-mail + envoi lien) +version: 1 +metadata: + name: UltiSuite recovery + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_flows.flow + id: ulti-recovery-flow + identifiers: + slug: ulti-recovery + attrs: + name: UltiSuite — Mot de passe oublié + title: Réinitialiser votre mot de passe + designation: recovery + authentication: require_unauthenticated + + - model: authentik_stages_identification.identificationstage + id: ulti-recovery-identification + identifiers: + name: ulti-recovery-identification + attrs: + user_fields: + - email + + - model: authentik_stages_email.emailstage + id: ulti-recovery-email + identifiers: + name: ulti-recovery-email + attrs: + use_global_settings: true + token_expiry: 1800 + subject: UltiSuite — Réinitialisation du mot de passe + template: email/password_reset.html + activate_user_on_success: true + recovery_max_attempts: 5 + recovery_cache_timeout: 300 + + - model: authentik_stages_prompt.prompt + id: ulti-recovery-field-password + identifiers: + name: ulti-recovery-field-password + attrs: + field_key: password + label: Nouveau mot de passe + type: password + required: true + placeholder: Mot de passe + order: 0 + + - model: authentik_stages_prompt.prompt + id: ulti-recovery-field-password-repeat + identifiers: + name: ulti-recovery-field-password-repeat + attrs: + field_key: password_repeat + label: Confirmer le mot de passe + type: password + required: true + placeholder: Confirmer le mot de passe + order: 1 + + - model: authentik_stages_prompt.promptstage + id: ulti-recovery-prompt-password + identifiers: + name: ulti-recovery-prompt-password + attrs: + fields: + - !KeyOf ulti-recovery-field-password + - !KeyOf ulti-recovery-field-password-repeat + + - model: authentik_stages_user_write.userwritestage + id: ulti-recovery-user-write + identifiers: + name: ulti-recovery-user-write + attrs: + user_creation_mode: never_create + + - model: authentik_stages_user_login.userloginstage + id: ulti-recovery-user-login + identifiers: + name: ulti-recovery-user-login + + - model: authentik_policies_expression.expressionpolicy + id: ulti-recovery-skip-if-restored + identifiers: + name: ulti-recovery-skip-if-restored + attrs: + expression: | + return not request.context.get('is_restored', False) + + - model: authentik_flows.flowstagebinding + id: ulti-recovery-binding-identification + identifiers: + target: !KeyOf ulti-recovery-flow + stage: !KeyOf ulti-recovery-identification + order: 10 + attrs: + evaluate_on_plan: true + re_evaluate_policies: true + invalid_response_action: retry + + - model: authentik_flows.flowstagebinding + id: ulti-recovery-binding-email + identifiers: + target: !KeyOf ulti-recovery-flow + stage: !KeyOf ulti-recovery-email + order: 20 + attrs: + evaluate_on_plan: true + re_evaluate_policies: true + invalid_response_action: retry + + - model: authentik_flows.flowstagebinding + id: ulti-recovery-binding-password + identifiers: + target: !KeyOf ulti-recovery-flow + stage: !KeyOf ulti-recovery-prompt-password + order: 30 + attrs: + evaluate_on_plan: true + re_evaluate_policies: false + invalid_response_action: retry + + - model: authentik_flows.flowstagebinding + id: ulti-recovery-binding-user-write + identifiers: + target: !KeyOf ulti-recovery-flow + stage: !KeyOf ulti-recovery-user-write + order: 40 + attrs: + evaluate_on_plan: true + re_evaluate_policies: false + invalid_response_action: retry + + - model: authentik_flows.flowstagebinding + id: ulti-recovery-binding-user-login + identifiers: + target: !KeyOf ulti-recovery-flow + stage: !KeyOf ulti-recovery-user-login + order: 100 + attrs: + evaluate_on_plan: true + re_evaluate_policies: false + invalid_response_action: retry + + - model: authentik_policies.policybinding + identifiers: + policy: !KeyOf ulti-recovery-skip-if-restored + target: !KeyOf ulti-recovery-binding-identification + order: 0 + attrs: + enabled: true + timeout: 30 + + - model: authentik_policies.policybinding + identifiers: + policy: !KeyOf ulti-recovery-skip-if-restored + target: !KeyOf ulti-recovery-binding-email + order: 0 + state: absent + attrs: + enabled: true + timeout: 30 + + - model: authentik_brands.brand + identifiers: + domain: authentik-default + attrs: + flow_recovery: !Find [authentik_flows.flow, [slug, ulti-recovery]] diff --git a/internal/api/auth/handlers.go b/internal/api/auth/handlers.go new file mode 100644 index 0000000..2b50f48 --- /dev/null +++ b/internal/api/auth/handlers.go @@ -0,0 +1,178 @@ +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) +} diff --git a/internal/authentik/api_base.go b/internal/authentik/api_base.go new file mode 100644 index 0000000..a6fd4ab --- /dev/null +++ b/internal/authentik/api_base.go @@ -0,0 +1,15 @@ +package authentik + +import "strings" + +// APIBaseURL normalizes the Authentik API root (includes /auth when deployed under a subpath). +func APIBaseURL(raw string) string { + raw = strings.TrimRight(strings.TrimSpace(raw), "/") + if raw == "" { + return "http://authentik-server:9000/auth" + } + if strings.HasSuffix(raw, "/auth") { + return raw + } + return raw + "/auth" +} diff --git a/internal/authentik/api_base_test.go b/internal/authentik/api_base_test.go new file mode 100644 index 0000000..cfe6647 --- /dev/null +++ b/internal/authentik/api_base_test.go @@ -0,0 +1,19 @@ +package authentik + +import "testing" + +func TestAPIBaseURL(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "http://authentik-server:9000": "http://authentik-server:9000/auth", + "http://authentik-server:9000/": "http://authentik-server:9000/auth", + "http://authentik-server:9000/auth": "http://authentik-server:9000/auth", + "http://authentik-server:9000/auth/": "http://authentik-server:9000/auth", + "": "http://authentik-server:9000/auth", + } + for in, want := range cases { + if got := APIBaseURL(in); got != want { + t.Fatalf("APIBaseURL(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/authentik/client.go b/internal/authentik/client.go index 63691dc..f647578 100644 --- a/internal/authentik/client.go +++ b/internal/authentik/client.go @@ -20,7 +20,7 @@ type Client struct { func NewClient(baseURL, token string) *Client { return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), + baseURL: APIBaseURL(baseURL), token: token, httpClient: &http.Client{ Timeout: 30 * time.Second, diff --git a/internal/authentik/flow_errors.go b/internal/authentik/flow_errors.go new file mode 100644 index 0000000..36b09ef --- /dev/null +++ b/internal/authentik/flow_errors.go @@ -0,0 +1,8 @@ +package authentik + +import "errors" + +var ( + ErrFlowSessionNotFound = errors.New("flow session not found") + ErrFlowSessionSlugMismatch = errors.New("flow session slug mismatch") +) diff --git a/internal/authentik/flow_executor.go b/internal/authentik/flow_executor.go new file mode 100644 index 0000000..0dfd3aa --- /dev/null +++ b/internal/authentik/flow_executor.go @@ -0,0 +1,183 @@ +package authentik + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" +) + +const flowExecutorTimeout = 45 * time.Second + +// FlowChallenge is a raw Authentik flow executor challenge payload. +type FlowChallenge map[string]any + +// FlowExecutor runs an Authentik flow via the backend flow executor API. +type FlowExecutor struct { + baseURL string + slug string + client *http.Client +} + +// NewFlowExecutor creates a flow executor with an isolated cookie jar. +func NewFlowExecutor(baseURL, slug string) (*FlowExecutor, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + return &FlowExecutor{ + baseURL: APIBaseURL(baseURL), + slug: strings.Trim(slug, "/"), + client: &http.Client{ + Timeout: flowExecutorTimeout, + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 8 { + return fmt.Errorf("too many redirects") + } + return nil + }, + }, + }, nil +} + +func (fe *FlowExecutor) executorURL(query string) string { + u := fmt.Sprintf("%s/api/v3/flows/executor/%s/", fe.baseURL, fe.slug) + if query != "" { + return u + "?" + query + } + return u +} + +// GetChallenge starts or resumes a flow and returns the pending challenge. +func (fe *FlowExecutor) GetChallenge(ctx context.Context, query string) (FlowChallenge, error) { + if err := fe.warmSession(ctx); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fe.executorURL(query), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + return fe.doChallenge(req) +} + +func (fe *FlowExecutor) warmSession(ctx context.Context) error { + u := fmt.Sprintf("%s/api/v3/flows/instances/%s/execute/", fe.baseURL, fe.slug) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + resp, err := fe.client.Do(req) + if err != nil { + return err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode >= 500 { + return fmt.Errorf("warm session: %d", resp.StatusCode) + } + return nil +} + +// PostResponse submits a stage response and returns the next challenge or completion. +func (fe *FlowExecutor) PostResponse(ctx context.Context, query string, payload map[string]any) (FlowChallenge, error) { + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fe.executorURL(query), bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Referer", fe.executorURL(query)) + if csrf := fe.csrfToken(); csrf != "" { + req.Header.Set("X-authentik-CSRF", csrf) + } + return fe.doChallenge(req) +} + +func (fe *FlowExecutor) csrfToken() string { + u, err := url.Parse(fe.baseURL) + if err != nil { + return "" + } + for _, c := range fe.client.Jar.Cookies(u) { + if c.Name == "authentik_csrf" { + return c.Value + } + } + return "" +} + +func (fe *FlowExecutor) doChallenge(req *http.Request) (FlowChallenge, error) { + resp, err := fe.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther { + loc := resp.Header.Get("Location") + if loc == "" { + return FlowChallenge{"component": "xak-flow-redirect"}, nil + } + return FlowChallenge{ + "component": "xak-flow-redirect", + "to": loc, + }, nil + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = resp.Status + } + return nil, fmt.Errorf("flow executor %s: %d %s", fe.slug, resp.StatusCode, msg) + } + + if len(body) == 0 { + return FlowChallenge{"component": "xak-flow-redirect"}, nil + } + + var challenge FlowChallenge + if err := json.Unmarshal(body, &challenge); err != nil { + return nil, fmt.Errorf("decode flow challenge: %w", err) + } + return challenge, nil +} + +// FlowComponent returns the active stage component identifier. +func FlowComponent(challenge FlowChallenge) string { + if challenge == nil { + return "" + } + v, _ := challenge["component"].(string) + return v +} + +// FlowDone reports whether the flow finished successfully or was denied. +func FlowDone(challenge FlowChallenge) (done bool, denied bool) { + switch FlowComponent(challenge) { + case "xak-flow-redirect": + return true, false + case "ak-stage-access-denied": + return true, true + default: + return false, false + } +} diff --git a/internal/authentik/flow_executor_test.go b/internal/authentik/flow_executor_test.go new file mode 100644 index 0000000..88a7e90 --- /dev/null +++ b/internal/authentik/flow_executor_test.go @@ -0,0 +1,45 @@ +package authentik + +import ( + "context" + "net/http/httptest" + "testing" +) + +func TestFlowExecutorGetChallenge(t *testing.T) { + t.Parallel() + // Smoke test structure only — integration tests hit real Authentik. + fe, err := NewFlowExecutor("http://localhost:9000", "test-flow") + if err != nil { + t.Fatal(err) + } + if fe.slug != "test-flow" { + t.Fatalf("slug = %q", fe.slug) + } +} + +func TestFlowDone(t *testing.T) { + t.Parallel() + done, denied := FlowDone(FlowChallenge{"component": "xak-flow-redirect"}) + if !done || denied { + t.Fatalf("redirect: done=%v denied=%v", done, denied) + } + done, denied = FlowDone(FlowChallenge{"component": "ak-stage-access-denied"}) + if !done || !denied { + t.Fatalf("denied: done=%v denied=%v", done, denied) + } + done, denied = FlowDone(FlowChallenge{"component": "ak-stage-prompt"}) + if done || denied { + t.Fatalf("prompt: done=%v denied=%v", done, denied) + } +} + +func TestFlowSessionStoreLifecycle(t *testing.T) { + t.Parallel() + store := NewFlowSessionStore("http://127.0.0.1:1") + _, err := store.Respond(context.Background(), "missing", "ulti-enrollment", "", map[string]any{"component": "x"}) + if err != ErrFlowSessionNotFound { + t.Fatalf("expected ErrFlowSessionNotFound, got %v", err) + } + _ = httptest.NewRecorder() +} diff --git a/internal/authentik/flow_session_store.go b/internal/authentik/flow_session_store.go new file mode 100644 index 0000000..40c499b --- /dev/null +++ b/internal/authentik/flow_session_store.go @@ -0,0 +1,116 @@ +package authentik + +import ( + "context" + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +const defaultFlowSessionTTL = 20 * time.Minute + +type flowSessionEntry struct { + executor *FlowExecutor + slug string + createdAt time.Time +} + +// FlowSessionStore keeps in-memory Authentik flow executor sessions. +type FlowSessionStore struct { + mu sync.Mutex + baseURL string + ttl time.Duration + items map[string]*flowSessionEntry +} + +func NewFlowSessionStore(baseURL string) *FlowSessionStore { + return &FlowSessionStore{ + baseURL: baseURL, + ttl: defaultFlowSessionTTL, + items: make(map[string]*flowSessionEntry), + } +} + +func (s *FlowSessionStore) Start(ctx context.Context, slug, query string) (sessionID string, challenge FlowChallenge, err error) { + executor, err := NewFlowExecutor(s.baseURL, slug) + if err != nil { + return "", nil, err + } + challenge, err = executor.GetChallenge(ctx, query) + if err != nil { + return "", nil, err + } + id, err := randomSessionID() + if err != nil { + return "", nil, err + } + done, _ := FlowDone(challenge) + s.mu.Lock() + defer s.mu.Unlock() + s.cleanupLocked(time.Now()) + if !done { + s.items[id] = &flowSessionEntry{ + executor: executor, + slug: slug, + createdAt: time.Now(), + } + } + return id, challenge, nil +} + +func (s *FlowSessionStore) Respond(ctx context.Context, sessionID, slug, query string, payload map[string]any) (FlowChallenge, error) { + entry, err := s.get(sessionID) + if err != nil { + return nil, err + } + if entry.slug != slug { + return nil, ErrFlowSessionSlugMismatch + } + challenge, err := entry.executor.PostResponse(ctx, query, payload) + if err != nil { + return nil, err + } + done, _ := FlowDone(challenge) + if done { + s.deleteLocked(sessionID) + } + return challenge, nil +} + +func (s *FlowSessionStore) Delete(sessionID string) { + s.mu.Lock() + defer s.mu.Unlock() + s.deleteLocked(sessionID) +} + +func (s *FlowSessionStore) deleteLocked(sessionID string) { + delete(s.items, sessionID) +} + +func (s *FlowSessionStore) get(sessionID string) (*flowSessionEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.cleanupLocked(time.Now()) + entry, ok := s.items[sessionID] + if !ok { + return nil, ErrFlowSessionNotFound + } + return entry, nil +} + +func (s *FlowSessionStore) cleanupLocked(now time.Time) { + for id, entry := range s.items { + if now.Sub(entry.createdAt) > s.ttl { + delete(s.items, id) + } + } +} + +func randomSessionID() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/authentik/users_test.go b/internal/authentik/users_test.go index b217455..080dc28 100644 --- a/internal/authentik/users_test.go +++ b/internal/authentik/users_test.go @@ -12,7 +12,7 @@ func TestSetUserAvatarAttribute(t *testing.T) { const userUUID = "11111111-1111-1111-1111-111111111111" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v3/core/users/": + case r.Method == http.MethodGet && r.URL.Path == "/auth/api/v3/core/users/": _ = json.NewEncoder(w).Encode(listResponse[akUser]{ Results: []akUser{{ PK: 42, @@ -22,7 +22,7 @@ func TestSetUserAvatarAttribute(t *testing.T) { }, }}, }) - case r.Method == http.MethodPatch && r.URL.Path == "/api/v3/core/users/42/": + case r.Method == http.MethodPatch && r.URL.Path == "/auth/api/v3/core/users/42/": var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode patch body: %v", err) diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go index 09de403..e15add7 100644 --- a/internal/server/bootstrap.go +++ b/internal/server/bootstrap.go @@ -17,6 +17,7 @@ import ( "github.com/redis/go-redis/v9" aiapi "github.com/ultisuite/ulti-backend/internal/api/ai" + authapi "github.com/ultisuite/ulti-backend/internal/api/auth" "github.com/ultisuite/ulti-backend/internal/api/admin" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" @@ -367,6 +368,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { r.Get("/api/v1/mail/addresses/check", mailHandler.CheckAddressAvailability) r.Get("/api/v1/migration/invite", migrationHandler.GetInvite) r.Post("/internal/provision/user", provisionHandler.ProvisionUser) + r.Mount("/api/v1/auth", authapi.NewHandler(cfg.AuthentikAPIURL).Routes()) var driveHandler *drive.Handler var driveSvc *drive.Service