Files
usernode/internal/service/user.go
T

373 lines
12 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"
"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 // 挂靠老师模糊匹配
ExpiresBefore *time.Time // 仅返回 expire_at 早于该时刻的用户(仪表盘"近期待过期"用)
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+"%")
}
if f.ExpiresBefore != nil {
q = q.Where("expire_at IS NOT NULL AND expire_at < ?", f.ExpiresBefore)
}
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
}
// UserStats 仪表盘统计(PLAN §8):各状态用户数、近 30 天待过期用户、待审批申请数。
type UserStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
Disabled int64 `json:"disabled"`
Expired int64 `json:"expired"`
ExpiringSoon []model.User `json:"expiring_soon"` // 30 天内到期且仍 active
PendingApprovals int64 `json:"pending_approvals"`
}
// Stats 汇总仪表盘统计。
func (s *UserService) Stats(ctx context.Context) (*UserStats, error) {
st := &UserStats{}
q := s.db.WithContext(ctx).Model(&model.User{})
if err := q.Count(&st.Total).Error; err != nil {
return nil, err
}
byStatus := map[string]int64{}
var rows []struct {
Status string
C int64
}
if err := s.db.WithContext(ctx).Model(&model.User{}).Select("status, count(*) as c").Group("status").Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
byStatus[r.Status] = r.C
}
st.Active = byStatus[model.UserStatusActive]
st.Disabled = byStatus[model.UserStatusDisabled]
st.Expired = byStatus[model.UserStatusExpired]
// 近 30 天待过期(active 且 expire_at 在 30 天内)
window := time.Now().Add(30 * 24 * time.Hour)
var soon []model.User
if err := s.db.WithContext(ctx).Model(&model.User{}).
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusActive, window).
Order("expire_at ASC").Limit(10).Find(&soon).Error; err != nil {
return nil, err
}
st.ExpiringSoon = soon
var pending int64
if err := s.db.WithContext(ctx).Model(&model.Approval{}).Where("status = ?", model.StatusPending).Count(&pending).Error; err != nil {
return nil, err
}
st.PendingApprovals = pending
return st, 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_keysSSH 立即失效)。
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_atdays<=0 用默认 TTLsettings 覆盖优先)。
// 已过期用户在回收期内可经此恢复(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
}