- 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.
298 lines
7.6 KiB
Go
298 lines
7.6 KiB
Go
package authentik
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewClient(baseURL, token string) *Client {
|
|
return &Client{
|
|
baseURL: APIBaseURL(baseURL),
|
|
token: token,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
type listResponse[T any] struct {
|
|
Results []T `json:"results"`
|
|
}
|
|
|
|
type flowRef struct {
|
|
PK string `json:"pk"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
type oauth2Provider struct {
|
|
PK int `json:"pk"`
|
|
Name string `json:"name"`
|
|
ClientID string `json:"client_id"`
|
|
RedirectURIs string `json:"redirect_uris"`
|
|
}
|
|
|
|
type application struct {
|
|
PK int `json:"pk"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Provider int `json:"provider"`
|
|
}
|
|
|
|
type scopeMapping struct {
|
|
PK string `json:"pk"`
|
|
ScopeName string `json:"scope_name"`
|
|
}
|
|
|
|
type certKeyPair struct {
|
|
PK string `json:"pk"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v3/root/config/", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.authorize(req)
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("authentik ping: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) authorize(req *http.Request) {
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
}
|
|
|
|
func (c *Client) FindApplicationBySlug(ctx context.Context, slug string) (*application, bool, error) {
|
|
q := url.Values{}
|
|
q.Set("slug", slug)
|
|
var out listResponse[application]
|
|
if err := c.getJSON(ctx, "/api/v3/core/applications/?"+q.Encode(), &out); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(out.Results) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
return &out.Results[0], true, nil
|
|
}
|
|
|
|
func (c *Client) FindOAuth2ProviderByName(ctx context.Context, name string) (*oauth2Provider, bool, error) {
|
|
q := url.Values{}
|
|
q.Set("name", name)
|
|
var out listResponse[oauth2Provider]
|
|
if err := c.getJSON(ctx, "/api/v3/providers/oauth2/?"+q.Encode(), &out); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(out.Results) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
return &out.Results[0], true, nil
|
|
}
|
|
|
|
func (c *Client) FindFlowBySlug(ctx context.Context, slug string) (string, error) {
|
|
q := url.Values{}
|
|
q.Set("slug", slug)
|
|
var out listResponse[flowRef]
|
|
if err := c.getJSON(ctx, "/api/v3/flows/instances/?"+q.Encode(), &out); err != nil {
|
|
return "", err
|
|
}
|
|
if len(out.Results) == 0 {
|
|
return "", fmt.Errorf("flow not found: %s", slug)
|
|
}
|
|
return out.Results[0].PK, nil
|
|
}
|
|
|
|
func (c *Client) FindScopeMappingPK(ctx context.Context, scopeName string) (string, error) {
|
|
q := url.Values{}
|
|
q.Set("scope_name", scopeName)
|
|
var out listResponse[scopeMapping]
|
|
if err := c.getJSON(ctx, "/api/v3/propertymappings/provider/oauth2/?"+q.Encode(), &out); err != nil {
|
|
return "", err
|
|
}
|
|
if len(out.Results) == 0 {
|
|
return "", fmt.Errorf("scope mapping not found: %s", scopeName)
|
|
}
|
|
return out.Results[0].PK, nil
|
|
}
|
|
|
|
func (c *Client) FindSigningKeyPK(ctx context.Context) (string, error) {
|
|
var out listResponse[certKeyPair]
|
|
if err := c.getJSON(ctx, "/api/v3/crypto/certificatekeypairs/", &out); err != nil {
|
|
return "", err
|
|
}
|
|
for _, k := range out.Results {
|
|
if strings.Contains(k.Name, "authentik") && strings.Contains(k.Name, "Certificate") {
|
|
return k.PK, nil
|
|
}
|
|
}
|
|
if len(out.Results) > 0 {
|
|
return out.Results[0].PK, nil
|
|
}
|
|
return "", fmt.Errorf("no signing key found")
|
|
}
|
|
|
|
type CreateOAuth2ProviderRequest struct {
|
|
Name string
|
|
ClientID string
|
|
ClientSecret string
|
|
RedirectURIs []string
|
|
AuthorizationFlowPK string
|
|
InvalidationFlowPK string
|
|
SigningKeyPK string
|
|
PropertyMappingPKs []string
|
|
OfflineAccess bool
|
|
}
|
|
|
|
func (c *Client) CreateOAuth2Provider(ctx context.Context, req CreateOAuth2ProviderRequest) (int, error) {
|
|
mappings := req.PropertyMappingPKs
|
|
if req.OfflineAccess {
|
|
if pk, err := c.FindScopeMappingPK(ctx, "offline_access"); err == nil {
|
|
mappings = append(mappings, pk)
|
|
}
|
|
}
|
|
body := map[string]any{
|
|
"name": req.Name,
|
|
"authorization_flow": req.AuthorizationFlowPK,
|
|
"invalidation_flow": req.InvalidationFlowPK,
|
|
"property_mappings": mappings,
|
|
"client_type": "confidential",
|
|
"client_id": req.ClientID,
|
|
"client_secret": req.ClientSecret,
|
|
"redirect_uris": joinRedirectURIs(req.RedirectURIs),
|
|
"signing_key": req.SigningKeyPK,
|
|
"access_token_validity": "hours=1",
|
|
"refresh_token_validity": "days=365",
|
|
}
|
|
var created oauth2Provider
|
|
if err := c.postJSON(ctx, "/api/v3/providers/oauth2/", body, &created); err != nil {
|
|
return 0, err
|
|
}
|
|
return created.PK, nil
|
|
}
|
|
|
|
func (c *Client) UpdateOAuth2ProviderRedirects(ctx context.Context, providerPK int, redirectURIs []string) error {
|
|
body := map[string]any{
|
|
"redirect_uris": joinRedirectURIs(redirectURIs),
|
|
}
|
|
return c.patchJSON(ctx, fmt.Sprintf("/api/v3/providers/oauth2/%d/", providerPK), body)
|
|
}
|
|
|
|
type CreateApplicationRequest struct {
|
|
Name string
|
|
Slug string
|
|
Group string
|
|
LaunchURL string
|
|
Provider int
|
|
}
|
|
|
|
func (c *Client) CreateApplication(ctx context.Context, req CreateApplicationRequest) (int, error) {
|
|
body := map[string]any{
|
|
"name": req.Name,
|
|
"slug": req.Slug,
|
|
"group": req.Group,
|
|
"provider": req.Provider,
|
|
"meta_launch_url": req.LaunchURL,
|
|
"policy_engine_mode": "any",
|
|
}
|
|
var created application
|
|
if err := c.postJSON(ctx, "/api/v3/core/applications/", body, &created); err != nil {
|
|
return 0, err
|
|
}
|
|
return created.PK, nil
|
|
}
|
|
|
|
func joinRedirectURIs(uris []string) string {
|
|
seen := make(map[string]struct{}, len(uris))
|
|
var lines []string
|
|
for _, u := range uris {
|
|
u = strings.TrimSpace(u)
|
|
if u == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[u]; ok {
|
|
continue
|
|
}
|
|
seen[u] = struct{}{}
|
|
lines = append(lines, u)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (c *Client) getJSON(ctx context.Context, path string, dest any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.authorize(req)
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
return decodeResponse(resp, dest)
|
|
}
|
|
|
|
func (c *Client) postJSON(ctx context.Context, path string, body any, dest any) error {
|
|
return c.doJSON(ctx, http.MethodPost, path, body, dest)
|
|
}
|
|
|
|
func (c *Client) patchJSON(ctx context.Context, path string, body any) error {
|
|
return c.doJSON(ctx, http.MethodPatch, path, body, nil)
|
|
}
|
|
|
|
func (c *Client) doJSON(ctx context.Context, method, path string, body any, dest any) error {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.authorize(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
return decodeResponse(resp, dest)
|
|
}
|
|
|
|
func decodeResponse(resp *http.Response, dest any) error {
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("authentik api %s: %d %s", resp.Request.URL.Path, resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
if dest == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(dest)
|
|
}
|