2037 lines
67 KiB
Go
2037 lines
67 KiB
Go
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")
|
||
|
|
}
|