- Add AdminExists() to UserRepository (EXISTS query, not full table scan) - SetupInitialAdmin returns tokens directly (skip separate Login call) - Add SetupRequest DTO to auth_dto.go (replace inline struct) - Extract defaultEmail() helper (removes duplicated email logic) - AuthPage uses setup tokens directly (skip redundant apiLogin call) - Use customAxiosInstance for auth API calls (consistent with codebase)
855 lines
26 KiB
Go
855 lines
26 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||
jwtpkg "github.com/ocdp/cluster-service/internal/pkg/jwt"
|
||
"k8s.io/apimachinery/pkg/api/resource"
|
||
"k8s.io/apimachinery/pkg/util/validation"
|
||
)
|
||
|
||
// AuthService 认证领域服务
|
||
type AuthService struct {
|
||
userRepo repository.UserRepository
|
||
workspaceRepo repository.WorkspaceRepository
|
||
instanceRepo repository.InstanceRepository
|
||
clusterRepo repository.ClusterRepository
|
||
bindingRepo repository.WorkspaceClusterBindingRepository
|
||
tenantClient repository.TenantKubeClient
|
||
passwordHasher PasswordHasher
|
||
tokenGenerator TokenGenerator
|
||
}
|
||
|
||
// PasswordHasher 密码哈希接口
|
||
type PasswordHasher interface {
|
||
Hash(password string) (string, error)
|
||
Verify(password, hash string) error
|
||
}
|
||
|
||
// TokenGenerator Token 生成器接口
|
||
type TokenGenerator interface {
|
||
Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error)
|
||
Verify(token string) (userID, username string, err error)
|
||
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
|
||
VerifyAccess(token string) (*jwtpkg.Claims, error)
|
||
VerifyRefresh(token string) (*jwtpkg.Claims, error)
|
||
Refresh(refreshToken string) (newAccessToken string, err error)
|
||
}
|
||
|
||
// NewAuthService 创建认证服务
|
||
func NewAuthService(
|
||
userRepo repository.UserRepository,
|
||
workspaceRepo repository.WorkspaceRepository,
|
||
passwordHasher PasswordHasher,
|
||
tokenGenerator TokenGenerator,
|
||
) *AuthService {
|
||
return &AuthService{
|
||
userRepo: userRepo,
|
||
workspaceRepo: workspaceRepo,
|
||
passwordHasher: passwordHasher,
|
||
tokenGenerator: tokenGenerator,
|
||
}
|
||
}
|
||
|
||
func (s *AuthService) SetUserLifecycleCleanup(
|
||
instanceRepo repository.InstanceRepository,
|
||
clusterRepo repository.ClusterRepository,
|
||
bindingRepo repository.WorkspaceClusterBindingRepository,
|
||
tenantClient repository.TenantKubeClient,
|
||
) {
|
||
s.instanceRepo = instanceRepo
|
||
s.clusterRepo = clusterRepo
|
||
s.bindingRepo = bindingRepo
|
||
s.tenantClient = tenantClient
|
||
}
|
||
|
||
// Register 注册新用户。业务入口只允许 admin 调用;初始 admin 由 bootstrap seeder 创建。
|
||
type UserWorkspaceOptions struct {
|
||
Namespace string
|
||
DefaultClusterID string
|
||
QuotaCPU string
|
||
QuotaMemory string
|
||
QuotaGPU string
|
||
QuotaGPUMem string
|
||
}
|
||
|
||
func defaultEmail(username string) string {
|
||
return username + "@local.ocdp"
|
||
}
|
||
|
||
// IsAdminExists checks whether any admin user already exists in the database.
|
||
func (s *AuthService) IsAdminExists(ctx context.Context) (bool, error) {
|
||
return s.userRepo.AdminExists(ctx)
|
||
}
|
||
|
||
// SetupInitialAdmin creates the first admin user and returns access + refresh tokens.
|
||
// Fails if an admin already exists.
|
||
func (s *AuthService) SetupInitialAdmin(ctx context.Context, username, password, email string) (*entity.User, string, string, error) {
|
||
hasAdmin, err := s.IsAdminExists(ctx)
|
||
if err != nil {
|
||
return nil, "", "", err
|
||
}
|
||
if hasAdmin {
|
||
return nil, "", "", entity.ErrForbidden
|
||
}
|
||
|
||
passwordHash, err := s.passwordHasher.Hash(password)
|
||
if err != nil {
|
||
return nil, "", "", err
|
||
}
|
||
|
||
if email == "" {
|
||
email = defaultEmail(username)
|
||
}
|
||
|
||
user := entity.NewUser(username, passwordHash, email)
|
||
user.ID = uuid.New().String()
|
||
user.Role = authz.RoleAdmin
|
||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||
|
||
if err := user.Validate(); err != nil {
|
||
return nil, "", "", err
|
||
}
|
||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||
return nil, "", "", err
|
||
}
|
||
|
||
// Generate tokens directly — avoid a separate login round-trip
|
||
accessToken, refreshToken, err := s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||
if err != nil {
|
||
return nil, "", "", err
|
||
}
|
||
return user, accessToken, refreshToken, nil
|
||
}
|
||
|
||
func (s *AuthService) Register(ctx context.Context, username, password, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||
principal, err := authz.RequirePrincipal(ctx)
|
||
if err != nil {
|
||
return nil, entity.ErrUnauthorized
|
||
}
|
||
if !principal.IsAdmin() {
|
||
return nil, entity.ErrForbidden
|
||
}
|
||
|
||
// 检查用户是否已存在
|
||
existingUser, _ := s.userRepo.GetByUsername(ctx, username)
|
||
if existingUser != nil {
|
||
return nil, entity.ErrUserExists
|
||
}
|
||
|
||
// 哈希密码
|
||
passwordHash, err := s.passwordHasher.Hash(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if normalizeUserRole(role) == authz.RoleUser {
|
||
normalizedOpts = defaultUserQuotaOptions(normalizedOpts)
|
||
}
|
||
|
||
// 默认生成占位邮箱,避免数据库约束失败
|
||
email := defaultEmail(username)
|
||
|
||
// 创建用户
|
||
user := entity.NewUser(username, passwordHash, email)
|
||
user.ID = uuid.New().String()
|
||
user.Role = normalizeUserRole(role)
|
||
user.WorkspaceID = workspaceID
|
||
if user.Role == authz.RoleUser {
|
||
workspace, err := s.createUserWorkspace(ctx, username, principal.UserID, normalizedOpts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
user.WorkspaceID = workspace.ID
|
||
}
|
||
if user.WorkspaceID == "" {
|
||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||
}
|
||
if user.Role == authz.RoleAdmin {
|
||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||
}
|
||
if isActive != nil {
|
||
user.IsActive = *isActive
|
||
}
|
||
if mustChangePassword != nil {
|
||
user.MustChangePassword = *mustChangePassword
|
||
}
|
||
|
||
if err := user.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return user, nil
|
||
}
|
||
|
||
func (s *AuthService) createUserWorkspace(ctx context.Context, username, createdBy string, opts UserWorkspaceOptions) (*entity.Workspace, error) {
|
||
if s.workspaceRepo == nil {
|
||
return nil, entity.ErrWorkspaceNotFound
|
||
}
|
||
name := userWorkspaceName(username)
|
||
namespace := strings.TrimSpace(opts.Namespace)
|
||
if namespace == "" {
|
||
namespace = entity.NamespaceForUser(username)
|
||
}
|
||
if namespace != "" {
|
||
if len(validation.IsDNS1123Label(namespace)) > 0 {
|
||
return nil, entity.ErrInvalidNamespace
|
||
}
|
||
}
|
||
if existing, err := s.workspaceRepo.GetByName(ctx, name); err == nil && existing != nil {
|
||
if namespace != "" && existing.K8sNamespace != namespace {
|
||
if err := s.ensureNamespaceAvailable(ctx, namespace, existing.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
applyWorkspaceOptions(existing, opts)
|
||
if namespace != "" {
|
||
existing.K8sNamespace = namespace
|
||
existing.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||
}
|
||
if err := s.workspaceRepo.Update(ctx, existing); err != nil {
|
||
return nil, err
|
||
}
|
||
return existing, nil
|
||
} else if err != nil && !errors.Is(err, entity.ErrWorkspaceNotFound) {
|
||
return nil, err
|
||
}
|
||
if err := s.ensureNamespaceAvailable(ctx, namespace, ""); err != nil {
|
||
return nil, err
|
||
}
|
||
workspace := entity.NewWorkspace(name, createdBy)
|
||
workspace.ID = uuid.New().String()
|
||
workspace.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||
if namespace != "" {
|
||
workspace.K8sNamespace = namespace
|
||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||
}
|
||
workspace.QuotaCPU = strings.TrimSpace(opts.QuotaCPU)
|
||
workspace.QuotaMemory = strings.TrimSpace(opts.QuotaMemory)
|
||
workspace.QuotaGPU = strings.TrimSpace(opts.QuotaGPU)
|
||
workspace.QuotaGPUMem = strings.TrimSpace(opts.QuotaGPUMem)
|
||
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||
if errors.Is(err, entity.ErrWorkspaceExists) {
|
||
existing, getErr := s.workspaceRepo.GetByName(ctx, name)
|
||
if getErr != nil {
|
||
return nil, err
|
||
}
|
||
if existing.K8sNamespace != namespace {
|
||
return nil, entity.ErrWorkspaceNamespaceConflict
|
||
}
|
||
return existing, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
return workspace, nil
|
||
}
|
||
|
||
func userWorkspaceName(username string) string {
|
||
return strings.TrimPrefix(entity.NamespaceForUser(username), "ocdp-u-")
|
||
}
|
||
|
||
func (s *AuthService) ensureNamespaceAvailable(ctx context.Context, namespace, allowedWorkspaceID string) error {
|
||
if s.workspaceRepo == nil || strings.TrimSpace(namespace) == "" {
|
||
return nil
|
||
}
|
||
workspaces, err := s.workspaceRepo.List(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, workspace := range workspaces {
|
||
if workspace == nil || workspace.K8sNamespace != namespace {
|
||
continue
|
||
}
|
||
if allowedWorkspaceID != "" && workspace.ID == allowedWorkspaceID {
|
||
continue
|
||
}
|
||
return entity.ErrWorkspaceNamespaceConflict
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeQuotaOptions(opts UserWorkspaceOptions) (UserWorkspaceOptions, error) {
|
||
opts.Namespace = strings.TrimSpace(opts.Namespace)
|
||
opts.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||
opts.QuotaCPU = normalizeStandardQuotaQuantity(opts.QuotaCPU)
|
||
opts.QuotaMemory = normalizeStandardQuotaQuantity(opts.QuotaMemory)
|
||
opts.QuotaGPU = normalizeStandardQuotaQuantity(opts.QuotaGPU)
|
||
gpuMem, err := normalizeGPUMemoryQuota(opts.QuotaGPUMem)
|
||
if err != nil {
|
||
return opts, err
|
||
}
|
||
opts.QuotaGPUMem = gpuMem
|
||
for _, value := range []string{opts.QuotaCPU, opts.QuotaMemory, opts.QuotaGPU} {
|
||
if value == "" {
|
||
continue
|
||
}
|
||
if _, err := resource.ParseQuantity(value); err != nil {
|
||
return opts, entity.ErrInvalidTenantResourceQuota
|
||
}
|
||
}
|
||
if opts.Namespace != "" && len(validation.IsDNS1123Label(opts.Namespace)) > 0 {
|
||
return opts, entity.ErrInvalidNamespace
|
||
}
|
||
return opts, nil
|
||
}
|
||
|
||
func defaultUserQuotaOptions(opts UserWorkspaceOptions) UserWorkspaceOptions {
|
||
if strings.TrimSpace(opts.QuotaGPU) == "" {
|
||
opts.QuotaGPU = "0"
|
||
}
|
||
if strings.TrimSpace(opts.QuotaGPUMem) == "" {
|
||
opts.QuotaGPUMem = "0"
|
||
}
|
||
return opts
|
||
}
|
||
|
||
func (s *AuthService) ListUsers(ctx context.Context) ([]*entity.User, error) {
|
||
principal, err := authz.RequirePrincipal(ctx)
|
||
if err != nil {
|
||
return nil, entity.ErrUnauthorized
|
||
}
|
||
if !principal.IsAdmin() {
|
||
return nil, entity.ErrForbidden
|
||
}
|
||
return s.userRepo.List(ctx)
|
||
}
|
||
|
||
func (s *AuthService) UpdateUser(ctx context.Context, userID, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||
principal, err := authz.RequirePrincipal(ctx)
|
||
if err != nil {
|
||
return nil, entity.ErrUnauthorized
|
||
}
|
||
if !principal.IsAdmin() {
|
||
return nil, entity.ErrForbidden
|
||
}
|
||
user, err := s.userRepo.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, entity.ErrUserNotFound
|
||
}
|
||
previousRole := user.Role
|
||
if role != "" {
|
||
user.Role = normalizeUserRole(role)
|
||
}
|
||
if workspaceID != "" && user.Role != authz.RoleUser {
|
||
user.WorkspaceID = workspaceID
|
||
}
|
||
workspaceHandled := false
|
||
if user.Role == authz.RoleAdmin {
|
||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||
}
|
||
if user.Role == authz.RoleUser && (role != "" || workspaceID != "" || hasWorkspaceUpdates(opts)) {
|
||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
normalizedOpts = defaultUserQuotaOptions(normalizedOpts)
|
||
currentWorkspace, _ := s.currentUserWorkspace(ctx, user)
|
||
if currentWorkspace != nil && shouldCreatePrivateWorkspace(user, previousRole, currentWorkspace) {
|
||
if normalizedOpts.Namespace == "" || normalizedOpts.Namespace == currentWorkspace.K8sNamespace {
|
||
normalizedOpts.Namespace = ""
|
||
}
|
||
}
|
||
workspace, err := s.ensureUserWorkspaceForUpdate(ctx, user, previousRole, currentWorkspace, opts, normalizedOpts, principal.UserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
user.WorkspaceID = workspace.ID
|
||
workspaceHandled = true
|
||
}
|
||
if isActive != nil {
|
||
if user.ID == principal.UserID && !*isActive {
|
||
return nil, entity.ErrForbidden
|
||
}
|
||
user.IsActive = *isActive
|
||
}
|
||
if mustChangePassword != nil {
|
||
user.MustChangePassword = *mustChangePassword
|
||
}
|
||
if user.Role != authz.RoleAdmin && !workspaceHandled && hasWorkspaceUpdates(opts) {
|
||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
applyWorkspaceOptionsForUpdate(workspace, opts, normalizedOpts)
|
||
if err := s.workspaceRepo.Update(ctx, workspace); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.syncWorkspaceBindings(ctx, workspace); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
user.RevokedAfter = time.Now()
|
||
user.UpdatedAt = time.Now()
|
||
if err := user.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||
return nil, err
|
||
}
|
||
return user, nil
|
||
}
|
||
|
||
func hasWorkspaceUpdates(opts UserWorkspaceOptions) bool {
|
||
return strings.TrimSpace(opts.Namespace) != "" ||
|
||
strings.TrimSpace(opts.DefaultClusterID) != "" ||
|
||
strings.TrimSpace(opts.QuotaCPU) != "" ||
|
||
strings.TrimSpace(opts.QuotaMemory) != "" ||
|
||
strings.TrimSpace(opts.QuotaGPU) != "" ||
|
||
strings.TrimSpace(opts.QuotaGPUMem) != ""
|
||
}
|
||
|
||
func applyWorkspaceOptions(workspace *entity.Workspace, opts UserWorkspaceOptions) {
|
||
if namespace := strings.TrimSpace(opts.Namespace); namespace != "" {
|
||
workspace.K8sNamespace = namespace
|
||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||
}
|
||
if value := strings.TrimSpace(opts.DefaultClusterID); value != "" {
|
||
workspace.DefaultClusterID = value
|
||
}
|
||
if value := strings.TrimSpace(opts.QuotaCPU); value != "" {
|
||
workspace.QuotaCPU = value
|
||
}
|
||
if value := strings.TrimSpace(opts.QuotaMemory); value != "" {
|
||
workspace.QuotaMemory = value
|
||
}
|
||
if value := strings.TrimSpace(opts.QuotaGPU); value != "" {
|
||
workspace.QuotaGPU = value
|
||
}
|
||
if value := strings.TrimSpace(opts.QuotaGPUMem); value != "" {
|
||
workspace.QuotaGPUMem = value
|
||
}
|
||
}
|
||
|
||
func (s *AuthService) currentUserWorkspace(ctx context.Context, user *entity.User) (*entity.Workspace, error) {
|
||
if s.workspaceRepo == nil || user == nil || user.WorkspaceID == "" {
|
||
return nil, entity.ErrWorkspaceNotFound
|
||
}
|
||
return s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||
}
|
||
|
||
func shouldCreatePrivateWorkspace(user *entity.User, previousRole string, current *entity.Workspace) bool {
|
||
if user == nil {
|
||
return true
|
||
}
|
||
if previousRole == authz.RoleAdmin || user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID {
|
||
return true
|
||
}
|
||
if current == nil {
|
||
return true
|
||
}
|
||
return current.Name != userWorkspaceName(user.Username)
|
||
}
|
||
|
||
func (s *AuthService) ensureUserWorkspaceForUpdate(ctx context.Context, user *entity.User, previousRole string, current *entity.Workspace, rawOpts, normalizedOpts UserWorkspaceOptions, createdBy string) (*entity.Workspace, error) {
|
||
if s.workspaceRepo == nil {
|
||
return nil, entity.ErrWorkspaceNotFound
|
||
}
|
||
if shouldCreatePrivateWorkspace(user, previousRole, current) {
|
||
return s.createUserWorkspace(ctx, user.Username, createdBy, normalizedOpts)
|
||
}
|
||
if rawNamespace := strings.TrimSpace(rawOpts.Namespace); rawNamespace != "" && rawNamespace != current.K8sNamespace {
|
||
if err := s.ensureNamespaceAvailable(ctx, rawNamespace, current.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
applyWorkspaceOptionsForUpdate(current, rawOpts, normalizedOpts)
|
||
if err := s.workspaceRepo.Update(ctx, current); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.syncWorkspaceBindings(ctx, current); err != nil {
|
||
return nil, err
|
||
}
|
||
return current, nil
|
||
}
|
||
|
||
func applyWorkspaceOptionsForUpdate(workspace *entity.Workspace, rawOpts, normalizedOpts UserWorkspaceOptions) {
|
||
if namespace := strings.TrimSpace(rawOpts.Namespace); namespace != "" {
|
||
workspace.K8sNamespace = namespace
|
||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||
}
|
||
if strings.TrimSpace(rawOpts.DefaultClusterID) != "" {
|
||
workspace.DefaultClusterID = normalizedOpts.DefaultClusterID
|
||
}
|
||
if strings.TrimSpace(rawOpts.QuotaCPU) != "" {
|
||
workspace.QuotaCPU = normalizedOpts.QuotaCPU
|
||
}
|
||
if strings.TrimSpace(rawOpts.QuotaMemory) != "" {
|
||
workspace.QuotaMemory = normalizedOpts.QuotaMemory
|
||
}
|
||
if strings.TrimSpace(rawOpts.QuotaGPU) != "" {
|
||
workspace.QuotaGPU = normalizedOpts.QuotaGPU
|
||
}
|
||
if strings.TrimSpace(rawOpts.QuotaGPUMem) != "" {
|
||
workspace.QuotaGPUMem = normalizedOpts.QuotaGPUMem
|
||
}
|
||
}
|
||
|
||
func (s *AuthService) syncWorkspaceBindings(ctx context.Context, workspace *entity.Workspace) error {
|
||
if workspace == nil || s.bindingRepo == nil {
|
||
return nil
|
||
}
|
||
bindings, err := s.bindingRepo.ListByWorkspace(ctx, workspace.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, binding := range bindings {
|
||
if binding == nil {
|
||
continue
|
||
}
|
||
binding.QuotaCPU = strings.TrimSpace(workspace.QuotaCPU)
|
||
binding.QuotaMemory = strings.TrimSpace(workspace.QuotaMemory)
|
||
binding.QuotaGPU = strings.TrimSpace(workspace.QuotaGPU)
|
||
if binding.QuotaGPU == "" {
|
||
binding.QuotaGPU = "0"
|
||
}
|
||
binding.QuotaGPUMem = strings.TrimSpace(workspace.QuotaGPUMem)
|
||
if binding.QuotaGPUMem == "" {
|
||
binding.QuotaGPUMem = "0"
|
||
}
|
||
binding.UpdatedAt = time.Now()
|
||
if s.tenantClient != nil && s.clusterRepo != nil {
|
||
cluster, err := s.clusterRepo.GetByID(ctx, binding.ClusterID)
|
||
if err != nil {
|
||
if errors.Is(err, entity.ErrClusterNotFound) {
|
||
continue
|
||
}
|
||
return err
|
||
}
|
||
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||
tenantBinding.ResourceQuotaHard = bindingQuotaHard(binding)
|
||
if err := s.tenantClient.EnsureTenant(ctx, cluster, tenantBinding); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.bindingRepo.Upsert(ctx, binding); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *AuthService) DeleteUser(ctx context.Context, userID string) error {
|
||
principal, err := authz.RequirePrincipal(ctx)
|
||
if err != nil {
|
||
return entity.ErrUnauthorized
|
||
}
|
||
if !principal.IsAdmin() {
|
||
return entity.ErrForbidden
|
||
}
|
||
if userID == principal.UserID {
|
||
return entity.ErrForbidden
|
||
}
|
||
user, err := s.userRepo.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return entity.ErrUserNotFound
|
||
}
|
||
if err := s.ensureUserHasNoInstances(ctx, user); err != nil {
|
||
return err
|
||
}
|
||
if s.isExclusiveUserWorkspace(ctx, user) {
|
||
if err := s.cleanupUserWorkspace(ctx, user.WorkspaceID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return s.userRepo.Delete(ctx, userID)
|
||
}
|
||
|
||
func (s *AuthService) ensureUserHasNoInstances(ctx context.Context, user *entity.User) error {
|
||
if s.instanceRepo == nil || user == nil {
|
||
return nil
|
||
}
|
||
instances, err := s.instanceRepo.List(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, instance := range instances {
|
||
if instance == nil {
|
||
continue
|
||
}
|
||
if instance.OwnerID == user.ID {
|
||
return entity.ErrUserHasInstances
|
||
}
|
||
if user.WorkspaceID != "" && user.WorkspaceID != entity.DefaultWorkspaceID && instance.WorkspaceID == user.WorkspaceID {
|
||
return entity.ErrUserHasInstances
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *AuthService) isExclusiveUserWorkspace(ctx context.Context, user *entity.User) bool {
|
||
if user == nil || user.Role == authz.RoleAdmin || user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID {
|
||
return false
|
||
}
|
||
users, err := s.userRepo.List(ctx)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, other := range users {
|
||
if other == nil || other.ID == user.ID {
|
||
continue
|
||
}
|
||
if other.WorkspaceID == user.WorkspaceID {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (s *AuthService) cleanupUserWorkspace(ctx context.Context, workspaceID string) error {
|
||
if s.workspaceRepo == nil || s.bindingRepo == nil {
|
||
return nil
|
||
}
|
||
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if isProtectedWorkspaceNamespace(workspace.K8sNamespace) {
|
||
return entity.ErrProtectedNamespace
|
||
}
|
||
bindings, err := s.bindingRepo.ListByWorkspace(ctx, workspace.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, binding := range bindings {
|
||
if binding == nil {
|
||
continue
|
||
}
|
||
if isProtectedWorkspaceNamespace(binding.Namespace) {
|
||
return entity.ErrProtectedNamespace
|
||
}
|
||
if s.tenantClient != nil && s.clusterRepo != nil {
|
||
cluster, err := s.clusterRepo.GetByID(ctx, binding.ClusterID)
|
||
if err != nil && !errors.Is(err, entity.ErrClusterNotFound) {
|
||
return err
|
||
}
|
||
if err == nil {
|
||
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||
tenantBinding.ResourceQuotaHard = resourceQuotaHard(workspace)
|
||
if err := s.tenantClient.DeleteTenant(ctx, cluster, tenantBinding); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
if err := s.bindingRepo.Delete(ctx, binding.WorkspaceID, binding.ClusterID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.workspaceRepo.Delete(ctx, workspace.ID); err != nil && !errors.Is(err, entity.ErrWorkspaceNotFound) {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isProtectedWorkspaceNamespace(namespace string) bool {
|
||
switch strings.TrimSpace(namespace) {
|
||
case "", "default", "kube-system", "kube-public", "kube-node-lease":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func normalizeUserRole(role string) string {
|
||
if role == authz.RoleAdmin {
|
||
return authz.RoleAdmin
|
||
}
|
||
return authz.RoleUser
|
||
}
|
||
|
||
// Login 用户登录
|
||
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, user *entity.User, err error) {
|
||
// 查找用户
|
||
user, err = s.userRepo.GetByUsername(ctx, username)
|
||
if err != nil {
|
||
return "", "", nil, entity.ErrUserNotFound
|
||
}
|
||
if !user.IsActive {
|
||
return "", "", nil, entity.ErrUserInactive
|
||
}
|
||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||
return "", "", nil, err
|
||
}
|
||
|
||
// 验证密码
|
||
if err := s.passwordHasher.Verify(password, user.PasswordHash); err != nil {
|
||
return "", "", nil, entity.ErrInvalidPassword
|
||
}
|
||
|
||
// 生成 Token
|
||
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||
if err != nil {
|
||
return "", "", nil, err
|
||
}
|
||
|
||
return accessToken, refreshToken, user, nil
|
||
}
|
||
|
||
// RefreshToken 刷新 Token
|
||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, *entity.User, error) {
|
||
claims, err := s.tokenGenerator.VerifyRefresh(refreshToken)
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||
if err != nil {
|
||
return "", nil, entity.ErrUserNotFound
|
||
}
|
||
if !user.IsActive {
|
||
return "", nil, entity.ErrUserInactive
|
||
}
|
||
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||
return "", nil, entity.ErrTokenRevoked
|
||
}
|
||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||
return "", nil, err
|
||
}
|
||
accessToken, _, err := s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
return accessToken, user, nil
|
||
}
|
||
|
||
// GetUserByID 根据 ID 获取用户
|
||
func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User, error) {
|
||
return s.userRepo.GetByID(ctx, id)
|
||
}
|
||
|
||
// VerifyAccessToken 验证 Access Token(包括 revoked_after 检查)
|
||
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (*authz.Principal, error) {
|
||
// 1. JWT 自验证
|
||
claims, err := s.tokenGenerator.VerifyAccess(token)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 2. 检查用户级别的撤销时间
|
||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||
if err != nil {
|
||
return nil, entity.ErrUserNotFound
|
||
}
|
||
if !user.IsActive {
|
||
return nil, entity.ErrUserInactive
|
||
}
|
||
|
||
// 3. 如果 Token 签发时间早于 revoked_after,则失效
|
||
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||
return nil, entity.ErrTokenRevoked
|
||
}
|
||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||
return nil, err
|
||
}
|
||
workspaceName := ""
|
||
namespace := ""
|
||
defaultClusterID := ""
|
||
quotaCPU := ""
|
||
quotaMemory := ""
|
||
quotaGPU := ""
|
||
quotaGPUMem := ""
|
||
if s.workspaceRepo != nil && user.WorkspaceID != "" {
|
||
if workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID); err == nil && workspace != nil {
|
||
workspaceName = workspace.Name
|
||
namespace = workspace.K8sNamespace
|
||
defaultClusterID = workspace.DefaultClusterID
|
||
quotaCPU = workspace.QuotaCPU
|
||
quotaMemory = workspace.QuotaMemory
|
||
quotaGPU = workspace.QuotaGPU
|
||
quotaGPUMem = workspace.QuotaGPUMem
|
||
}
|
||
}
|
||
|
||
return &authz.Principal{
|
||
UserID: user.ID,
|
||
Username: user.Username,
|
||
Role: user.Role,
|
||
WorkspaceID: user.WorkspaceID,
|
||
WorkspaceName: workspaceName,
|
||
Namespace: namespace,
|
||
DefaultClusterID: defaultClusterID,
|
||
QuotaCPU: quotaCPU,
|
||
QuotaMemory: quotaMemory,
|
||
QuotaGPU: quotaGPU,
|
||
QuotaGPUMem: quotaGPUMem,
|
||
Permissions: authz.PermissionsForRole(user.Role),
|
||
PermissionVersion: 1,
|
||
}, nil
|
||
}
|
||
|
||
func (s *AuthService) GetWorkspaceByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||
if s.workspaceRepo == nil || id == "" {
|
||
return nil, entity.ErrWorkspaceNotFound
|
||
}
|
||
return s.workspaceRepo.GetByID(ctx, id)
|
||
}
|
||
|
||
func (s *AuthService) ensureWorkspaceActive(ctx context.Context, user *entity.User) error {
|
||
if user.Role == authz.RoleAdmin || user.WorkspaceID == "" || s.workspaceRepo == nil {
|
||
return nil
|
||
}
|
||
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||
if err != nil {
|
||
return entity.ErrWorkspaceNotFound
|
||
}
|
||
if workspace.Status == entity.WorkspaceSuspended {
|
||
return entity.ErrWorkspaceSuspended
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ChangePassword 修改密码(会触发全局登出)
|
||
func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
|
||
// 1. 获取用户
|
||
user, err := s.userRepo.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return entity.ErrUserNotFound
|
||
}
|
||
|
||
// 2. 验证旧密码
|
||
if err := s.passwordHasher.Verify(oldPassword, user.PasswordHash); err != nil {
|
||
return entity.ErrInvalidPassword
|
||
}
|
||
|
||
// 3. 哈希新密码
|
||
newPasswordHash, err := s.passwordHasher.Hash(newPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 4. 更新密码(会自动触发 revoked_after 更新)
|
||
user.UpdatePassword(newPasswordHash)
|
||
|
||
// 5. 保存到数据库
|
||
return s.userRepo.Update(ctx, user)
|
||
}
|
||
|
||
// ForceLogoutAll 强制全局登出(管理员操作)
|
||
func (s *AuthService) ForceLogoutAll(ctx context.Context, userID string) error {
|
||
user, err := s.userRepo.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return entity.ErrUserNotFound
|
||
}
|
||
|
||
user.RevokeAllTokens()
|
||
return s.userRepo.Update(ctx, user)
|
||
}
|