Files
xinmi/internal/application/service/user.go
T

2037 lines
67 KiB
Go
Raw Normal View History

package service
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
var (
jwtSecretOnce sync.Once
jwtSecret string
// ErrUserEmailExists is returned by Register when the target entity's
// email already exists.
ErrUserEmailExists = errors.New("user with this email already exists")
// ErrUserUsernameExists is returned by Register when the target entity's
// username already exists.
ErrUserUsernameExists = errors.New("user with this username already exists")
// ErrUserIdentityConflict is returned by AdminCreateUser when only part
// of the requested identity (email or username) collides with an existing
// user, so a blind idempotent retry would return the wrong account.
ErrUserIdentityConflict = errors.New("email and username refer to conflicting existing identities")
// ErrPasswordPolicy is returned when a newly chosen password does not
// meet the product's public 3-32 character contract.
// It is exported so HTTP handlers can translate the failure to a 400
// without exposing bcrypt or persistence errors.
ErrPasswordPolicy = secutils.ErrPasswordPolicy
// ErrComplexPasswordPolicy is returned when the runtime complex-password
// switch is on and the new password is missing a required character class.
ErrComplexPasswordPolicy = secutils.ErrComplexPasswordPolicy
// ErrInvalidOldPassword is returned by ChangePassword when the supplied
// current password does not match the stored hash. Handlers map this to
// a 400 so callers can prompt the user without treating it as a 500.
ErrInvalidOldPassword = errors.New("invalid old password")
// ErrSamePassword is returned when the new password equals the current
// one so callers can reject no-op rotations that would still revoke
// every session.
ErrSamePassword = errors.New("new password must differ from current password")
)
// Machine-readable change-password failure reasons for HTTP details fields.
const (
DetailInvalidOldPassword = "invalid_old_password"
DetailPasswordPolicy = "password_policy"
DetailSamePassword = "same_password"
)
// getJwtSecret retrieves the JWT secret from the environment, falling back to a securely generated random secret.
func getJwtSecret() string {
jwtSecretOnce.Do(func() {
if envSecret := strings.TrimSpace(os.Getenv("JWT_SECRET")); envSecret != "" {
jwtSecret = envSecret
return
}
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
panic(fmt.Sprintf("failed to generate JWT secret: %v", err))
}
jwtSecret = base64.StdEncoding.EncodeToString(randomBytes)
})
return jwtSecret
}
// userService implements the UserService interface
type userService struct {
userRepo interfaces.UserRepository
tokenRepo interfaces.AuthTokenRepository
tenantService interfaces.TenantService
memberService interfaces.TenantMemberService
config *config.Config
systemSettingSvc interfaces.SystemSettingService
}
// NewUserService creates a new user service instance
func NewUserService(
configInfo *config.Config,
userRepo interfaces.UserRepository,
tokenRepo interfaces.AuthTokenRepository,
tenantService interfaces.TenantService,
memberService interfaces.TenantMemberService,
systemSettingSvc interfaces.SystemSettingService,
) interfaces.UserService {
return &userService{
userRepo: userRepo,
tokenRepo: tokenRepo,
tenantService: tenantService,
memberService: memberService,
config: configInfo,
systemSettingSvc: systemSettingSvc,
}
}
func (s *userService) complexPasswordEnabled(ctx context.Context) bool {
return ResolveComplexPasswordEnabled(ctx, s.config, s.systemSettingSvc)
}
// Register creates a new user account
func (s *userService) Register(ctx context.Context, req *types.RegisterRequest) (*types.User, error) {
logger.Info(ctx, "Start user registration")
// Validate input
if req.Username == "" || req.Email == "" || req.Password == "" {
return nil, errors.New("username, email and password are required")
}
// Check if user already exists
existingUser, _ := s.userRepo.GetUserByEmail(ctx, req.Email)
if existingUser != nil {
return nil, ErrUserEmailExists
}
existingUser, _ = s.userRepo.GetUserByUsername(ctx, req.Username)
if existingUser != nil {
return nil, ErrUserUsernameExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
logger.Errorf(ctx, "Failed to hash password: %v", err)
return nil, errors.New("failed to process password")
}
provisioning := req.TenantProvisioning
if provisioning == "" {
provisioning = types.TenantProvisioningCreatePersonal
}
if !provisioning.IsValid() {
return nil, fmt.Errorf("invalid tenant provisioning mode %q", provisioning)
}
var createdTenant *types.Tenant
if provisioning == types.TenantProvisioningCreatePersonal {
// Note: RetrieverEngines is left empty - system will use defaults
// from RETRIEVE_DRIVER env.
tenant := &types.Tenant{
Name: fmt.Sprintf("%s's Workspace", secutils.SanitizeForLog(req.Username)),
Description: "Default workspace",
Status: "active",
}
createdTenant, err = s.tenantService.CreateTenant(ctx, tenant)
if err != nil {
logger.Errorf(ctx, "Failed to create workspace")
return nil, errors.New("failed to create workspace")
}
}
// Create user
user := &types.User{
ID: uuid.New().String(),
Username: req.Username,
Email: req.Email,
PasswordHash: string(hashedPassword),
TenantID: 0,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if createdTenant != nil {
user.TenantID = createdTenant.ID
}
err = s.userRepo.CreateUser(ctx, user)
if err != nil {
logger.Errorf(ctx, "Failed to create user: %v", err)
if createdTenant != nil {
if rollbackErr := s.tenantService.DeleteTenant(ctx, createdTenant.ID); rollbackErr != nil {
logger.Errorf(ctx, "Failed to roll back tenant %d after user creation failure: %v", createdTenant.ID, rollbackErr)
}
}
return nil, errors.New("failed to create user")
}
// Bootstrap an Owner membership so the registrant has full control over
// the tenant their account just created. Failure here only logs — the
// user record exists and the auth middleware's orphan-tenant recovery
// path will recreate the membership on next login.
if createdTenant != nil && s.memberService != nil {
if _, err := s.memberService.EnsureOwner(ctx, user.ID, createdTenant.ID); err != nil {
logger.Errorf(ctx, "Failed to create owner membership for user %s tenant %d: %v",
user.ID, createdTenant.ID, err)
_ = s.userRepo.DeleteUser(ctx, user.ID)
_ = s.tenantService.DeleteTenant(ctx, createdTenant.ID)
return nil, errors.New("failed to finalise workspace ownership")
}
}
logger.Info(ctx, "User registered successfully")
return user, nil
}
// Login authenticates a user and returns tokens
func (s *userService) Login(ctx context.Context, req *types.LoginRequest) (*types.LoginResponse, error) {
logger.Info(ctx, "Start user login")
invalidCredentials := func() (*types.LoginResponse, error) {
return &types.LoginResponse{
Success: false,
Message: "Invalid account or password",
}, nil
}
if req == nil || req.Password == "" {
return invalidCredentials()
}
username, email := strings.TrimSpace(req.Username), strings.TrimSpace(req.Email)
var user *types.User
var err error
if username != "" {
user, err = s.userRepo.GetUserByUsername(ctx, username)
} else if email != "" {
user, err = s.userRepo.GetUserByEmail(ctx, email)
} else {
return invalidCredentials()
}
if err != nil || user == nil {
logger.Warn(ctx, "Login account lookup failed")
return invalidCredentials()
}
// When both identifiers are supplied they must name the same account.
// Never fall back to a different user's email after a username failure.
if username != "" && email != "" {
emailUser, emailErr := s.userRepo.GetUserByEmail(ctx, email)
if emailErr != nil || emailUser == nil || emailUser.ID != user.ID {
return invalidCredentials()
}
}
if !user.IsActive {
logger.Warn(ctx, "Login account is disabled")
return invalidCredentials()
}
// Verify password
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
if err != nil {
logger.Warn(ctx, "Password verification failed")
return invalidCredentials()
}
logger.Info(ctx, "Password verification successful")
// Generate tokens. Resolve the target tenant once so the JWT claim
// and the tenant we return below agree — otherwise an honoured
// "last active tenant" preference would mint a token for tenant N
// but tell the client they're in their home tenant.
logger.Info(ctx, "Generating tokens")
resolvedTenantID := s.resolveLoginTenantID(ctx, user)
accessToken, refreshToken, err := s.generateTokensForTenant(ctx, user, resolvedTenantID)
if err != nil {
logger.Errorf(ctx, "Failed to generate tokens: %v", err)
return &types.LoginResponse{
Success: false,
Message: "Login failed",
}, nil
}
logger.Info(ctx, "Tokens generated successfully")
// Get tenant information. A zero resolved ID is a valid tenantless
// identity, not a failed tenant lookup.
var tenant *types.Tenant
if resolvedTenantID > 0 {
tenant, err = s.tenantService.GetTenantByID(ctx, resolvedTenantID)
if err != nil {
logger.Warn(ctx, "Failed to get tenant info")
} else {
logger.Info(ctx, "Tenant information retrieved successfully")
}
}
memberships := s.buildMembershipsForUser(ctx, user, tenant)
logger.Info(ctx, "User logged in successfully")
return &types.LoginResponse{
Success: true,
Message: "Login successful",
User: user,
ActiveTenant: tenant,
Memberships: memberships,
Token: accessToken,
RefreshToken: refreshToken,
}, nil
}
// buildMembershipsForUser returns the user's tenant memberships projected
// into the login-response shape. activeTenant (if non-nil and matching one
// of the rows) is used to reuse its already-fetched name without a second
// DB lookup; other tenants are looked up individually. Errors are logged
// but never propagated — a missing memberships array degrades gracefully
// to length 0 rather than failing the whole login.
//
// When the membership service is unavailable (e.g. in tests that wire only
// part of the dependency graph), this falls back to a single synthesized
// row built from User.TenantID + the active tenant so callers always get
// at least one entry.
func (s *userService) BuildLoginMemberships(
ctx context.Context,
user *types.User,
activeTenant *types.Tenant,
) []types.Membership {
return s.buildMembershipsForUser(ctx, user, activeTenant)
}
func (s *userService) buildMembershipsForUser(
ctx context.Context,
user *types.User,
activeTenant *types.Tenant,
) []types.Membership {
if user == nil {
return []types.Membership{}
}
// Only synthesise a membership from User.TenantID when the membership
// service is entirely unavailable (partial DI graphs / legacy tests).
// Once ListByUser is reachable, an empty or fully-filtered result is
// authoritative: inventing a row from a stale users.tenant_id is what
// kept removed workspaces visible in the space switcher (#2586).
if s.memberService == nil {
return synthFallbackMembership(user, activeTenant)
}
rows, err := s.memberService.ListByUser(ctx, user.ID)
if err != nil {
logger.Warnf(ctx, "Failed to list memberships for user %s: %v", user.ID, err)
return []types.Membership{}
}
if len(rows) == 0 {
return []types.Membership{}
}
// 收集需要批量查询名称的 tenant id(跳过 activeTenant 因为它已经在手)。
needsLookup := make([]uint64, 0, len(rows))
for _, m := range rows {
if m == nil || m.Status != types.TenantMemberStatusActive {
continue
}
if activeTenant != nil && m.TenantID == activeTenant.ID {
continue
}
needsLookup = append(needsLookup, m.TenantID)
}
tenantByID := map[uint64]*types.Tenant{}
if len(needsLookup) > 0 {
if found, terr := s.tenantService.GetTenantsByIDs(ctx, needsLookup); terr == nil {
tenantByID = found
} else {
logger.Warnf(ctx, "Failed to batch-load tenants for memberships (user=%s): %v",
user.ID, terr)
}
}
out := make([]types.Membership, 0, len(rows))
for _, m := range rows {
if m == nil || m.Status != types.TenantMemberStatusActive {
continue
}
name := ""
if activeTenant != nil && m.TenantID == activeTenant.ID {
name = activeTenant.Name
} else if t, ok := tenantByID[m.TenantID]; ok && t != nil {
name = t.Name
}
// Drop memberships whose tenant row is gone (deleted tenant or
// stale tenant_members left over from before cascade delete).
if strings.TrimSpace(name) == "" {
continue
}
out = append(out, types.Membership{
TenantID: m.TenantID,
TenantName: name,
Role: m.Role,
})
}
return out
}
// synthFallbackMembership returns a single-row membership list inferred
// from User.TenantID. Used only when the membership service itself is
// unavailable (partial DI graphs in tests, or a rollout window where the
// service has not been wired yet) so the response shape stays consistent.
//
// Callers that successfully queried tenant_members must NOT use this
// helper: an empty membership list is authoritative and synthesising
// from users.tenant_id would re-surface workspaces the user was removed
// from (#2586).
//
// The fallback role is intentionally TenantRoleViewer (least privilege):
// the login response only feeds UI rendering, and the backend re-derives
// the real role from tenant_members on every request. If membership data
// is temporarily unavailable, showing a Viewer UI is preferable to
// granting a misleading Owner UI that would surface admin controls the
// backend will then 403. Once the membership row appears (via the auth
// middleware's home-tenant auto-promotion or an admin invitation) the
// next /auth/me-style refresh will upgrade the UI to the real role.
func synthFallbackMembership(user *types.User, activeTenant *types.Tenant) []types.Membership {
if user == nil || user.TenantID == 0 {
// Always return a non-nil slice so the login response carries an
// empty array rather than `null`, preserving the documented
// "always populated" contract on LoginResponse.Memberships.
return []types.Membership{}
}
name := ""
if activeTenant != nil && activeTenant.ID == user.TenantID {
name = activeTenant.Name
}
return []types.Membership{{
TenantID: user.TenantID,
TenantName: name,
Role: types.TenantRoleViewer,
}}
}
// GetOIDCAuthorizationURL builds the OIDC authorization URL.
func (s *userService) GetOIDCAuthorizationURL(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error) {
cfg, err := s.getOIDCConfig(ctx)
if err != nil {
return nil, err
}
if strings.TrimSpace(redirectURI) == "" {
return nil, errors.New("redirect_uri is required")
}
nonce, err := generateRandomString(24)
if err != nil {
return nil, fmt.Errorf("failed to generate state: %w", err)
}
state, err := secutils.SignOIDCState(&secutils.OIDCStatePayload{
Nonce: nonce,
RedirectURI: strings.TrimSpace(redirectURI),
})
if err != nil {
return nil, fmt.Errorf("failed to encode OIDC state: %w", err)
}
query := url.Values{}
query.Set("response_type", "code")
query.Set("client_id", cfg.ClientID)
query.Set("redirect_uri", redirectURI)
query.Set("scope", strings.Join(cfg.Scopes, " "))
query.Set("state", state)
authURL := cfg.AuthorizationEndpoint
if strings.Contains(authURL, "?") {
authURL += "&" + query.Encode()
} else {
authURL += "?" + query.Encode()
}
return &types.OIDCAuthURLResponse{
Success: true,
ProviderDisplayName: cfg.ProviderDisplayName,
AuthorizationURL: authURL,
State: state,
Nonce: nonce,
}, nil
}
// LoginWithOIDC exchanges code for tokens, loads user info, provisions user if
// needed, and returns local login tokens. provisioning is the default tenant
// mode applied only when a brand-new local user is auto-created; it is resolved
// by the caller from the shared auth.default_tenant_mode policy.
func (s *userService) LoginWithOIDC(
ctx context.Context,
code, redirectURI string,
provisioning types.TenantProvisioningMode,
) (*types.OIDCCallbackResponse, error) {
if strings.TrimSpace(code) == "" {
return nil, errors.New("code is required")
}
if strings.TrimSpace(redirectURI) == "" {
return nil, errors.New("redirect_uri is required")
}
cfg, err := s.getOIDCConfig(ctx)
if err != nil {
return nil, err
}
tokenResp, err := s.exchangeOIDCCode(ctx, cfg, code, redirectURI)
if err != nil {
return nil, err
}
userInfo, err := s.resolveOIDCUserInfo(ctx, cfg, tokenResp)
if err != nil {
return nil, err
}
if strings.TrimSpace(userInfo.Email) == "" {
return nil, errors.New("OIDC provider did not return email")
}
user, err := s.userRepo.GetUserByEmail(ctx, userInfo.Email)
if err != nil && !isUserLookupNotFound(err) {
return nil, fmt.Errorf("failed to query user by email: %w", err)
}
isNewUser := false
if isUserLookupNotFound(err) || user == nil {
user, err = s.provisionOIDCUser(ctx, userInfo, provisioning)
if err != nil {
return nil, err
}
isNewUser = true
}
if !user.IsActive {
return &types.OIDCCallbackResponse{Success: false, Message: "Account is disabled"}, nil
}
// Resolve target tenant once so the JWT claim and the tenant we
// return below stay in sync; see Login for the rationale.
resolvedTenantID := s.resolveLoginTenantID(ctx, user)
accessToken, refreshToken, err := s.generateTokensForTenant(ctx, user, resolvedTenantID)
if err != nil {
return nil, fmt.Errorf("failed to generate local tokens: %w", err)
}
// 拉取 tenant + memberships,让 OIDC 登录的返回结构与本地登录一致,
// 前端无须为 OIDC 单独走一次 /auth/me 才能拿到角色。
var tenant *types.Tenant
if resolvedTenantID > 0 {
if t, terr := s.tenantService.GetTenantByID(ctx, resolvedTenantID); terr == nil {
tenant = t
} else {
logger.Warnf(ctx, "OIDC login: failed to load tenant %d for user %s: %v",
resolvedTenantID, user.ID, terr)
}
}
memberships := s.buildMembershipsForUser(ctx, user, tenant)
return &types.OIDCCallbackResponse{
Success: true,
Message: "登录成功",
User: user,
Tenant: tenant,
Memberships: memberships,
Token: accessToken,
RefreshToken: refreshToken,
IsNewUser: isNewUser,
}, nil
}
// GetUserByID gets a user by ID
func (s *userService) GetUserByID(ctx context.Context, id string) (*types.User, error) {
return s.userRepo.GetUserByID(ctx, id)
}
// GetUsersByIDs proxies to the repository batch fetch. Returns an empty
// map for an empty input; missing ids are absent from the result.
func (s *userService) GetUsersByIDs(ctx context.Context, ids []string) (map[string]*types.User, error) {
return s.userRepo.GetUsersByIDs(ctx, ids)
}
// GetUserByEmail gets a user by email
func (s *userService) GetUserByEmail(ctx context.Context, email string) (*types.User, error) {
return s.userRepo.GetUserByEmail(ctx, email)
}
// GetUserByUsername gets a user by username
func (s *userService) GetUserByUsername(ctx context.Context, username string) (*types.User, error) {
return s.userRepo.GetUserByUsername(ctx, username)
}
// GetUserByTenantID gets the first user (owner) of a tenant
func (s *userService) GetUserByTenantID(ctx context.Context, tenantID uint64) (*types.User, error) {
return s.userRepo.GetUserByTenantID(ctx, tenantID)
}
// UpdateUser updates user information
func (s *userService) UpdateUser(ctx context.Context, user *types.User) error {
user.UpdatedAt = time.Now()
return s.userRepo.UpdateUser(ctx, user)
}
// ListSystemAdmins lists users with IsSystemAdmin=true. Thin pass-through
// to the repository; the handler enforces SystemAdmin gating, so the
// service does not duplicate the role check here.
func (s *userService) ListSystemAdmins(
ctx context.Context, offset, limit int,
) ([]*types.User, int64, error) {
return s.userRepo.ListSystemAdmins(ctx, offset, limit)
}
// RevokeSystemAdmin removes system-admin privileges through the
// repository's transactional guard so concurrent revokes cannot remove
// the final administrator.
func (s *userService) RevokeSystemAdmin(ctx context.Context, userID, actorID string) (*types.User, error) {
return s.userRepo.RevokeSystemAdmin(ctx, userID, actorID)
}
// UpdateUserPreferences applies a partial update over the user's
// preferences blob. PATCH semantics: only keys present in `patch`
// (non-nil pointer fields) replace the existing value; everything else
// is preserved. This lets the front-end PUT only the preference that
// changed without having to read-modify-write the whole struct, and
// also makes the endpoint forward-compatible — older clients that
// don't know about newer keys won't accidentally erase them.
func (s *userService) UpdateUserPreferences(
ctx context.Context,
userID string,
patch types.UserPreferences,
) (types.UserPreferences, error) {
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return types.UserPreferences{}, err
}
merged := user.Preferences
if patch.LastActiveTenantID != nil {
// *0 = "forget my preference, fall back to home on next login";
// any positive value = set/replace. We do not validate membership
// here — invalid values get culled on the next login via
// resolveLoginTenantID, keeping this endpoint cheap.
if *patch.LastActiveTenantID == 0 {
merged.LastActiveTenantID = nil
} else {
v := *patch.LastActiveTenantID
merged.LastActiveTenantID = &v
}
}
user.Preferences = merged
user.UpdatedAt = time.Now()
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
return types.UserPreferences{}, err
}
return merged, nil
}
// DeleteUser deletes a user
func (s *userService) DeleteUser(ctx context.Context, id string) error {
return s.userRepo.DeleteUser(ctx, id)
}
// ChangePassword changes user password after verifying the current
// credential. The new password must satisfy ValidatePasswordPolicy so
// self-service rotation cannot introduce weaker passwords than
// registration / admin reset allow. On success every outstanding session
// is revoked so a stolen token cannot survive the rotation.
func (s *userService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return err
}
// Verify old password before policy checks so callers with a wrong
// current credential get a clear failure instead of a policy error.
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
return ErrInvalidOldPassword
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(newPassword)); err == nil {
return ErrSamePassword
}
if err := ValidatePasswordPolicy(newPassword, s.complexPasswordEnabled(ctx)); err != nil {
return err
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
user.PasswordHash = string(hashedPassword)
user.UpdatedAt = time.Now()
if user.Preferences.OidcOnlyLogin != nil && *user.Preferences.OidcOnlyLogin {
cleared := false
user.Preferences.OidcOnlyLogin = &cleared
}
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
return err
}
// Invalidate every outstanding session so a stolen token cannot
// survive a password rotation.
return s.tokenRepo.RevokeTokensByUserID(ctx, userID)
}
// AdminResetPassword replaces a user's password without checking the previous
// credential. Authorization and the cannot-reset-self rule live at the system
// admin HTTP boundary; this service owns the security-critical persistence and
// session invalidation so no caller can accidentally update only one of them.
func (s *userService) AdminResetPassword(ctx context.Context, userID, newPassword string) error {
if err := ValidatePasswordPolicy(newPassword, s.complexPasswordEnabled(ctx)); err != nil {
return err
}
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
user.PasswordHash = string(hashedPassword)
user.UpdatedAt = time.Now()
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
return err
}
return s.tokenRepo.RevokeTokensByUserID(ctx, userID)
}
// AdminCreateUser provisions a new local user on behalf of a SystemAdmin.
//
// An absent password generates a random one, returned exactly once.
// Any provided password, must satisfy ValidatePasswordPolicy.
//
// Delegates to Register, so duplicate checks, tenant provisioning and Owner
// membership bootstrapping match public registration.
func (s *userService) AdminCreateUser(
ctx context.Context,
req *types.AdminCreateUserRequest,
provisioning types.TenantProvisioningMode,
) (*types.User, string, error) {
if req == nil || strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Email) == "" {
return nil, "", errors.New("username and email are required")
}
password := ""
generated := false
complexPasswordEnabled := s.complexPasswordEnabled(ctx)
if req.Password == nil {
randomPassword, err := generatePolicyCompliantPassword(complexPasswordEnabled)
if err != nil {
return nil, "", fmt.Errorf("failed to generate password: %w", err)
}
password = randomPassword
generated = true
} else {
password = *req.Password
}
// Generation triggers only on an absent password. Any provided
// value, empty or whitespace-only, is hashed byte-for-byte and must
// satisfy the password policy.
if err := ValidatePasswordPolicy(password, complexPasswordEnabled); err != nil {
return nil, "", err
}
user, err := s.Register(ctx, &types.RegisterRequest{
Username: strings.TrimSpace(req.Username),
Email: strings.TrimSpace(req.Email),
Password: password,
TenantProvisioning: provisioning,
})
// WARN: idempotency is sequential only. Two concurrent creates of the
// same identity can race past Register's check; the loser gets a 500,
// and a retry resolves idempotently.
if err != nil {
// Register owns duplicate detection; on a duplicate we surface
// the existing row so the caller can respond idempotently. The
// sentinel names the colliding identity, so the lookup is
// targeted at exactly that key.
switch {
case errors.Is(err, ErrUserEmailExists):
return s.adminCreateUserOnDuplicate(
ctx, req, err,
func(ctx context.Context) (*types.User, error) {
return s.userRepo.GetUserByEmail(ctx, strings.TrimSpace(req.Email))
},
)
case errors.Is(err, ErrUserUsernameExists):
return s.adminCreateUserOnDuplicate(
ctx, req, err,
func(ctx context.Context) (*types.User, error) {
return s.userRepo.GetUserByUsername(ctx, strings.TrimSpace(req.Username))
},
)
}
return nil, "", err
}
if generated {
return user, password, nil
}
return user, "", nil
}
func (s *userService) adminCreateUserOnDuplicate(
ctx context.Context,
req *types.AdminCreateUserRequest,
dupErr error,
lookup func(context.Context) (*types.User, error),
) (*types.User, string, error) {
existing, lookupErr := lookup(ctx)
if lookupErr != nil || existing == nil {
return nil, "", dupErr
}
if adminCreateIdentityMatches(existing, req.Username, req.Email) {
return existing, "", dupErr
}
return nil, "", ErrUserIdentityConflict
}
// adminCreateIdentityMatches reports whether an existing row is the exact
// identity an admin create is idempotently retrying (both email and username).
func adminCreateIdentityMatches(existing *types.User, username, email string) bool {
if existing == nil {
return false
}
return existing.Username == strings.TrimSpace(username) &&
existing.Email == strings.TrimSpace(email)
}
// ValidatePassword validates user password
func (s *userService) ValidatePassword(ctx context.Context, userID string, password string) error {
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return err
}
return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
}
// GenerateTokens generates access and refresh tokens for user. The
// access token's tenant_id claim defaults to user.TenantID (home), but
// if the user has persisted a still-valid "last active tenant"
// preference we honour it instead — so login (and the refresh-token
// rotation path that also calls into here) lands the user back where
// they left off across devices. SwitchTenant remains the explicit tool
// for switching to an arbitrary membership.
func (s *userService) GenerateTokens(
ctx context.Context,
user *types.User,
) (accessToken, refreshToken string, err error) {
return s.generateTokensForTenant(ctx, user, s.resolveLoginTenantID(ctx, user))
}
// resolveLoginTenantID picks the tenant whose ID should be encoded in a
// freshly minted access token. The contract:
//
// 1. If the user has no LastActiveTenantID preference set (or it points
// at home), return home — the historical behaviour. A tenantless user
// with an active membership adopts their earliest membership instead;
// this repairs partial invitation/admin-assignment flows.
// 2. Otherwise validate the preference: the tenant must still exist and
// the user must still have an active membership (or be a cross-tenant
// superuser). Validation failure logs a warning, best-effort clears
// the stale preference (so we don't waste a DB round-trip on every
// subsequent login), and falls back to home.
//
// This is intentionally a private method on userService so it can reach
// memberService / tenantService / userRepo. Errors from the validation
// path never fail login; the worst case is the user lands in home.
func (s *userService) resolveLoginTenantID(ctx context.Context, user *types.User) uint64 {
if user == nil {
return 0
}
pref := user.Preferences.LastActiveTenantID
if pref == nil || *pref == 0 || *pref == user.TenantID {
return s.homeOrFirstMembershipTenant(ctx, user)
}
preferred := *pref
// Tenant must still exist.
if s.tenantService != nil {
if _, err := s.tenantService.GetTenantByID(ctx, preferred); err != nil {
logger.Warnf(ctx,
"resolveLoginTenantID: preferred tenant %d not loadable for user %s, "+
"clearing preference and falling back to home: %v",
preferred, user.ID, err)
s.clearLastActiveTenantPreference(ctx, user)
return s.homeOrFirstMembershipTenant(ctx, user)
}
}
// Membership (or cross-tenant superuser) must still be valid. Mirrors
// the gate in SwitchTenant so the two entry points stay consistent.
if !user.CanAccessAllTenants {
if s.memberService == nil {
logger.Warnf(ctx,
"resolveLoginTenantID: member service unavailable; falling back to home for user %s",
user.ID)
return user.TenantID
}
member, err := s.memberService.GetMembership(ctx, user.ID, preferred)
if err != nil || member == nil || member.Status != types.TenantMemberStatusActive {
logger.Warnf(ctx,
"resolveLoginTenantID: user %s no longer has active membership in tenant %d, "+
"clearing preference and falling back to home (err=%v)",
user.ID, preferred, err)
s.clearLastActiveTenantPreference(ctx, user)
return s.homeOrFirstMembershipTenant(ctx, user)
}
}
return preferred
}
// homeOrFirstMembershipTenant returns the user's home tenant, or — for a
// tenantless identity (TenantID == 0) — the earliest active membership.
// Shared by the happy path and the stale-preference fallbacks so a
// tenantless session with a valid membership never gets a zero-tenant
// token when a usable tenant is available (repairs partial
// invitation/admin-assignment flows). resolveFirstMembershipTenant
// best-effort persists the resolved tenant as the new home.
//
// When users.tenant_id is non-zero we still verify an active membership
// still exists (mirroring resolveLoginTenantID's check on
// LastActiveTenantID). A dangling home pointer is common after
// RemoveMember: the membership row is soft-deleted but users.tenant_id
// was historically left untouched, which made the removed workspace
// reappear via synthFallbackMembership (#2586). Superusers that can
// access every tenant skip the membership gate.
func (s *userService) homeOrFirstMembershipTenant(ctx context.Context, user *types.User) uint64 {
if user == nil {
return 0
}
if user.TenantID == 0 {
return s.resolveFirstMembershipTenant(ctx, user)
}
if user.CanAccessAllTenants || s.memberService == nil {
return user.TenantID
}
member, err := s.memberService.GetMembership(ctx, user.ID, user.TenantID)
if err == nil && member != nil && member.Status == types.TenantMemberStatusActive {
return user.TenantID
}
logger.Warnf(ctx,
"homeOrFirstMembershipTenant: user %s home tenant %d has no active membership, "+
"clearing stale home and re-resolving (err=%v)",
user.ID, user.TenantID, err)
s.clearStaleHomeTenant(ctx, user)
return s.resolveFirstMembershipTenant(ctx, user)
}
// clearStaleHomeTenant best-effort zeroes users.tenant_id (and a
// LastActiveTenantID that pointed at the same workspace) after the home
// membership is observed to be gone. Failures are logged but never
// fail login: the in-memory user is already corrected for this request.
func (s *userService) clearStaleHomeTenant(ctx context.Context, user *types.User) {
if user == nil {
return
}
staleHome := user.TenantID
user.TenantID = 0
if user.Preferences.LastActiveTenantID != nil && *user.Preferences.LastActiveTenantID == staleHome {
user.Preferences.LastActiveTenantID = nil
}
if s.userRepo == nil {
return
}
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
logger.Warnf(ctx,
"clearStaleHomeTenant: failed to persist cleared home for user %s (was tenant %d): %v",
user.ID, staleHome, err)
}
}
// resolveFirstMembershipTenant makes a tenantless identity usable when an
// active membership already exists (for example, an invitation was accepted
// but persisting the default tenant failed). ListByUser is stably ordered by
// join time, so the earliest valid membership is deterministic. Persisting it
// as home is best-effort: even if the repair write fails, the freshly issued
// token can still be scoped to the membership and the next login retries.
func (s *userService) resolveFirstMembershipTenant(ctx context.Context, user *types.User) uint64 {
if user == nil || s.memberService == nil {
return 0
}
members, err := s.memberService.ListByUser(ctx, user.ID)
if err != nil {
logger.Warnf(ctx, "resolveLoginTenantID: failed to list memberships for tenantless user %s: %v", user.ID, err)
return 0
}
for _, member := range members {
if member == nil || member.TenantID == 0 || member.Status != types.TenantMemberStatusActive {
continue
}
if s.tenantService != nil {
if _, err := s.tenantService.GetTenantByID(ctx, member.TenantID); err != nil {
logger.Warnf(ctx, "resolveLoginTenantID: tenant %d for tenantless user %s is unavailable: %v",
member.TenantID, user.ID, err)
continue
}
}
user.TenantID = member.TenantID
if s.userRepo != nil {
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
logger.Warnf(ctx, "resolveLoginTenantID: failed to persist tenant %d for tenantless user %s: %v",
member.TenantID, user.ID, err)
user.TenantID = 0
}
}
return member.TenantID
}
return 0
}
// clearLastActiveTenantPreference is the best-effort cleanup half of
// resolveLoginTenantID. Failures here are logged but never propagated:
// the in-memory user already has the preference cleared for this login,
// and the next login will re-attempt the cleanup.
func (s *userService) clearLastActiveTenantPreference(ctx context.Context, user *types.User) {
if user == nil {
return
}
user.Preferences.LastActiveTenantID = nil
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
logger.Warnf(ctx,
"clearLastActiveTenantPreference: failed to persist cleared preference for user %s: %v",
user.ID, err)
}
}
// generateTokensForTenant is the shared implementation behind
// GenerateTokens and SwitchTenant. It encodes activeTenantID into the
// access token's tenant_id claim so the auth middleware scopes future
// requests there.
func (s *userService) generateTokensForTenant(
ctx context.Context,
user *types.User,
activeTenantID uint64,
) (accessToken, refreshToken string, err error) {
// Generate access token (expires in 24 hours)
accessClaims := jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"tenant_id": activeTenantID,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"type": "access",
}
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessToken, err = accessTokenObj.SignedString([]byte(getJwtSecret()))
if err != nil {
return "", "", err
}
// Generate refresh token (expires in 7 days)
refreshClaims := jwt.MapClaims{
"user_id": user.ID,
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"type": "refresh",
}
refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshToken, err = refreshTokenObj.SignedString([]byte(getJwtSecret()))
if err != nil {
return "", "", err
}
// Store tokens in database
accessTokenRecord := &types.AuthToken{
ID: uuid.New().String(),
UserID: user.ID,
Token: accessToken,
TokenType: "access_token",
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
refreshTokenRecord := &types.AuthToken{
ID: uuid.New().String(),
UserID: user.ID,
Token: refreshToken,
TokenType: "refresh_token",
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_ = s.tokenRepo.CreateToken(ctx, accessTokenRecord)
_ = s.tokenRepo.CreateToken(ctx, refreshTokenRecord)
return accessToken, refreshToken, nil
}
// SwitchTenant verifies that user has an active membership in
// targetTenantID and issues a new token pair scoped to that tenant.
// The previous refresh token (if provided) is revoked so the old session
// can no longer roll forward into the source tenant.
//
// On success the target is also recorded as the user's last-active-
// tenant preference. Refresh tokens do not carry tenant_id, so
// RefreshToken / the next login re-resolve from this preference;
// a successful switch therefore has to persist it before minting
// tokens. A write failure aborts the switch so we never return a
// token pair whose later refresh would bounce to a different workspace.
//
// Returns ErrMembershipNotFound when the user is not a member of the
// target tenant. Cross-tenant superuser access (CanAccessAllTenants)
// is allowed without a membership row, mirroring the auth middleware's
// resolveTenantRole behaviour.
func (s *userService) SwitchTenant(
ctx context.Context,
user *types.User,
targetTenantID uint64,
currentRefreshToken string,
) (*types.LoginResponse, error) {
if user == nil {
return nil, errors.New("user is required")
}
if targetTenantID == 0 {
return nil, errors.New("target workspace ID is required")
}
// Verify membership unless the caller is a cross-tenant superuser
// switching outside their home tenant.
if !user.CanAccessAllTenants || targetTenantID == user.TenantID {
if s.memberService == nil {
return nil, errors.New("workspace membership service unavailable")
}
member, err := s.memberService.GetMembership(ctx, user.ID, targetTenantID)
if err != nil {
return nil, fmt.Errorf("lookup membership: %w", err)
}
if member == nil || member.Status != types.TenantMemberStatusActive {
return nil, ErrMembershipNotFound
}
}
tenant, err := s.tenantService.GetTenantByID(ctx, targetTenantID)
if err != nil {
return nil, fmt.Errorf("load target workspace: %w", err)
}
// Persist before minting tokens so a 200 response is also a
// durable landing-preference update. RefreshToken re-reads this
// field; writing after issue would leave a window where access
// is scoped to targetTenantID but refresh falls back to home.
if err := s.recordLastActiveTenant(ctx, user, targetTenantID); err != nil {
return nil, fmt.Errorf("record last-active-tenant preference: %w", err)
}
accessToken, refreshToken, err := s.generateTokensForTenant(ctx, user, targetTenantID)
if err != nil {
return nil, fmt.Errorf("generate tokens: %w", err)
}
// Best-effort revoke of the previous refresh token. Failure is
// logged but not fatal — the new tokens are already issued and the
// old refresh token will expire naturally.
if strings.TrimSpace(currentRefreshToken) != "" {
if err := s.RevokeToken(ctx, currentRefreshToken); err != nil {
logger.Warnf(ctx, "Failed to revoke previous refresh token during tenant switch: %v", err)
}
}
memberships := s.buildMembershipsForUser(ctx, user, tenant)
return &types.LoginResponse{
Success: true,
Message: "Workspace switched",
User: user,
ActiveTenant: tenant,
Memberships: memberships,
Token: accessToken,
RefreshToken: refreshToken,
}, nil
}
// recordLastActiveTenant records tenantID as the user's last-active-
// tenant preference so fresh logins and refresh-token rotations land
// in the last activated workspace. Switching home writes the home ID
// rather than clearing; resolveLoginTenantID treats *home the same as
// nil today. (The SPA still sends 0 when switching home — landing is
// equivalent unless home tenant_id is later rewritten while the old
// home membership remains.) Errors are returned to the caller.
func (s *userService) recordLastActiveTenant(ctx context.Context, user *types.User, tenantID uint64) error {
if user == nil || tenantID == 0 {
return nil
}
patch := types.UserPreferences{LastActiveTenantID: &tenantID}
merged, err := s.UpdateUserPreferences(ctx, user.ID, patch)
if err != nil {
return err
}
// Reflect the persisted preferences in the switch response.
user.Preferences = merged
return nil
}
// ValidateToken validates an access token. The second return value is
// the JWT's `tenant_id` claim — i.e. the tenant the token was minted
// for, which may differ from user.TenantID after a /auth/switch-tenant
// call. Tokens minted before tenant-level RBAC don't carry the claim;
// in that case we fall back to user.TenantID for backward compatibility.
func (s *userService) ValidateToken(ctx context.Context, tokenString string) (*types.User, uint64, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(getJwtSecret()), nil
})
if err != nil || !token.Valid {
return nil, 0, errors.New("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, 0, errors.New("invalid token claims")
}
userID, ok := claims["user_id"].(string)
if !ok {
return nil, 0, errors.New("invalid user ID in token")
}
if isRefreshTokenClaims(claims) {
return nil, 0, errors.New("refresh token cannot be used as access token")
}
// Check if token is revoked
tokenRecord, err := s.tokenRepo.GetTokenByValue(ctx, tokenString)
if err != nil || tokenRecord == nil || tokenRecord.IsRevoked {
return nil, 0, errors.New("token is revoked")
}
if tokenRecord.TokenType == "refresh_token" {
return nil, 0, errors.New("refresh token cannot be used as access token")
}
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return nil, 0, err
}
if user == nil || !user.IsActive {
return nil, 0, errors.New("user account is inactive")
}
// Extract active tenant from the JWT. Anything missing or unparseable
// falls back to the user's home tenant so old tokens (and tokens issued
// by code paths that don't yet set the claim) keep working.
activeTenantID := tenantIDFromClaims(claims, user.TenantID)
return user, activeTenantID, nil
}
func isRefreshTokenClaims(claims jwt.MapClaims) bool {
tokenType, ok := claims["type"].(string)
return ok && tokenType == "refresh"
}
func userIDFromSignedToken(tokenString string) (string, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(getJwtSecret()), nil
}, jwt.WithoutClaimsValidation())
if err != nil || token == nil || !token.Valid {
return "", errors.New("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", errors.New("invalid token claims")
}
userID, ok := claims["user_id"].(string)
if !ok || strings.TrimSpace(userID) == "" {
return "", errors.New("invalid user ID in token")
}
return userID, nil
}
// tenantIDFromClaims pulls the active tenant ID out of a parsed JWT
// claim map. Returns fallback when the claim is missing or has an
// unrecognised type. Extracted as a free function so it can be unit
// tested without standing up the full userService dependency graph.
//
// JSON numbers come back as float64 from jwt.MapClaims; the int64 /
// uint64 branches cover legacy code paths and tests that build claims
// directly. Negative values are treated as missing.
func tenantIDFromClaims(claims jwt.MapClaims, fallback uint64) uint64 {
raw, ok := claims["tenant_id"]
if !ok {
return fallback
}
switch v := raw.(type) {
case float64:
if v > 0 {
return uint64(v)
}
case int64:
if v > 0 {
return uint64(v)
}
case uint64:
if v > 0 {
return v
}
}
return fallback
}
// RefreshToken refreshes access token using refresh token
func (s *userService) RefreshToken(
ctx context.Context,
refreshTokenString string,
) (accessToken, newRefreshToken string, err error) {
token, err := jwt.Parse(refreshTokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(getJwtSecret()), nil
})
if err != nil || !token.Valid {
return "", "", errors.New("invalid refresh token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", "", errors.New("invalid token claims")
}
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "refresh" {
return "", "", errors.New("not a refresh token")
}
userID, ok := claims["user_id"].(string)
if !ok {
return "", "", errors.New("invalid user ID in token")
}
// Check if token is revoked
tokenRecord, err := s.tokenRepo.GetTokenByValue(ctx, refreshTokenString)
if err != nil || tokenRecord == nil || tokenRecord.IsRevoked {
return "", "", errors.New("refresh token is revoked")
}
if tokenRecord.TokenType != "refresh_token" {
return "", "", errors.New("not a refresh token")
}
// Get user
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return "", "", err
}
if user == nil || !user.IsActive {
return "", "", errors.New("user account is inactive")
}
// Revoke old refresh token
tokenRecord.IsRevoked = true
_ = s.tokenRepo.UpdateToken(ctx, tokenRecord)
// Generate new tokens
return s.GenerateTokens(ctx, user)
}
// Logout invalidates every outstanding session for the user identified by
// the presented JWT. Access and refresh tokens are both accepted so clients
// can end the session without refreshing first; expired tokens are allowed
// so logout still works after the access token TTL.
func (s *userService) Logout(ctx context.Context, tokenString string) error {
userID, err := userIDFromSignedToken(tokenString)
if err != nil {
return err
}
return s.tokenRepo.RevokeTokensByUserID(ctx, userID)
}
// RevokeToken revokes a token
func (s *userService) RevokeToken(ctx context.Context, tokenString string) error {
tokenRecord, err := s.tokenRepo.GetTokenByValue(ctx, tokenString)
if err != nil {
return err
}
tokenRecord.IsRevoked = true
tokenRecord.UpdatedAt = time.Now()
return s.tokenRepo.UpdateToken(ctx, tokenRecord)
}
// GetCurrentUser gets current user from context
func (s *userService) GetCurrentUser(ctx context.Context) (*types.User, error) {
user, ok := ctx.Value(types.UserContextKey).(*types.User)
if !ok {
return nil, errors.New("user not found in context")
}
return user, nil
}
// SearchUsers searches users by username or email
func (s *userService) SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error) {
if query == "" {
return []*types.User{}, nil
}
return s.userRepo.SearchUsers(ctx, query, limit)
}
type oidcDiscoveryDocument 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 oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
}
func newOIDCHTTPClient() *http.Client {
cfg := secutils.DefaultSSRFSafeHTTPClientConfig()
cfg.Timeout = 30 * time.Second
return secutils.NewSSRFSafeHTTPClient(cfg)
}
func validateOIDCEndpoint(label, endpoint string, required bool) error {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
if required {
return fmt.Errorf("OIDC %s endpoint is required", label)
}
return nil
}
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return fmt.Errorf("OIDC %s endpoint failed SSRF validation: %w", label, err)
}
return nil
}
func validateOIDCEndpoints(cfg *config.OIDCAuthConfig) error {
if err := validateOIDCEndpoint("authorization", cfg.AuthorizationEndpoint, true); err != nil {
return err
}
if err := validateOIDCEndpoint("token", cfg.TokenEndpoint, true); err != nil {
return err
}
if err := validateOIDCEndpoint("userinfo", cfg.UserInfoEndpoint, false); err != nil {
return err
}
if err := validateOIDCEndpoint("jwks", cfg.JwksURI, false); err != nil {
return err
}
return nil
}
func (s *userService) getOIDCConfig(ctx context.Context) (*config.OIDCAuthConfig, error) {
if s.config == nil || s.config.OIDCAuth == nil || !s.config.OIDCAuth.Enable {
return nil, errors.New("OIDC login is disabled")
}
cfg := *s.config.OIDCAuth
if cfg.UserInfoMapping == nil {
cfg.UserInfoMapping = &config.OIDCUserInfoMapping{Username: "name", Email: "email"}
}
if err := s.populateOIDCEndpoints(ctx, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func oidcNeedsDiscovery(cfg *config.OIDCAuthConfig) bool {
return strings.TrimSpace(cfg.AuthorizationEndpoint) == "" ||
strings.TrimSpace(cfg.TokenEndpoint) == "" ||
strings.TrimSpace(cfg.JwksURI) == "" ||
strings.TrimSpace(cfg.IssuerURL) == ""
}
func (s *userService) populateOIDCEndpoints(ctx context.Context, cfg *config.OIDCAuthConfig) error {
if oidcNeedsDiscovery(cfg) {
if strings.TrimSpace(cfg.DiscoveryURL) == "" {
if strings.TrimSpace(cfg.AuthorizationEndpoint) == "" || strings.TrimSpace(cfg.TokenEndpoint) == "" {
return errors.New("OIDC discovery_url or explicit endpoints are required")
}
} else if err := s.applyOIDCDiscoveryDocument(ctx, cfg); err != nil {
return err
}
}
if strings.TrimSpace(cfg.AuthorizationEndpoint) == "" || strings.TrimSpace(cfg.TokenEndpoint) == "" {
return errors.New("OIDC discovery document missing required endpoints")
}
return validateOIDCEndpoints(cfg)
}
func (s *userService) applyOIDCDiscoveryDocument(ctx context.Context, cfg *config.OIDCAuthConfig) error {
if err := validateOIDCEndpoint("discovery", cfg.DiscoveryURL, true); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.DiscoveryURL, nil)
if err != nil {
return fmt.Errorf("failed to create OIDC discovery request: %w", err)
}
resp, err := newOIDCHTTPClient().Do(req)
if err != nil {
return fmt.Errorf("failed to load OIDC discovery document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("OIDC discovery request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var doc oidcDiscoveryDocument
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
return fmt.Errorf("failed to decode OIDC discovery document: %w", err)
}
if strings.TrimSpace(cfg.AuthorizationEndpoint) == "" {
cfg.AuthorizationEndpoint = doc.AuthorizationEndpoint
}
if strings.TrimSpace(cfg.TokenEndpoint) == "" {
cfg.TokenEndpoint = doc.TokenEndpoint
}
if strings.TrimSpace(cfg.UserInfoEndpoint) == "" {
cfg.UserInfoEndpoint = doc.UserInfoEndpoint
}
if strings.TrimSpace(cfg.JwksURI) == "" {
cfg.JwksURI = doc.JwksURI
}
if strings.TrimSpace(cfg.IssuerURL) == "" {
cfg.IssuerURL = doc.Issuer
}
return nil
}
func (s *userService) exchangeOIDCCode(ctx context.Context, cfg *config.OIDCAuthConfig, code, redirectURI string) (*oidcTokenResponse, error) {
if err := validateOIDCEndpoint("token", cfg.TokenEndpoint, true); err != nil {
return nil, err
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", cfg.ClientID)
form.Set("client_secret", cfg.ClientSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create OIDC token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := newOIDCHTTPClient().Do(req)
if err != nil {
return nil, fmt.Errorf("failed to exchange OIDC code: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("OIDC token exchange failed: status=%d", resp.StatusCode)
}
var tokenResp oidcTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode OIDC token response: %w", err)
}
if strings.TrimSpace(tokenResp.AccessToken) == "" && strings.TrimSpace(tokenResp.IDToken) == "" {
return nil, errors.New("OIDC token response missing access_token and id_token")
}
return &tokenResp, nil
}
func (s *userService) resolveOIDCUserInfo(ctx context.Context, cfg *config.OIDCAuthConfig, tokenResp *oidcTokenResponse) (*types.OIDCUserInfo, error) {
claims := map[string]interface{}{}
verifiedFromIDToken := false
if idToken := strings.TrimSpace(tokenResp.IDToken); idToken != "" {
if strings.TrimSpace(cfg.JwksURI) == "" {
if strings.TrimSpace(cfg.UserInfoEndpoint) == "" || strings.TrimSpace(tokenResp.AccessToken) == "" {
return nil, errors.New("cannot verify OIDC id_token: no jwks_uri configured")
}
logger.Warnf(ctx, "OIDC id_token ignored: no jwks_uri configured; relying on userinfo endpoint")
} else {
idTokenClaims, err := s.verifyOIDCIDToken(ctx, cfg, idToken)
if err != nil {
return nil, fmt.Errorf("OIDC id_token verification failed: %w", err)
}
for k, v := range idTokenClaims {
claims[k] = v
}
verifiedFromIDToken = true
}
}
if strings.TrimSpace(cfg.UserInfoEndpoint) != "" && strings.TrimSpace(tokenResp.AccessToken) != "" {
userInfoClaims, err := s.fetchOIDCUserInfo(ctx, cfg.UserInfoEndpoint, tokenResp.AccessToken)
if err != nil {
if !verifiedFromIDToken {
return nil, fmt.Errorf("failed to fetch OIDC userinfo: %w", err)
}
logger.Warnf(ctx, "Failed to fetch OIDC userinfo, using verified id_token claims: %v", err)
} else {
for k, v := range userInfoClaims {
claims[k] = v
}
}
}
if len(claims) == 0 {
return nil, errors.New("OIDC provider returned no user claims")
}
info := &types.OIDCUserInfo{Claims: claims}
if sub, _ := claims["sub"].(string); sub != "" {
info.Subject = sub
}
info.Username = extractClaimAsString(claims, cfg.UserInfoMapping.Username)
info.Email = extractClaimAsString(claims, cfg.UserInfoMapping.Email)
if info.Username == "" {
info.Username = extractClaimAsString(claims, "preferred_username")
}
if info.Username == "" {
info.Username = extractClaimAsString(claims, "name")
}
if info.Username == "" && info.Email != "" {
info.Username = strings.Split(info.Email, "@")[0]
}
return info, nil
}
func (s *userService) fetchOIDCUserInfo(ctx context.Context, endpoint, accessToken string) (map[string]interface{}, error) {
if err := validateOIDCEndpoint("userinfo", endpoint, true); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := newOIDCHTTPClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("userinfo request failed: status=%d", resp.StatusCode)
}
var claims map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
return nil, err
}
return claims, nil
}
// provisionOIDCUser auto-creates a local account for a first-time OIDC
// login. The provisioning mode is decided by the caller (the OIDC callback
// handler resolves it from the same auth.default_tenant_mode system-setting
// that governs public password registration) so both entry points share a
// single deployment policy. An empty mode falls back to create_personal via
// Register's own defaulting.
func (s *userService) provisionOIDCUser(
ctx context.Context,
info *types.OIDCUserInfo,
provisioning types.TenantProvisioningMode,
) (*types.User, error) {
username := s.generateOIDCUsername(ctx, info)
randomPassword, err := generateRandomString(32)
if err != nil {
return nil, fmt.Errorf("failed to generate password for OIDC user: %w", err)
}
user, err := s.Register(ctx, &types.RegisterRequest{
Username: username,
Email: info.Email,
Password: randomPassword,
TenantProvisioning: provisioning,
})
if err != nil {
return nil, fmt.Errorf("failed to auto-provision OIDC user: %w", err)
}
oidcOnly := true
user.Preferences.OidcOnlyLogin = &oidcOnly
user.UpdatedAt = time.Now()
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
return nil, fmt.Errorf("failed to mark OIDC-only login preference: %w", err)
}
return user, nil
}
func (s *userService) generateOIDCUsername(ctx context.Context, info *types.OIDCUserInfo) string {
base := sanitizeUsernameCandidate(info.Username)
if base == "" {
base = sanitizeUsernameCandidate(strings.Split(info.Email, "@")[0])
}
if base == "" {
base = "oidc-user"
}
candidate := base
for i := 0; i < 20; i++ {
existing, err := s.userRepo.GetUserByUsername(ctx, candidate)
if isUserLookupNotFound(err) || (err == nil && existing == nil) {
return candidate
}
if err != nil && !isUserLookupNotFound(err) {
logger.Warnf(ctx, "Failed to check existing OIDC username %q: %v", candidate, err)
}
candidate = fmt.Sprintf("%s-%d", base, i+1)
}
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
}
func generateRandomString(length int) (string, error) {
buffer := make([]byte, length)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func getRandomChar(charset string) (byte, error) {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return 0, err
}
return charset[n.Int64()], nil
}
func generateComplexPassword(length int) (string, error) {
if length < 4 {
return "", fmt.Errorf("password length must be at least 4")
}
password := make([]byte, 0, length)
c, err := getRandomChar(upperChars)
if err != nil {
return "", err
}
password = append(password, c)
c, err = getRandomChar(lowerChars)
if err != nil {
return "", err
}
password = append(password, c)
c, err = getRandomChar(digitChars)
if err != nil {
return "", err
}
password = append(password, c)
c, err = getRandomChar(passwordSpecialChars)
if err != nil {
return "", err
}
password = append(password, c)
for len(password) < length {
c, err = getRandomChar(allChars)
if err != nil {
return "", err
}
password = append(password, c)
}
// Fisher-Yates shuffle
for i := len(password) - 1; i > 0; i-- {
n, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return "", err
}
j := int(n.Int64())
password[i], password[j] = password[j], password[i]
}
return string(password), nil
}
// generatePolicyCompliantPassword returns a cryptographically random
// password that satisfies ValidatePasswordPolicy.
//
// Generated credentials remain longer than the minimum user-chosen password.
func generatePolicyCompliantPassword(complexPasswordEnabled bool) (string, error) {
var password string
var err error
for {
if complexPasswordEnabled {
password, err = generateComplexPassword(16)
} else {
password, err = generateRandomString(24)
}
if err != nil {
return "", err
}
if ValidatePasswordPolicy(password, complexPasswordEnabled) == nil {
return password, nil
}
}
}
// oidcJWK / oidcJWKS model the subset of a JWKS document we need to rebuild an
// RSA signing key. Only RSA keys are supported (the overwhelmingly common OIDC
// id_token signing type); other key types are skipped during lookup.
type oidcJWK struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
type oidcJWKS struct {
Keys []oidcJWK `json:"keys"`
}
// rsaPublicKey rebuilds an *rsa.PublicKey from the base64url modulus/exponent of
// a JWK (RFC 7518 §6.3.1).
func decodeJWKBase64(value string) ([]byte, error) {
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil && len(b) > 0 {
return b, nil
}
return base64.URLEncoding.DecodeString(value)
}
func (k oidcJWK) rsaPublicKey() (*rsa.PublicKey, error) {
if !strings.EqualFold(k.Kty, "RSA") {
return nil, fmt.Errorf("unsupported JWK key type: %s", k.Kty)
}
nBytes, err := decodeJWKBase64(k.N)
if err != nil {
return nil, fmt.Errorf("invalid JWK modulus: %w", err)
}
eBytes, err := decodeJWKBase64(k.E)
if err != nil {
return nil, fmt.Errorf("invalid JWK exponent: %w", err)
}
if len(nBytes) == 0 || len(eBytes) == 0 {
return nil, errors.New("empty JWK modulus or exponent")
}
eInt := new(big.Int).SetBytes(eBytes)
if !eInt.IsInt64() {
return nil, errors.New("invalid JWK exponent value")
}
e := int(eInt.Int64())
if e <= 0 {
return nil, errors.New("invalid JWK exponent value")
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil
}
func (jwks *oidcJWKS) rsaKeyForKid(kid string) (*rsa.PublicKey, error) {
var usable []oidcJWK
for _, k := range jwks.Keys {
if k.Use != "" && !strings.EqualFold(k.Use, "sig") {
continue
}
if k.Kty != "" && !strings.EqualFold(k.Kty, "RSA") {
continue
}
if kid != "" && k.Kid != kid {
continue
}
if _, err := k.rsaPublicKey(); err != nil {
continue
}
usable = append(usable, k)
}
if kid != "" {
if len(usable) == 0 {
return nil, fmt.Errorf("no matching JWKS RSA key for kid %q", kid)
}
return usable[0].rsaPublicKey()
}
if len(usable) == 0 {
return nil, errors.New("no matching JWKS key for id_token")
}
if len(usable) > 1 {
return nil, errors.New("id_token missing kid and JWKS contains multiple RSA signing keys")
}
return usable[0].rsaPublicKey()
}
// fetchOIDCJWKS loads the provider's JWKS document over the SSRF-safe client.
func (s *userService) fetchOIDCJWKS(ctx context.Context, jwksURI string) (*oidcJWKS, error) {
if err := validateOIDCEndpoint("jwks", jwksURI, true); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURI, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := newOIDCHTTPClient().Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("JWKS request failed: status=%d", resp.StatusCode)
}
var jwks oidcJWKS
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&jwks); err != nil {
return nil, fmt.Errorf("failed to decode JWKS document: %w", err)
}
if len(jwks.Keys) == 0 {
return nil, errors.New("JWKS document contains no keys")
}
return &jwks, nil
}
const oidcIDTokenLeeway = 2 * time.Minute
// verifyOIDCIDToken cryptographically verifies an OIDC id_token: it checks the
// RSA signature against the provider's JWKS (matched by kid) and validates the
// issuer, audience (client_id), expiry and subject. It returns the verified claims.
func (s *userService) verifyOIDCIDToken(
ctx context.Context, cfg *config.OIDCAuthConfig, idToken string,
) (map[string]interface{}, error) {
if strings.TrimSpace(cfg.JwksURI) == "" {
return nil, errors.New("cannot verify OIDC id_token: no jwks_uri configured")
}
if strings.TrimSpace(cfg.IssuerURL) == "" {
return nil, errors.New("cannot verify OIDC id_token: issuer is not configured")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return nil, errors.New("cannot verify OIDC id_token: client_id is not configured")
}
jwks, err := s.fetchOIDCJWKS(ctx, cfg.JwksURI)
if err != nil {
return nil, err
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected id_token signing method: %v", token.Header["alg"])
}
kid, _ := token.Header["kid"].(string)
return jwks.rsaKeyForKid(kid)
}
claims := jwt.MapClaims{}
if _, err := jwt.NewParser(
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512"}),
jwt.WithExpirationRequired(),
jwt.WithLeeway(oidcIDTokenLeeway),
jwt.WithIssuer(strings.TrimSpace(cfg.IssuerURL)),
jwt.WithAudience(strings.TrimSpace(cfg.ClientID)),
).ParseWithClaims(idToken, claims, keyFunc); err != nil {
return nil, fmt.Errorf("id_token verification failed: %w", err)
}
verified := map[string]interface{}(claims)
if strings.TrimSpace(extractClaimAsString(verified, "sub")) == "" {
return nil, errors.New("id_token missing sub claim")
}
return verified, nil
}
func extractClaimAsString(claims map[string]interface{}, key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
value, ok := claims[key]
if !ok || value == nil {
return ""
}
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func sanitizeUsernameCandidate(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return ""
}
var b strings.Builder
lastDash := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.' {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
result := strings.Trim(b.String(), "-._")
if len(result) > 50 {
result = strings.Trim(result[:50], "-._")
}
return result
}
func isUserLookupNotFound(err error) bool {
if err == nil {
return false
}
return errors.Is(err, apprepo.ErrUserNotFound) || strings.Contains(strings.ToLower(err.Error()), "user not found")
}