Files
ocdp-go/backend/internal/domain/service/auth_service.go
Ivan087 7f238a3168 refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics
- 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
2026-05-12 16:15:14 +08:00

489 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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