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.
This commit is contained in:
parent
c36416bdb4
commit
e4549f29b2
@ -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 |
|
| `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) |
|
| `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`) |
|
| `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 |
|
| `ulti-oidc.yaml` | App OIDC Ultimail |
|
||||||
| `nextcloud-oidc.yaml` | App OIDC Nextcloud |
|
| `nextcloud-oidc.yaml` | App OIDC Nextcloud |
|
||||||
| `onlyoffice-oidc.yaml` | App OIDC OnlyOffice |
|
| `onlyoffice-oidc.yaml` | App OIDC OnlyOffice |
|
||||||
|
|||||||
170
deploy/authentik/blueprints/05-ulti-recovery.yaml
Normal file
170
deploy/authentik/blueprints/05-ulti-recovery.yaml
Normal 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]]
|
||||||
178
internal/api/auth/handlers.go
Normal file
178
internal/api/auth/handlers.go
Normal 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)
|
||||||
|
}
|
||||||
15
internal/authentik/api_base.go
Normal file
15
internal/authentik/api_base.go
Normal 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"
|
||||||
|
}
|
||||||
19
internal/authentik/api_base_test.go
Normal file
19
internal/authentik/api_base_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@ type Client struct {
|
|||||||
|
|
||||||
func NewClient(baseURL, token string) *Client {
|
func NewClient(baseURL, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: APIBaseURL(baseURL),
|
||||||
token: token,
|
token: token,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
|||||||
8
internal/authentik/flow_errors.go
Normal file
8
internal/authentik/flow_errors.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package authentik
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFlowSessionNotFound = errors.New("flow session not found")
|
||||||
|
ErrFlowSessionSlugMismatch = errors.New("flow session slug mismatch")
|
||||||
|
)
|
||||||
183
internal/authentik/flow_executor.go
Normal file
183
internal/authentik/flow_executor.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/authentik/flow_executor_test.go
Normal file
45
internal/authentik/flow_executor_test.go
Normal 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()
|
||||||
|
}
|
||||||
116
internal/authentik/flow_session_store.go
Normal file
116
internal/authentik/flow_session_store.go
Normal 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
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ func TestSetUserAvatarAttribute(t *testing.T) {
|
|||||||
const userUUID = "11111111-1111-1111-1111-111111111111"
|
const userUUID = "11111111-1111-1111-1111-111111111111"
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
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]{
|
_ = json.NewEncoder(w).Encode(listResponse[akUser]{
|
||||||
Results: []akUser{{
|
Results: []akUser{{
|
||||||
PK: 42,
|
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
|
var body map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
t.Fatalf("decode patch body: %v", err)
|
t.Fatalf("decode patch body: %v", err)
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
aiapi "github.com/ultisuite/ulti-backend/internal/api/ai"
|
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/admin"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
"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/mail/addresses/check", mailHandler.CheckAddressAvailability)
|
||||||
r.Get("/api/v1/migration/invite", migrationHandler.GetInvite)
|
r.Get("/api/v1/migration/invite", migrationHandler.GetInvite)
|
||||||
r.Post("/internal/provision/user", provisionHandler.ProvisionUser)
|
r.Post("/internal/provision/user", provisionHandler.ProvisionUser)
|
||||||
|
r.Mount("/api/v1/auth", authapi.NewHandler(cfg.AuthentikAPIURL).Routes())
|
||||||
|
|
||||||
var driveHandler *drive.Handler
|
var driveHandler *drive.Handler
|
||||||
var driveSvc *drive.Service
|
var driveSvc *drive.Service
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user