- Add Workspace domain (entity, repository, service, handler, DTO) - Add multi-tenant K8s client with tenant binding and quota management - Add K8s diagnostics client (instance diagnostics) - Add authorization middleware (authz package) - Restructure frontend to feature-based architecture (features/) - Add User Management page in configuration - Add AccessDenied page and route guards - Refactor shared components (form inputs, layout, UI) - Update Tailwind config for new design system - Add comprehensive documentation (docs/, tasks/, plans) - Improve cluster service with better kubeconfig handling - Add tests for crypto, config, helm client, tenant binding
489 lines
14 KiB
Go
489 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"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
|
||
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,
|
||
}
|
||
}
|
||
|
||
// Register 注册新用户。业务入口只允许 admin 调用;初始 admin 由 bootstrap seeder 创建。
|
||
type UserWorkspaceOptions struct {
|
||
Namespace string
|
||
DefaultClusterID string
|
||
QuotaCPU string
|
||
QuotaMemory string
|
||
QuotaGPU string
|
||
QuotaGPUMem string
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 默认生成占位邮箱,避免数据库约束失败
|
||
email := username + "@local.ocdp"
|
||
|
||
// 创建用户
|
||
user := entity.NewUser(username, passwordHash, email)
|
||
user.ID = uuid.New().String()
|
||
user.Role = normalizeUserRole(role)
|
||
user.WorkspaceID = workspaceID
|
||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||
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 := strings.TrimPrefix(entity.NamespaceForUser(username), "ocdp-u-")
|
||
workspace := entity.NewWorkspace(name, createdBy)
|
||
workspace.ID = uuid.New().String()
|
||
workspace.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||
namespace := strings.TrimSpace(opts.Namespace)
|
||
if namespace == "" {
|
||
namespace = entity.NamespaceForUser(username)
|
||
}
|
||
if namespace != "" {
|
||
if len(validation.IsDNS1123Label(namespace)) > 0 {
|
||
return nil, entity.ErrInvalidNamespace
|
||
}
|
||
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 {
|
||
return nil, err
|
||
}
|
||
return workspace, 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 (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
|
||
}
|
||
if role != "" {
|
||
user.Role = normalizeUserRole(role)
|
||
}
|
||
if workspaceID != "" {
|
||
user.WorkspaceID = workspaceID
|
||
}
|
||
if user.Role == authz.RoleAdmin {
|
||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||
}
|
||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
workspace, err := s.createUserWorkspace(ctx, user.Username, principal.UserID, normalizedOpts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
user.WorkspaceID = workspace.ID
|
||
}
|
||
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 && 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
|
||
}
|
||
applyWorkspaceOptions(workspace, normalizedOpts)
|
||
if err := s.workspaceRepo.Update(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) 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
|
||
}
|
||
return s.userRepo.Delete(ctx, userID)
|
||
}
|
||
|
||
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)
|
||
}
|