add oidc integration

This commit is contained in:
Jeff Rescignano
2025-09-07 02:30:26 -04:00
parent 8481f052e1
commit 2e685d8e14
16 changed files with 758 additions and 15 deletions

View File

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

View File

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

View File

@@ -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, &regData); err != nil {

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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