Files
usernode/internal/service/user.go
T
cao.wangrenbo 630d240dc0 feat(M1): 认证与用户管理 — 双通道登录、cookie 会话、用户 CRUD 与真实系统账号对接
认证:
- 图形验证码 GET /auth/captcha(内置 PNG 渲染,零第三方依赖)
- 外部用户 OTP 双通道:DB 存储(otp_codes)使邮件与 CLI 共用同一验证码/冷却/失败限速
- 管理员 bcrypt 登录 + 连续失败限速锁定;admin/forgot + admin/reset 邮件重置(SMTP 或日志)
- cookie 会话(HttpOnly/SameSite)、me/logout、admin/user 鉴权中间件

用户管理(admin):
- CRUD + disable/enable/extend/delete,对接 system 层真实 useradd/usermod/userdel/passwd
- system 层三执行模式:dry-run(默认,安全)/ direct(容器/测试用户)/ sudo(生产 sudoers 白名单)
- Exists 系统账号一致性检查;deploy/sudoers.example 白名单模板
- 关键操作接入 append-only 审计

其他:
- CLI user otp 改 DB store,与邮件通道真正对齐
- 容器镜像补 shadow(alpine 无 useradd);Makefile VERSION 0.2.0-m1
- 测试:auth/service 单测 + api httptest 集成 + 容器内真实系统账号端到端验证
2026-08-29 23:40:20 +08:00

295 lines
8.9 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
}
// NewUserService 创建用户服务。
func NewUserService(db *gorm.DB, sys system.Manager, cfg *config.Config) *UserService {
return &UserService{db: db, sys: sys, cfg: cfg}
}
// 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.cfg.Policy.DefaultTTL
}
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
}
// 恢复有效密钥(M1 阶段用户尚无密钥,M2 接入后按 DB 同步)
keys, err := s.activeKeys(ctx, u.ID)
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 用配置默认 TTL)。
// 已过期用户在回收期内可经此恢复(PLAN §2.2),恢复后同步密钥。
func (s *UserService) Extend(ctx context.Context, id uint, days int) error {
u, err := s.GetByID(ctx, id)
if err != nil {
return err
}
ttl := time.Duration(days) * 24 * time.Hour
if days <= 0 {
ttl = s.cfg.Policy.DefaultTTL
}
newExpire := time.Now().Add(ttl)
updates := map[string]any{"expire_at": newExpire}
if u.Status == model.UserStatusExpired {
if !s.systemAccountOK(ctx, u.Username) {
return ErrSystemAccountMissing
}
keys, err := s.activeKeys(ctx, u.ID)
if err != nil {
return err
}
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, keys); err != nil {
return err
}
updates["status"] = model.UserStatusActive
}
return s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error
}
// 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
})
}
// activeKeys 返回用户当前有效(active)密钥,供授权同步(M2 完善密钥管理)。
func (s *UserService) activeKeys(ctx context.Context, userID uint) ([]system.Key, error) {
var rows []model.SSHKey
if err := s.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 {
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
}