diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index c77cff3b..28641700 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -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, + }, }) } } diff --git a/backend/app/api/handlers/v1/v1_ctrl_auth.go b/backend/app/api/handlers/v1/v1_ctrl_auth.go index 64d556fe..067b24a5 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_auth.go +++ b/backend/app/api/handlers/v1/v1_ctrl_auth.go @@ -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 + } +} diff --git a/backend/app/api/handlers/v1/v1_ctrl_user.go b/backend/app/api/handlers/v1/v1_ctrl_user.go index a99c7b86..0caaee9c 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_user.go +++ b/backend/app/api/handlers/v1/v1_ctrl_user.go @@ -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 { diff --git a/backend/app/api/providers/oidc.go b/backend/app/api/providers/oidc.go new file mode 100644 index 00000000..ecede988 --- /dev/null +++ b/backend/app/api/providers/oidc.go @@ -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) +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 5f6a5ca6..647ba628 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -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, diff --git a/backend/go.mod b/backend/go.mod index d80ed397..80edd2d9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index fe5062e6..9a428429 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/core/services/main_test.go b/backend/internal/core/services/main_test.go index b792ab37..43d5921a 100644 --- a/backend/internal/core/services/main_test.go +++ b/backend/internal/core/services/main_test.go @@ -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, }) diff --git a/backend/internal/core/services/service_user.go b/backend/internal/core/services/service_user.go index b3397527..20c40086 100644 --- a/backend/internal/core/services/service_user.go +++ b/backend/internal/core/services/service_user.go @@ -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) diff --git a/backend/internal/data/ent/schema/user.go b/backend/internal/data/ent/schema/user.go index bd747aea..aa1e3417 100644 --- a/backend/internal/data/ent/schema/user.go +++ b/backend/internal/data/ent/schema/user.go @@ -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), diff --git a/backend/internal/data/migrations/sqlite3/20250907000000_make_password_nullable.sql b/backend/internal/data/migrations/sqlite3/20250907000000_make_password_nullable.sql new file mode 100644 index 00000000..90679de5 --- /dev/null +++ b/backend/internal/data/migrations/sqlite3/20250907000000_make_password_nullable.sql @@ -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 \ No newline at end of file diff --git a/backend/internal/data/repo/repo_users.go b/backend/internal/data/repo/repo_users.go index 8007fbc0..707862c0 100644 --- a/backend/internal/data/repo/repo_users.go +++ b/backend/internal/data/repo/repo_users.go @@ -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 } diff --git a/backend/internal/data/repo/repo_users_test.go b/backend/internal/data/repo/repo_users_test.go index abf62580..65de5e0b 100644 --- a/backend/internal/data/repo/repo_users_test.go +++ b/backend/internal/data/repo/repo_users_test.go @@ -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, } diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index bad0ccf0..991fb582 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -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"` } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 7c628b24..e637d12c 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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" } diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index a1332fe2..2247d5c5 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -41,6 +41,9 @@ const ctx = useAuthContext(); const api = usePublicApi(); + + // Use useState for OIDC error state management + const oidcError = useState("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(); @@ -285,7 +326,7 @@ {{ $t("index.login") }} - +