mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-31 10:07:28 +01:00
add oidc integration
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/hay-kot/httpkit/errchain"
|
||||
"github.com/hay-kot/httpkit/server"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/app/api/providers"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
||||
@@ -74,6 +75,7 @@ type V1Controller struct {
|
||||
bus *eventbus.EventBus
|
||||
url string
|
||||
config *config.Config
|
||||
oidcProvider *providers.OIDCProvider
|
||||
}
|
||||
|
||||
type (
|
||||
@@ -95,6 +97,14 @@ type (
|
||||
Demo bool `json:"demo"`
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
LabelPrinting bool `json:"labelPrinting"`
|
||||
OIDC OIDCStatus `json:"oidc"`
|
||||
}
|
||||
|
||||
OIDCStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ButtonText string `json:"buttonText,omitempty"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
AllowLocal bool `json:"allowLocal"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -111,9 +121,23 @@ func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *event
|
||||
opt(ctrl)
|
||||
}
|
||||
|
||||
ctrl.initOIDCProvider()
|
||||
|
||||
return ctrl
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) initOIDCProvider() {
|
||||
if ctrl.config.OIDC.Enabled {
|
||||
oidcProvider, err := providers.NewOIDCProvider(ctrl.svc.User, &ctrl.config.OIDC, &ctrl.config.Options, ctrl.cookieSecure)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to initialize OIDC provider at startup")
|
||||
} else {
|
||||
ctrl.oidcProvider = oidcProvider
|
||||
log.Info().Msg("OIDC provider initialized successfully at startup")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBase godoc
|
||||
//
|
||||
// @Summary Application Info
|
||||
@@ -132,6 +156,12 @@ func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.Hand
|
||||
Demo: ctrl.isDemo,
|
||||
AllowRegistration: ctrl.allowRegistration,
|
||||
LabelPrinting: ctrl.config.LabelMaker.PrintCommand != nil,
|
||||
OIDC: OIDCStatus{
|
||||
Enabled: ctrl.config.OIDC.Enabled,
|
||||
ButtonText: ctrl.config.OIDC.ButtonText,
|
||||
Force: ctrl.config.OIDC.Force,
|
||||
AllowLocal: ctrl.config.Options.AllowLocalLogin,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -100,6 +101,11 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
// Forbidden if local login is not enabled
|
||||
if !ctrl.config.Options.AllowLocalLogin {
|
||||
return validate.NewRequestError(fmt.Errorf("Local login is not enabled"), http.StatusForbidden)
|
||||
}
|
||||
|
||||
// Extract provider query
|
||||
provider := r.URL.Query().Get("provider")
|
||||
if provider == "" {
|
||||
@@ -247,3 +253,65 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleOIDCLogin godoc
|
||||
//
|
||||
// @Summary OIDC Login Initiation
|
||||
// @Tags Authentication
|
||||
// @Produce json
|
||||
// @Success 302
|
||||
// @Router /v1/users/login/oidc [GET]
|
||||
func (ctrl *V1Controller) HandleOIDCLogin() errchain.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
// Forbidden if OIDC is not enabled
|
||||
if !ctrl.config.OIDC.Enabled {
|
||||
return validate.NewRequestError(fmt.Errorf("OIDC is not enabled"), http.StatusForbidden)
|
||||
}
|
||||
|
||||
// Check if OIDC provider is available
|
||||
if ctrl.oidcProvider == nil {
|
||||
log.Error().Msg("OIDC provider not initialized")
|
||||
return validate.NewRequestError(errors.New("OIDC provider not available"), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Initiate OIDC flow
|
||||
_, err := ctrl.oidcProvider.InitiateOIDCFlow(w, r)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOIDCCallback godoc
|
||||
//
|
||||
// @Summary OIDC Callback Handler
|
||||
// @Tags Authentication
|
||||
// @Param code query string true "Authorization code"
|
||||
// @Param state query string true "State parameter"
|
||||
// @Success 302
|
||||
// @Router /v1/users/login/oidc/callback [GET]
|
||||
func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
// Forbidden if OIDC is not enabled
|
||||
if !ctrl.config.OIDC.Enabled {
|
||||
return validate.NewRequestError(fmt.Errorf("OIDC is not enabled"), http.StatusForbidden)
|
||||
}
|
||||
|
||||
// Check if OIDC provider is available
|
||||
if ctrl.oidcProvider == nil {
|
||||
log.Error().Msg("OIDC provider not initialized")
|
||||
return validate.NewRequestError(errors.New("OIDC provider not available"), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Handle callback
|
||||
newToken, err := ctrl.oidcProvider.HandleCallback(w, r)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("OIDC callback failed")
|
||||
http.Redirect(w, r, "/?oidc_error=oidc_auth_failed", http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set cookies and redirect to home
|
||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true)
|
||||
http.Redirect(w, r, "/home", http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
// @Router /v1/users/register [Post]
|
||||
func (ctrl *V1Controller) HandleUserRegistration() errchain.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
// Forbidden if local login is not enabled
|
||||
if !ctrl.config.Options.AllowLocalLogin {
|
||||
return validate.NewRequestError(fmt.Errorf("Local login is not enabled"), http.StatusForbidden)
|
||||
}
|
||||
|
||||
regData := services.UserRegistration{}
|
||||
|
||||
if err := server.Decode(r, ®Data); err != nil {
|
||||
|
||||
386
backend/app/api/providers/oidc.go
Normal file
386
backend/app/api/providers/oidc.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type OIDCProvider struct {
|
||||
service *services.UserService
|
||||
config *config.OIDCConf
|
||||
options *config.Options
|
||||
cookieSecure bool
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
oauth2 oauth2.Config
|
||||
endpoint oauth2.Endpoint
|
||||
}
|
||||
|
||||
type DiscoveryDocument struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
type OIDCClaims struct {
|
||||
Email string
|
||||
Groups []string
|
||||
Name string
|
||||
Subject string
|
||||
}
|
||||
|
||||
func NewOIDCProvider(service *services.UserService, config *config.OIDCConf, options *config.Options, cookieSecure bool) (*OIDCProvider, error) {
|
||||
if !config.Enabled {
|
||||
return nil, fmt.Errorf("OIDC is not enabled")
|
||||
}
|
||||
|
||||
// Validate required configuration
|
||||
if config.ClientID == "" {
|
||||
return nil, fmt.Errorf("OIDC client ID is required when OIDC is enabled (set HBOX_OIDC_CLIENT_ID)")
|
||||
}
|
||||
if config.ClientSecret == "" {
|
||||
return nil, fmt.Errorf("OIDC client secret is required when OIDC is enabled (set HBOX_OIDC_CLIENT_SECRET)")
|
||||
}
|
||||
if config.IssuerURL == "" {
|
||||
return nil, fmt.Errorf("OIDC issuer URL is required when OIDC is enabled (set HBOX_OIDC_ISSUER_URL)")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, config.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC provider from issuer URL: %w", err)
|
||||
}
|
||||
|
||||
// Create ID token verifier
|
||||
verifier := provider.Verifier(&oidc.Config{
|
||||
ClientID: config.ClientID,
|
||||
})
|
||||
|
||||
log.Info().
|
||||
Str("issuer", config.IssuerURL).
|
||||
Str("client_id", config.ClientID).
|
||||
Str("scope", config.Scope).
|
||||
Msg("OIDC provider initialized successfully with discovery")
|
||||
|
||||
return &OIDCProvider{
|
||||
service: service,
|
||||
config: config,
|
||||
options: options,
|
||||
cookieSecure: cookieSecure,
|
||||
provider: provider,
|
||||
verifier: verifier,
|
||||
endpoint: provider.Endpoint(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) Name() string {
|
||||
return "oidc"
|
||||
}
|
||||
|
||||
// Authenticate implements the AuthProvider interface but is not used for OIDC
|
||||
// OIDC uses dedicated endpoints: GET /users/login/oidc and GET /users/login/oidc/callback
|
||||
func (p *OIDCProvider) Authenticate(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("OIDC authentication uses dedicated endpoints: /users/login/oidc")
|
||||
}
|
||||
|
||||
// AuthenticateWithBaseURL is the main authentication method that requires baseURL
|
||||
// This is now only called from handleCallback after state verification
|
||||
func (p *OIDCProvider) AuthenticateWithBaseURL(baseURL string, w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("missing authorization code")
|
||||
}
|
||||
|
||||
// Get OAuth2 config for this request
|
||||
oauth2Config := p.getOAuth2Config(baseURL)
|
||||
|
||||
// Exchange code for token with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.config.RequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := oauth2Config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to exchange OIDC code for token")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to exchange code for token")
|
||||
}
|
||||
|
||||
// Extract ID token
|
||||
idToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("no id_token in response")
|
||||
}
|
||||
|
||||
// Parse and validate the ID token using the library's verifier with timeout
|
||||
verifyCtx, verifyCancel := context.WithTimeout(context.Background(), p.config.RequestTimeout)
|
||||
defer verifyCancel()
|
||||
|
||||
idTokenStruct, err := p.verifier.Verify(verifyCtx, idToken)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to verify ID token")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to verify ID token")
|
||||
}
|
||||
|
||||
// Extract claims from the verified token using dynamic parsing
|
||||
var rawClaims map[string]interface{}
|
||||
if err := idTokenStruct.Claims(&rawClaims); err != nil {
|
||||
log.Err(err).Msg("failed to extract claims from ID token")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to extract claims from ID token")
|
||||
}
|
||||
|
||||
// Parse claims using configurable claim names
|
||||
claims, err := p.parseOIDCClaims(rawClaims)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to parse OIDC claims")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to parse OIDC claims: %w", err)
|
||||
}
|
||||
|
||||
// Check group authorization if configured
|
||||
if p.config.AllowedGroups != "" {
|
||||
allowedGroups := strings.Split(p.config.AllowedGroups, ",")
|
||||
if !p.hasAllowedGroup(claims.Groups, allowedGroups) {
|
||||
log.Warn().
|
||||
Strs("user_groups", claims.Groups).
|
||||
Strs("allowed_groups", allowedGroups).
|
||||
Str("user", claims.Email).
|
||||
Msg("user not in allowed groups")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("user not in allowed groups")
|
||||
}
|
||||
}
|
||||
|
||||
// Determine username from claims
|
||||
email := claims.Email
|
||||
if email == "" {
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("no email found in token claims")
|
||||
}
|
||||
|
||||
// Use the dedicated OIDC login method
|
||||
sessionToken, err := p.service.LoginOIDC(r.Context(), email, claims.Name)
|
||||
if err != nil {
|
||||
log.Err(err).Str("email", email).Msg("OIDC login failed")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("OIDC login failed: %w", err)
|
||||
}
|
||||
|
||||
return sessionToken, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) parseOIDCClaims(rawClaims map[string]interface{}) (OIDCClaims, error) {
|
||||
var claims OIDCClaims
|
||||
|
||||
// Parse email claim
|
||||
if emailValue, exists := rawClaims[p.config.EmailClaim]; exists {
|
||||
if email, ok := emailValue.(string); ok {
|
||||
claims.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
// Parse name claim
|
||||
if nameValue, exists := rawClaims[p.config.NameClaim]; exists {
|
||||
if name, ok := nameValue.(string); ok {
|
||||
claims.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
// Parse groups claim
|
||||
if groupsValue, exists := rawClaims[p.config.GroupClaim]; exists {
|
||||
switch groups := groupsValue.(type) {
|
||||
case []interface{}:
|
||||
for _, group := range groups {
|
||||
if groupStr, ok := group.(string); ok {
|
||||
claims.Groups = append(claims.Groups, groupStr)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
claims.Groups = groups
|
||||
case string:
|
||||
// Single group as string
|
||||
claims.Groups = []string{groups}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse subject claim (always "sub")
|
||||
if subValue, exists := rawClaims["sub"]; exists {
|
||||
if subject, ok := subValue.(string); ok {
|
||||
claims.Subject = subject
|
||||
}
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) hasAllowedGroup(userGroups, allowedGroups []string) bool {
|
||||
if len(allowedGroups) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
allowedGroupsMap := make(map[string]bool)
|
||||
for _, group := range allowedGroups {
|
||||
allowedGroupsMap[strings.TrimSpace(group)] = true
|
||||
}
|
||||
|
||||
for _, userGroup := range userGroups {
|
||||
if allowedGroupsMap[userGroup] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) GetAuthURL(baseURL, state string) string {
|
||||
oauth2Config := p.getOAuth2Config(baseURL)
|
||||
return oauth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) getOAuth2Config(baseURL string) oauth2.Config {
|
||||
// Construct full redirect URL with dedicated callback endpoint
|
||||
redirectURL, err := url.JoinPath(baseURL, "/api/v1/users/login/oidc/callback")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to construct redirect URL")
|
||||
return oauth2.Config{}
|
||||
}
|
||||
|
||||
return oauth2.Config{
|
||||
ClientID: p.config.ClientID,
|
||||
ClientSecret: p.config.ClientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Endpoint: p.endpoint,
|
||||
Scopes: strings.Split(p.config.Scope, " "),
|
||||
}
|
||||
}
|
||||
|
||||
// initiateOIDCFlow handles the initial OIDC authentication request by redirecting to the provider
|
||||
func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
||||
// Generate state parameter for CSRF protection
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to generate OIDC state parameter")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("internal server error")
|
||||
}
|
||||
|
||||
// Get base URL from request
|
||||
baseURL := p.getBaseURL(r)
|
||||
|
||||
// Store state in session cookie for validation
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: state,
|
||||
Expires: time.Now().Add(p.config.StateExpiry),
|
||||
Domain: noPort(r.Host),
|
||||
Secure: p.isSecure(r),
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
// Generate auth URL and redirect
|
||||
authURL := p.GetAuthURL(baseURL, state)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
|
||||
// Return empty token since this is a redirect response
|
||||
return services.UserAuthTokenDetail{}, nil
|
||||
}
|
||||
|
||||
// handleCallback processes the OAuth2 callback from the OIDC provider
|
||||
func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
||||
// Check for OAuth error responses first
|
||||
if errCode := r.URL.Query().Get("error"); errCode != "" {
|
||||
errDesc := r.URL.Query().Get("error_description")
|
||||
log.Warn().Str("error", errCode).Str("description", errDesc).Msg("OIDC provider returned error")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("OIDC provider error: %s - %s", errCode, errDesc)
|
||||
}
|
||||
|
||||
// Verify state parameter
|
||||
stateCookie, err := r.Cookie("oidc_state")
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("OIDC state cookie not found - possible CSRF attack or expired session")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("state cookie not found")
|
||||
}
|
||||
|
||||
stateParam := r.URL.Query().Get("state")
|
||||
if stateParam == "" {
|
||||
log.Warn().Msg("OIDC state parameter missing from callback")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("state parameter missing")
|
||||
}
|
||||
|
||||
if stateParam != stateCookie.Value {
|
||||
log.Warn().Str("received", stateParam).Str("expected", stateCookie.Value).Msg("OIDC state mismatch - possible CSRF attack")
|
||||
return services.UserAuthTokenDetail{}, fmt.Errorf("state parameter mismatch")
|
||||
}
|
||||
|
||||
// Clear state cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: "",
|
||||
Expires: time.Unix(0, 0),
|
||||
Domain: noPort(r.Host),
|
||||
Secure: p.isSecure(r),
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
// Get base URL from request
|
||||
baseURL := p.getBaseURL(r)
|
||||
|
||||
// Use the existing callback logic but return the token instead of redirecting
|
||||
return p.AuthenticateWithBaseURL(baseURL, w, r)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func generateState() (string, error) {
|
||||
// Generate 32 bytes of cryptographically secure random data
|
||||
bytes := make([]byte, 32)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate secure random state: %w", err)
|
||||
}
|
||||
// Use URL-safe base64 encoding without padding for clean URLs
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func noPort(host string) string {
|
||||
return strings.Split(host, ":")[0]
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) getBaseURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
} else if p.options.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
host := r.Host
|
||||
if p.options.Hostname != "" {
|
||||
host = p.options.Hostname
|
||||
}
|
||||
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) isSecure(r *http.Request) bool {
|
||||
return p.cookieSecure
|
||||
}
|
||||
|
||||
// InitiateOIDCFlow starts the OIDC authentication flow by redirecting to the provider
|
||||
func (p *OIDCProvider) InitiateOIDCFlow(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
||||
return p.initiateOIDCFlow(w, r)
|
||||
}
|
||||
|
||||
// HandleCallback processes the OIDC callback and returns the authenticated user token
|
||||
func (p *OIDCProvider) HandleCallback(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
||||
return p.handleCallback(w, r)
|
||||
}
|
||||
@@ -74,6 +74,8 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
|
||||
|
||||
r.Post("/users/register", chain.ToHandlerFunc(v1Ctrl.HandleUserRegistration()))
|
||||
r.Post("/users/login", chain.ToHandlerFunc(v1Ctrl.HandleAuthLogin(providers...)))
|
||||
r.Get("/users/login/oidc", chain.ToHandlerFunc(v1Ctrl.HandleOIDCLogin()))
|
||||
r.Get("/users/login/oidc/callback", chain.ToHandlerFunc(v1Ctrl.HandleOIDCCallback()))
|
||||
|
||||
userMW := []errchain.Middleware{
|
||||
a.mwAuthToken,
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
entgo.io/ent v0.14.5
|
||||
github.com/ardanlabs/conf/v3 v3.8.0
|
||||
github.com/containrrr/shoutrrr v0.8.0
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/evanoberholster/imagemeta v0.3.1
|
||||
github.com/gen2brain/avif v0.4.4
|
||||
github.com/gen2brain/heic v0.4.5
|
||||
@@ -41,6 +42,7 @@ require (
|
||||
gocloud.dev/pubsub/rabbitpubsub v0.43.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/text v0.28.0
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
@@ -188,7 +190,6 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
|
||||
@@ -133,6 +133,8 @@ github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9F
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
||||
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -317,6 +319,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
@@ -339,6 +343,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olahol/melody v1.3.0 h1:n7UlKiQnxVrgxKoM0d7usZiN+Z0y2lVENtYLgKtXS6s=
|
||||
github.com/olahol/melody v1.3.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
@@ -381,6 +387,10 @@ github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1
|
||||
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -38,10 +38,11 @@ func bootstrap() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
password := fk.Str(10)
|
||||
tUser, err = tRepos.Users.Create(ctx, repo.UserCreate{
|
||||
Name: fk.Str(10),
|
||||
Email: fk.Email(),
|
||||
Password: fk.Str(10),
|
||||
Password: &password,
|
||||
IsSuperuser: fk.Bool(),
|
||||
GroupID: tGroup.ID,
|
||||
})
|
||||
|
||||
@@ -82,7 +82,7 @@ func (svc *UserService) RegisterUser(ctx context.Context, data UserRegistration)
|
||||
usrCreate := repo.UserCreate{
|
||||
Name: data.Name,
|
||||
Email: data.Email,
|
||||
Password: hashed,
|
||||
Password: &hashed,
|
||||
IsSuperuser: false,
|
||||
GroupID: group.ID,
|
||||
IsOwner: creatingGroup,
|
||||
@@ -190,6 +190,14 @@ func (svc *UserService) Login(ctx context.Context, username, password string, ex
|
||||
return UserAuthTokenDetail{}, ErrorInvalidLogin
|
||||
}
|
||||
|
||||
// SECURITY: Deny login for users with null or empty password (OIDC users)
|
||||
if usr.PasswordHash == "" {
|
||||
log.Warn().Str("email", username).Msg("Login attempt blocked for user with null password (likely OIDC user)")
|
||||
// SECURITY: Perform hash to ensure response times are the same
|
||||
hasher.CheckPasswordHash("not-a-real-password", "not-a-real-password")
|
||||
return UserAuthTokenDetail{}, ErrorInvalidLogin
|
||||
}
|
||||
|
||||
check, rehash := hasher.CheckPasswordHash(password, usr.PasswordHash)
|
||||
|
||||
if !check {
|
||||
@@ -210,6 +218,72 @@ func (svc *UserService) Login(ctx context.Context, username, password string, ex
|
||||
return svc.createSessionToken(ctx, usr.ID, extendedSession)
|
||||
}
|
||||
|
||||
// LoginOIDC creates a session token for a user authenticated via OIDC.
|
||||
// If the user doesn't exist, it will create one.
|
||||
func (svc *UserService) LoginOIDC(ctx context.Context, email, name string) (UserAuthTokenDetail, error) {
|
||||
// Try to get existing user
|
||||
usr, err := svc.repos.Users.GetOneEmail(ctx, email)
|
||||
if err != nil {
|
||||
// User doesn't exist, create a new one without password
|
||||
log.Info().Str("email", email).Msg("OIDC user not found, creating new user")
|
||||
|
||||
usr, err = svc.registerOIDCUser(ctx, email, name)
|
||||
if err != nil {
|
||||
log.Err(err).Str("email", email).Msg("failed to create OIDC user")
|
||||
return UserAuthTokenDetail{}, err
|
||||
}
|
||||
|
||||
log.Info().Str("email", email).Msg("OIDC user created successfully")
|
||||
}
|
||||
|
||||
// Create session token with extended session (7 days)
|
||||
return svc.createSessionToken(ctx, usr.ID, true)
|
||||
}
|
||||
|
||||
// registerOIDCUser creates a new user for OIDC authentication
|
||||
func (svc *UserService) registerOIDCUser(ctx context.Context, email, name string) (repo.UserOut, error) {
|
||||
// Create a new group for the user (OIDC users always create their own group for now)
|
||||
group, err := svc.repos.Groups.GroupCreate(ctx, "Home")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to create group for OIDC user")
|
||||
return repo.UserOut{}, err
|
||||
}
|
||||
|
||||
// Create user without password (nil password for OIDC users)
|
||||
usrCreate := repo.UserCreate{
|
||||
Name: name,
|
||||
Email: email,
|
||||
Password: nil, // OIDC users have no password
|
||||
IsSuperuser: false,
|
||||
GroupID: group.ID,
|
||||
IsOwner: true, // OIDC users are owners of their new group
|
||||
}
|
||||
|
||||
usr, err := svc.repos.Users.Create(ctx, usrCreate)
|
||||
if err != nil {
|
||||
return repo.UserOut{}, err
|
||||
}
|
||||
|
||||
// Create default labels and locations for the new group
|
||||
log.Debug().Msg("creating default labels for OIDC user")
|
||||
for _, label := range defaultLabels() {
|
||||
_, err := svc.repos.Labels.Create(ctx, group.ID, label)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to create default label")
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().Msg("creating default locations for OIDC user")
|
||||
for _, location := range defaultLocations() {
|
||||
_, err := svc.repos.Locations.Create(ctx, group.ID, location)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to create default location")
|
||||
}
|
||||
}
|
||||
|
||||
return usr, nil
|
||||
}
|
||||
|
||||
func (svc *UserService) Logout(ctx context.Context, token string) error {
|
||||
hash := hasher.HashToken(token)
|
||||
err := svc.repos.AuthTokens.DeleteToken(ctx, hash)
|
||||
|
||||
@@ -34,7 +34,8 @@ func (User) Fields() []ent.Field {
|
||||
Unique(),
|
||||
field.String("password").
|
||||
MaxLen(255).
|
||||
NotEmpty().
|
||||
Nillable().
|
||||
Optional().
|
||||
Sensitive(),
|
||||
field.Bool("is_superuser").
|
||||
Default(false),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- SQLite doesn't support ALTER COLUMN directly, so we need to recreate the table
|
||||
-- Create a temporary table with the new schema
|
||||
CREATE TABLE users_temp (
|
||||
id uuid not null
|
||||
primary key,
|
||||
created_at datetime not null,
|
||||
updated_at datetime not null,
|
||||
name text not null,
|
||||
email text not null,
|
||||
password text,
|
||||
is_superuser bool default false not null,
|
||||
superuser bool default false not null,
|
||||
role text default 'user' not null,
|
||||
activated_on datetime,
|
||||
group_users uuid not null
|
||||
constraint users_groups_users
|
||||
references groups
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
-- Copy data from the original table
|
||||
INSERT INTO users_temp SELECT * FROM users;
|
||||
|
||||
-- Drop the original table
|
||||
DROP TABLE users;
|
||||
|
||||
-- Rename the temporary table
|
||||
ALTER TABLE users_temp RENAME TO users;
|
||||
|
||||
-- Recreate the unique index
|
||||
CREATE UNIQUE INDEX users_email_key on users (email);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
-- Create the original table structure
|
||||
CREATE TABLE users_temp (
|
||||
id uuid not null
|
||||
primary key,
|
||||
created_at datetime not null,
|
||||
updated_at datetime not null,
|
||||
name text not null,
|
||||
email text not null,
|
||||
password text not null,
|
||||
is_superuser bool default false not null,
|
||||
superuser bool default false not null,
|
||||
role text default 'user' not null,
|
||||
activated_on datetime,
|
||||
group_users uuid not null
|
||||
constraint users_groups_users
|
||||
references groups
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
-- Copy data from the current table (this will fail if there are NULL passwords)
|
||||
INSERT INTO users_temp SELECT * FROM users;
|
||||
|
||||
-- Drop the current table
|
||||
DROP TABLE users;
|
||||
|
||||
-- Rename the temporary table
|
||||
ALTER TABLE users_temp RENAME TO users;
|
||||
|
||||
-- Recreate the unique index
|
||||
CREATE UNIQUE INDEX users_email_key on users (email);
|
||||
-- +goose StatementEnd
|
||||
@@ -19,7 +19,7 @@ type (
|
||||
UserCreate struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Password *string `json:"password"`
|
||||
IsSuperuser bool `json:"isSuperuser"`
|
||||
GroupID uuid.UUID `json:"groupID"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
@@ -48,6 +48,11 @@ var (
|
||||
)
|
||||
|
||||
func mapUserOut(user *ent.User) UserOut {
|
||||
var passwordHash string
|
||||
if user.Password != nil {
|
||||
passwordHash = *user.Password
|
||||
}
|
||||
|
||||
return UserOut{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
@@ -55,7 +60,7 @@ func mapUserOut(user *ent.User) UserOut {
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
GroupID: user.Edges.Group.ID,
|
||||
GroupName: user.Edges.Group.Name,
|
||||
PasswordHash: user.Password,
|
||||
PasswordHash: passwordHash,
|
||||
IsOwner: user.Role == "owner",
|
||||
}
|
||||
}
|
||||
@@ -85,15 +90,20 @@ func (r *UserRepository) Create(ctx context.Context, usr UserCreate) (UserOut, e
|
||||
role = user.RoleOwner
|
||||
}
|
||||
|
||||
entUser, err := r.db.User.
|
||||
createQuery := r.db.User.
|
||||
Create().
|
||||
SetName(usr.Name).
|
||||
SetEmail(usr.Email).
|
||||
SetPassword(usr.Password).
|
||||
SetIsSuperuser(usr.IsSuperuser).
|
||||
SetGroupID(usr.GroupID).
|
||||
SetRole(role).
|
||||
Save(ctx)
|
||||
SetRole(role)
|
||||
|
||||
// Only set password if provided (non-nil)
|
||||
if usr.Password != nil {
|
||||
createQuery = createQuery.SetPassword(*usr.Password)
|
||||
}
|
||||
|
||||
entUser, err := createQuery.Save(ctx)
|
||||
if err != nil {
|
||||
return UserOut{}, err
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
)
|
||||
|
||||
func userFactory() UserCreate {
|
||||
password := fk.Str(10)
|
||||
return UserCreate{
|
||||
Name: fk.Str(10),
|
||||
Email: fk.Email(),
|
||||
Password: fk.Str(10),
|
||||
Password: &password,
|
||||
IsSuperuser: fk.Bool(),
|
||||
GroupID: tGroup.ID,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type Config struct {
|
||||
Demo bool `yaml:"demo"`
|
||||
Debug DebugConf `yaml:"debug"`
|
||||
Options Options `yaml:"options"`
|
||||
OIDC OIDCConf `yaml:"oidc"`
|
||||
LabelMaker LabelMakerConf `yaml:"labelmaker"`
|
||||
Thumbnail Thumbnail `yaml:"thumbnail"`
|
||||
Barcode BarcodeAPIConf `yaml:"barcode"`
|
||||
@@ -38,6 +39,9 @@ type Options struct {
|
||||
CurrencyConfig string `yaml:"currencies"`
|
||||
GithubReleaseCheck bool `yaml:"check_github_release" conf:"default:true"`
|
||||
AllowAnalytics bool `yaml:"allow_analytics" conf:"default:false"`
|
||||
AllowLocalLogin bool `yaml:"allow_local_login" conf:"default:true"`
|
||||
TrustProxy bool `yaml:"trust_proxy" conf:"default:false"`
|
||||
Hostname string `yaml:"hostname"`
|
||||
}
|
||||
|
||||
type Thumbnail struct {
|
||||
@@ -73,6 +77,22 @@ type LabelMakerConf struct {
|
||||
LabelServiceTimeout *time.Duration `yaml:"label_service_timeout"`
|
||||
}
|
||||
|
||||
type OIDCConf struct {
|
||||
Enabled bool `yaml:"enabled" conf:"default:false"`
|
||||
IssuerURL string `yaml:"issuer_url"`
|
||||
ClientID string `yaml:"client_id"`
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
Scope string `yaml:"scope" conf:"default:openid profile email offline_access"`
|
||||
AllowedGroups string `yaml:"allowed_groups"`
|
||||
Force bool `yaml:"force" conf:"default:false"`
|
||||
GroupClaim string `yaml:"group_claim" conf:"default:groups"`
|
||||
EmailClaim string `yaml:"email_claim" conf:"default:email"`
|
||||
NameClaim string `yaml:"name_claim" conf:"default:name"`
|
||||
ButtonText string `yaml:"button_text" conf:"default:Sign in with OIDC"`
|
||||
StateExpiry time.Duration `yaml:"state_expiry" conf:"default:10m"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout" conf:"default:30s"`
|
||||
}
|
||||
|
||||
type BarcodeAPIConf struct {
|
||||
TokenBarcodespider string `yaml:"token_barcodespider"`
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"dont_join_group": "Don't want to join a group?",
|
||||
"joining_group": "You're Joining an Existing Group!",
|
||||
"login": "Login",
|
||||
"or": "or",
|
||||
"register": "Register",
|
||||
"remember_me": "Remember Me",
|
||||
"set_email": "What's your email?",
|
||||
@@ -272,6 +273,14 @@
|
||||
"invalid_email": "Invalid email address",
|
||||
"invalid_email_password": "Invalid email or password",
|
||||
"login_success": "Logged in successfully",
|
||||
"oidc_access_denied": "Access denied: Your account does not have the required role/group membership",
|
||||
"oidc_auth_failed": "OIDC authentication failed",
|
||||
"oidc_invalid_response": "Invalid OIDC response received",
|
||||
"oidc_provider_error": "OIDC provider returned an error",
|
||||
"oidc_security_error": "OIDC security error - possible CSRF attack",
|
||||
"oidc_session_expired": "OIDC session has expired",
|
||||
"oidc_token_expired": "OIDC token has expired",
|
||||
"oidc_token_invalid": "OIDC token signature is invalid",
|
||||
"problem_registering": "Problem registering user",
|
||||
"user_registered": "User registered"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
const ctx = useAuthContext();
|
||||
|
||||
const api = usePublicApi();
|
||||
|
||||
// Use useState for OIDC error state management
|
||||
const oidcError = useState<string | null>("oidc_error", () => null);
|
||||
|
||||
const { data: status } = useAsyncData(async () => {
|
||||
const { data } = await api.status();
|
||||
@@ -57,6 +60,11 @@
|
||||
email.value = "demo@example.com";
|
||||
loginPassword.value = "demo";
|
||||
}
|
||||
|
||||
// Auto-redirect to OIDC if force is enabled, but not if there's an OIDC error
|
||||
if (status?.oidc?.enabled && status?.oidc?.force && !oidcError.value) {
|
||||
loginWithOIDC();
|
||||
}
|
||||
});
|
||||
|
||||
const isEvilAccentTheme = useIsThemeInList([
|
||||
@@ -138,6 +146,35 @@
|
||||
if (groupToken.value !== "") {
|
||||
registerForm.value = true;
|
||||
}
|
||||
|
||||
// Handle OIDC error notifications from URL parameters
|
||||
const oidcErrorParam = route.query.oidc_error;
|
||||
if (typeof oidcErrorParam === "string" && oidcErrorParam.startsWith("oidc_")) {
|
||||
// Set the error state to prevent auto-redirect
|
||||
oidcError.value = oidcErrorParam;
|
||||
|
||||
const translationKey = `index.toast.${oidcErrorParam}`;
|
||||
let errorMessage = t(translationKey);
|
||||
|
||||
// If there are additional details, append them
|
||||
const details = route.query.details;
|
||||
if (typeof details === "string" && details.trim() !== "") {
|
||||
errorMessage += `: ${details}`;
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
|
||||
// Clean up the URL by removing the error parameters
|
||||
const newQuery = { ...route.query };
|
||||
delete newQuery.oidc_error;
|
||||
delete newQuery.details;
|
||||
router.replace({ query: newQuery });
|
||||
|
||||
// Clear the error state after showing the message (with a delay to ensure auto-redirect doesn't trigger)
|
||||
setTimeout(() => {
|
||||
oidcError.value = null;
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -165,6 +202,10 @@
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function loginWithOIDC() {
|
||||
window.location.href = '/api/v1/users/login/oidc';
|
||||
}
|
||||
|
||||
const [registerForm, toggleLogin] = useToggle();
|
||||
</script>
|
||||
|
||||
@@ -285,7 +326,7 @@
|
||||
{{ $t("index.login") }}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-2">
|
||||
<CardContent v-if="status?.oidc?.allowLocal !== false" class="flex flex-col gap-2">
|
||||
<template v-if="status && status.demo">
|
||||
<p class="text-center text-xs italic">{{ $t("global.demo_instance") }}</p>
|
||||
<p class="text-center text-xs">
|
||||
@@ -301,17 +342,33 @@
|
||||
<FormCheckbox v-model="remember" :label="$t('index.remember_me')" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button class="w-full" type="submit" :class="loading ? 'loading' : ''" :disabled="loading">
|
||||
<CardFooter class="flex flex-col gap-2">
|
||||
<Button v-if="status?.oidc?.allowLocal !== false" class="w-full" type="submit" :class="loading ? 'loading' : ''" :disabled="loading">
|
||||
{{ $t("index.login") }}
|
||||
</Button>
|
||||
|
||||
<div v-if="status?.oidc?.enabled && status?.oidc?.allowLocal !== false" class="flex w-full items-center gap-2">
|
||||
<hr class="flex-1" />
|
||||
<span class="text-xs text-muted-foreground">{{ $t("index.or") }}</span>
|
||||
<hr class="flex-1" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="status?.oidc?.enabled"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="loginWithOIDC"
|
||||
>
|
||||
{{ status.oidc.buttonText || 'Sign in with OIDC' }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Transition>
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
v-if="status && status.allowRegistration"
|
||||
v-if="status && status.allowRegistration && status?.oidc?.allowLocal !== false"
|
||||
class="group"
|
||||
variant="link"
|
||||
data-testid="register-button"
|
||||
|
||||
Reference in New Issue
Block a user