319 lines
9.9 KiB
Go
319 lines
9.9 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
|
||
"ws_usernode/internal/config"
|
||
"ws_usernode/internal/model"
|
||
"ws_usernode/internal/pkg"
|
||
"ws_usernode/internal/system"
|
||
)
|
||
|
||
// 用户服务错误。
|
||
var (
|
||
ErrUserNotFound = errors.New("service: 用户不存在")
|
||
ErrUserExists = errors.New("service: 用户名已存在")
|
||
ErrUserExpired = errors.New("service: 用户已过期,请先延期")
|
||
ErrUserDisabled = errors.New("service: 用户已禁用,无法操作")
|
||
ErrSystemAccountMissing = errors.New("service: 系统账号不存在,无法操作")
|
||
)
|
||
|
||
// UserService 外部用户生命周期服务:DB 记录 + system.Manager 系统账号操作。
|
||
type UserService struct {
|
||
db *gorm.DB
|
||
sys system.Manager
|
||
cfg *config.Config
|
||
settings *SettingService // 可选:默认 TTL 等策略覆盖(settings 表)
|
||
}
|
||
|
||
// NewUserService 创建用户服务。
|
||
func NewUserService(db *gorm.DB, sys system.Manager, cfg *config.Config) *UserService {
|
||
return &UserService{db: db, sys: sys, cfg: cfg}
|
||
}
|
||
|
||
// WithSettings 注入设置服务(settings 表覆盖策略默认值,PLAN F7)。nil 安全。
|
||
func (s *UserService) WithSettings(settings *SettingService) *UserService {
|
||
s.settings = settings
|
||
return s
|
||
}
|
||
|
||
// effectiveDefaultTTL 新账号默认有效期:settings 覆盖优先,否则用配置默认。
|
||
func (s *UserService) effectiveDefaultTTL(ctx context.Context) time.Duration {
|
||
if s.settings != nil {
|
||
return s.settings.DefaultTTL(ctx)
|
||
}
|
||
return s.cfg.Policy.DefaultTTL
|
||
}
|
||
|
||
// GetByUsername 按用户名查询外部用户(含或不含 ext_ 前缀均可)。
|
||
func (s *UserService) GetByUsername(ctx context.Context, username string) (*model.User, error) {
|
||
full := normalizeName(username, s.cfg.System.UserPrefix)
|
||
var u model.User
|
||
if err := s.db.WithContext(ctx).First(&u, "username = ?", full).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrUserNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return &u, nil
|
||
}
|
||
|
||
// GetByID 按 ID 查询外部用户。
|
||
func (s *UserService) GetByID(ctx context.Context, id uint) (*model.User, error) {
|
||
var u model.User
|
||
if err := s.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrUserNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return &u, nil
|
||
}
|
||
|
||
// UserFilter 用户列表筛选条件。
|
||
type UserFilter struct {
|
||
Status string // active / disabled / expired,空为全部
|
||
Supervisor string // 挂靠老师模糊匹配
|
||
Page int
|
||
PageSize int
|
||
}
|
||
|
||
// List 分页查询用户(admin)。
|
||
func (s *UserService) List(ctx context.Context, f UserFilter) ([]model.User, int64, error) {
|
||
q := s.db.WithContext(ctx).Model(&model.User{})
|
||
if f.Status != "" {
|
||
q = q.Where("status = ?", f.Status)
|
||
}
|
||
if f.Supervisor != "" {
|
||
q = q.Where("supervisor LIKE ?", "%"+f.Supervisor+"%")
|
||
}
|
||
var total int64
|
||
if err := q.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
page, size := f.Page, f.PageSize
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 {
|
||
size = 20
|
||
}
|
||
if size > 100 {
|
||
size = 100
|
||
}
|
||
var users []model.User
|
||
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&users).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return users, total, nil
|
||
}
|
||
|
||
// Create 创建外部用户:DB 记录 + 系统账号(useradd + passwd -l)。
|
||
// username 不含前缀;ttl 为有效期时长,0 表示用配置默认(90 天)。
|
||
// 系统建号失败时回滚 DB 记录,保证两侧一致。
|
||
func (s *UserService) Create(ctx context.Context, username, email, supervisor, purpose string, ttl time.Duration, createdBy uint) (*model.User, error) {
|
||
if err := pkg.ValidateUserName(username); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := pkg.ValidateEmail(email); err != nil {
|
||
return nil, err
|
||
}
|
||
username = strings.TrimSpace(username)
|
||
full := s.cfg.System.UserPrefix + username
|
||
var count int64
|
||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", full).Count(&count).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if count > 0 {
|
||
return nil, ErrUserExists
|
||
}
|
||
if ttl <= 0 {
|
||
ttl = s.effectiveDefaultTTL(ctx)
|
||
}
|
||
expireAt := time.Now().Add(ttl)
|
||
u := &model.User{
|
||
Username: full,
|
||
Email: email,
|
||
Supervisor: supervisor,
|
||
Purpose: purpose,
|
||
Status: model.UserStatusActive,
|
||
ExpireAt: &expireAt,
|
||
Shell: s.cfg.System.Shell,
|
||
CreatedBy: createdBy,
|
||
}
|
||
if err := s.db.WithContext(ctx).Create(u).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
// 系统账号创建(dry-run / 直接 / sudo),失败时回滚 DB 记录
|
||
if err := s.sys.CreateUser(ctx, system.Account{Username: full, Shell: s.cfg.System.Shell}); err != nil {
|
||
_ = s.db.WithContext(ctx).Delete(u).Error
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
// Update 更新外部用户信息(仅更新非 nil 字段;邮箱由管理员修改,用户不可自助改)。
|
||
func (s *UserService) Update(ctx context.Context, id uint, email, supervisor, purpose *string) (*model.User, error) {
|
||
if _, err := s.GetByID(ctx, id); err != nil {
|
||
return nil, err
|
||
}
|
||
updates := make(map[string]any)
|
||
if email != nil {
|
||
if err := pkg.ValidateEmail(*email); err != nil {
|
||
return nil, err
|
||
}
|
||
updates["email"] = *email
|
||
}
|
||
if supervisor != nil {
|
||
updates["supervisor"] = *supervisor
|
||
}
|
||
if purpose != nil {
|
||
updates["purpose"] = *purpose
|
||
}
|
||
if len(updates) > 0 {
|
||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return s.GetByID(ctx, id)
|
||
}
|
||
|
||
// systemAccountOK 检查系统账号存在;dry-run 模式跳过检查(演练流程)。
|
||
func (s *UserService) systemAccountOK(ctx context.Context, username string) bool {
|
||
if s.cfg.System.DryRun {
|
||
return true
|
||
}
|
||
ok, err := s.sys.Exists(ctx, username)
|
||
return err == nil && ok
|
||
}
|
||
|
||
// Disable 禁用用户:DB 置 disabled + 清空 authorized_keys(SSH 立即失效)。
|
||
func (s *UserService) Disable(ctx context.Context, id uint) error {
|
||
u, err := s.GetByID(ctx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if u.Status == model.UserStatusDisabled {
|
||
return nil // 幂等
|
||
}
|
||
if !s.systemAccountOK(ctx, u.Username) {
|
||
return ErrSystemAccountMissing
|
||
}
|
||
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, nil); err != nil {
|
||
return err
|
||
}
|
||
return s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.UserStatusDisabled).Error
|
||
}
|
||
|
||
// Enable 启用用户:DB 置 active + 按 DB 状态重写 authorized_keys。
|
||
// 已过期的用户需先延期(Extend)。
|
||
func (s *UserService) Enable(ctx context.Context, id uint) error {
|
||
u, err := s.GetByID(ctx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if u.Status == model.UserStatusActive {
|
||
return nil // 幂等
|
||
}
|
||
if u.ExpireAt != nil && time.Now().After(*u.ExpireAt) {
|
||
return ErrUserExpired
|
||
}
|
||
if !s.systemAccountOK(ctx, u.Username) {
|
||
return ErrSystemAccountMissing
|
||
}
|
||
// 恢复有效密钥(以 DB 状态全量同步)
|
||
keys, err := activeUserKeys(s.db, ctx, u.ID, 0)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, keys); err != nil {
|
||
return err
|
||
}
|
||
return s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.UserStatusActive).Error
|
||
}
|
||
|
||
// Extend 延长有效期:重设 expire_at(days<=0 用默认 TTL,settings 覆盖优先)。
|
||
// 已过期用户在回收期内可经此恢复(PLAN §2.2),恢复后同步密钥。
|
||
// 返回新的过期时间,供 handler 响应。
|
||
func (s *UserService) Extend(ctx context.Context, id uint, days int) (time.Time, error) {
|
||
u, err := s.GetByID(ctx, id)
|
||
if err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
ttl := time.Duration(days) * 24 * time.Hour
|
||
if days <= 0 {
|
||
ttl = s.effectiveDefaultTTL(ctx)
|
||
}
|
||
newExpire := time.Now().Add(ttl)
|
||
updates := map[string]any{"expire_at": newExpire}
|
||
if u.Status == model.UserStatusExpired {
|
||
if !s.systemAccountOK(ctx, u.Username) {
|
||
return time.Time{}, ErrSystemAccountMissing
|
||
}
|
||
keys, err := activeUserKeys(s.db, ctx, u.ID, 0)
|
||
if err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, keys); err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
updates["status"] = model.UserStatusActive
|
||
}
|
||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
return newExpire, nil
|
||
}
|
||
|
||
// Delete 删除并回收用户:删除系统账号(userdel -r)+ 家目录 + 密钥记录,
|
||
// 保留审计。系统账号已不存在时仍完成 DB 清理。
|
||
func (s *UserService) Delete(ctx context.Context, id uint) error {
|
||
u, err := s.GetByID(ctx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if s.systemAccountOK(ctx, u.Username) {
|
||
if err := s.sys.RemoveUser(ctx, u.Username); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Where("user_id = ?", u.ID).Delete(&model.SSHKey{}).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Delete(&model.User{}, "id = ?", u.ID).Error
|
||
})
|
||
}
|
||
|
||
// activeUserKeys 返回用户当前有效(active)密钥,供 authorized_keys 全量同步
|
||
// (UserService.Enable/Extend 与 KeyService 变更共用,保证同步口径一致)。
|
||
// excludeKeyID 非 0 时排除指定密钥(吊销场景:先同步剩余密钥,再落 DB)。
|
||
func activeUserKeys(db *gorm.DB, ctx context.Context, userID uint, excludeKeyID uint) ([]system.Key, error) {
|
||
var rows []model.SSHKey
|
||
if err := db.WithContext(ctx).Where("user_id = ? AND status = ?", userID, model.StatusActive).Find(&rows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
keys := make([]system.Key, 0, len(rows))
|
||
for _, k := range rows {
|
||
if excludeKeyID != 0 && k.ID == excludeKeyID {
|
||
continue
|
||
}
|
||
keys = append(keys, system.Key{Type: k.KeyType, PublicKey: k.PublicKey})
|
||
}
|
||
return keys, nil
|
||
}
|
||
|
||
// normalizeName 补全系统账号前缀(如 ext_)。
|
||
func normalizeName(username, prefix string) string {
|
||
name := strings.TrimSpace(username)
|
||
if !strings.HasPrefix(name, prefix) {
|
||
return prefix + name
|
||
}
|
||
return name
|
||
}
|