feat(authentik): implement password recovery flow and API integration
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

- 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.
This commit is contained in:
R3D347HR4Y 2026-06-19 22:34:29 +02:00
parent c36416bdb4
commit e4549f29b2
12 changed files with 740 additions and 3 deletions

View File

@ -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 |

View File

@ -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]]

View File

@ -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)
}

View File

@ -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"
}

View File

@ -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)
}
}
}

View File

@ -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,

View File

@ -0,0 +1,8 @@
package authentik
import "errors"
var (
ErrFlowSessionNotFound = errors.New("flow session not found")
ErrFlowSessionSlugMismatch = errors.New("flow session slug mismatch")
)

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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)

View File

@ -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