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 集成 + 容器内真实系统账号端到端验证
This commit is contained in:
2026-08-29 23:40:20 +08:00
parent ae45aba607
commit 630d240dc0
32 changed files with 2923 additions and 188 deletions
+229 -17
View File
@@ -4,9 +4,11 @@ import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"ws_usernode/internal/config"
"ws_usernode/internal/model"
"ws_usernode/internal/pkg"
"ws_usernode/internal/system"
@@ -14,29 +16,30 @@ import (
// 用户服务错误。
var (
ErrUserNotFound = errors.New("service: 用户不存在")
ErrUserExists = errors.New("service: 用户名已存在")
ErrUserNotFound = errors.New("service: 用户不存在")
ErrUserExists = errors.New("service: 用户名已存在")
ErrUserExpired = errors.New("service: 用户已过期,请先延期")
ErrUserDisabled = errors.New("service: 用户已禁用,无法操作")
ErrSystemAccountMissing = errors.New("service: 系统账号不存在,无法操作")
)
// UserService 外部用户生命周期服务。
// M0 提供查询与创建骨架;建号(useradd)、禁用/延期等系统操作 M1 接入 system.Manager。
// 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) *UserService {
return &UserService{db: db, sys: sys}
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) {
if !strings.HasPrefix(username, "ext_") {
username = "ext_" + username
}
full := normalizeName(username, s.cfg.System.UserPrefix)
var u model.User
if err := s.db.WithContext(ctx).First(&u, "username = ?", username).Error; err != nil {
if err := s.db.WithContext(ctx).First(&u, "username = ?", full).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
@@ -45,16 +48,68 @@ func (s *UserService) GetByUsername(ctx context.Context, username string) (*mode
return &u, nil
}
// Create 创建外部用户记录并调用系统层建号。M0 阶段系统层为 dry-run
// username 为不含前缀的申请名,内部加 ext_ 前缀。
func (s *UserService) Create(ctx context.Context, username, email, supervisor, purpose string, ttlSeconds int64) (*model.User, error) {
// 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
}
full := "ext_" + username
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
@@ -62,21 +117,178 @@ func (s *UserService) Create(ctx context.Context, username, email, supervisor, p
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,
Shell: "/bin/sh",
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 / 真实),失败时回滚 DB 记录
if err := s.sys.CreateUser(ctx, system.Account{Username: full}); err != nil {
// 系统账号创建(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
}