feat(M0): 工程骨架 — Go 后端 + Vue3 前端脚手架
- Go 工程:cmd/usernode CLI(serve/migrate/admin create/reset-password/user otp)
+ internal/{config,model,service,system,auth,cron,api,router,server,pkg,webui}
- 配置:TOML + 环境变量覆盖(USERNODE_<SEC>_<FIELD>),config.example.toml
- 数据:GORM 双驱动(SQLite/MySQL)8 表模型,migrate 子命令可跑通
- 系统层:system.Manager 接口(useradd/userdel/passwd 白名单,dev dry-run)
- HTTP:Gin 路由骨架(/api/v1 + 501 占位),healthz,slog 结构化日志,优雅启停
- 前端:Vite + Vue3 + TS + Element Plus 最小可运行(web/),go:embed 打通
- 构建:deploy/Containerfile 多阶段单二进制镜像,web/Containerfile.dev 前端 dev
镜像,Makefile(build/test/dev/web-build/image);go.mod 锁定 go 1.22
- 验证:go build/vet/test 通过;podman 镜像构建运行 healthz+embed 通过
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// ErrCaptchaInvalid 表示图形验证码校验失败。
|
||||
var ErrCaptchaInvalid = errors.New("auth: 图形验证码错误")
|
||||
|
||||
// Captcha 图形验证码(防机器人,登录前置)。
|
||||
type Captcha struct {
|
||||
ID string
|
||||
Text string // M1 生成图像渲染,此处仅存文本
|
||||
}
|
||||
|
||||
// CaptchaStore 为图形验证码存储(M1 实现图像渲染)。
|
||||
type CaptchaStore interface {
|
||||
// New 生成一个验证码并返回其 ID。
|
||||
New() (*Captcha, error)
|
||||
// Verify 校验并一次性消费。失败或过期返回 false。
|
||||
Verify(id, answer string) bool
|
||||
}
|
||||
|
||||
// MemoryCaptchaStore 单实例内存实现。
|
||||
type MemoryCaptchaStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*captchaEntry
|
||||
}
|
||||
|
||||
type captchaEntry struct {
|
||||
text string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewMemoryCaptchaStore 创建内存图形验证码存储。
|
||||
func NewMemoryCaptchaStore() *MemoryCaptchaStore {
|
||||
return &MemoryCaptchaStore{entries: make(map[string]*captchaEntry)}
|
||||
}
|
||||
|
||||
func (s *MemoryCaptchaStore) New() (*Captcha, error) {
|
||||
text, err := pkg.RandomDigits(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := pkg.RandomHex(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[id] = &captchaEntry{text: text, expiresAt: time.Now().Add(5 * time.Minute)}
|
||||
return &Captcha{ID: id, Text: text}, nil
|
||||
}
|
||||
|
||||
func (s *MemoryCaptchaStore) Verify(id, answer string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.entries[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.entries, id) // 一次性
|
||||
return e.text == answer && time.Now().Before(e.expiresAt)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package auth 提供认证相关能力:OTP(双通道)、会话、bcrypt、图形验证码。
|
||||
//
|
||||
// OTP 双通道对齐:邮件发送与 CLI 获取共用同一 OTPStore(同一验证码、同一
|
||||
// 10 分钟有效期、同一 60s 冷却与失败限速),邮件失败不阻断 CLI 通道。
|
||||
// 单实例用内存存储;多实例需改为 DB/Redis(PLAN §6 注明)。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// OTP 错误。
|
||||
var (
|
||||
ErrCooldown = errors.New("auth: otp 发送冷却中")
|
||||
ErrInvalidCode = errors.New("auth: 验证码错误")
|
||||
ErrTooManyFails = errors.New("auth: 失败次数过多,请稍后再试")
|
||||
)
|
||||
|
||||
const (
|
||||
otpCodeLen = 6
|
||||
maxFailures = 5 // 单账号连续失败限速阈值
|
||||
failureWin = 10 * time.Minute // 失败计数窗口
|
||||
)
|
||||
|
||||
// OTPStore 为 OTP 验证码存储。内存实现为单实例默认实现。
|
||||
type OTPStore interface {
|
||||
// Send 为 username 生成新验证码(覆盖旧码)。冷却期内调用返回 ErrCooldown。
|
||||
// 邮件与 CLI 双通道都走该方法,保证对齐。
|
||||
Send(username string, ttl, cooldown time.Duration) (string, error)
|
||||
// Verify 校验验证码并一次性消费。失败累计计数(达到阈值返回 ErrTooManyFails)。
|
||||
Verify(username, code string) (bool, error)
|
||||
// Failures 返回 username 当前失败计数。
|
||||
Failures(username string) (int, error)
|
||||
}
|
||||
|
||||
type otpEntry struct {
|
||||
code string
|
||||
expiresAt time.Time
|
||||
cooldownAt time.Time
|
||||
failures int
|
||||
}
|
||||
|
||||
// MemoryOTPStore 为单实例内存实现。
|
||||
type MemoryOTPStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*otpEntry
|
||||
}
|
||||
|
||||
// NewMemoryOTPStore 创建内存 OTP 存储。
|
||||
func NewMemoryOTPStore() *MemoryOTPStore {
|
||||
return &MemoryOTPStore{entries: make(map[string]*otpEntry)}
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Send(username string, ttl, cooldown time.Duration) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if e, ok := s.entries[username]; ok && now.Before(e.cooldownAt) {
|
||||
return "", ErrCooldown
|
||||
}
|
||||
code, err := pkg.RandomDigits(otpCodeLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.entries[username] = &otpEntry{
|
||||
code: code,
|
||||
expiresAt: now.Add(ttl),
|
||||
cooldownAt: now.Add(cooldown),
|
||||
failures: 0,
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Verify(username, code string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
e, ok := s.entries[username]
|
||||
if !ok {
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(e.expiresAt) {
|
||||
delete(s.entries, username)
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
if e.failures >= maxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
if e.code != code {
|
||||
e.failures++
|
||||
if e.failures >= maxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
delete(s.entries, username) // 一次性
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Failures(username string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if e, ok := s.entries[username]; ok {
|
||||
return e.failures, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword 使用 bcrypt 对明文口令加盐哈希。管理员口令存储唯一用途。
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 校验明文口令与哈希是否匹配。
|
||||
func VerifyPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// 会话用户类型。
|
||||
const (
|
||||
SessionUserAdmin = "admin"
|
||||
SessionUserUser = "user"
|
||||
)
|
||||
|
||||
// Session 为一次会话的元数据(对 model.Session 的轻量封装)。
|
||||
type Session struct {
|
||||
ID string
|
||||
UserType string
|
||||
RefID uint
|
||||
ExpireAt time.Time
|
||||
}
|
||||
|
||||
// ErrSessionNotFound 表示会话不存在或已过期。
|
||||
var ErrSessionNotFound = errors.New("auth: session not found")
|
||||
|
||||
// SessionStore 为会话存储接口(DB 实现,兼容多实例)。
|
||||
type SessionStore interface {
|
||||
Create(ctx context.Context, userType string, refID uint, ttl time.Duration, ip, userAgent string) (string, error)
|
||||
Get(ctx context.Context, sessionID string) (*Session, error)
|
||||
Delete(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
// DBSessionStore 基于 model.Session 的存储实现。
|
||||
type DBSessionStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDBSessionStore 创建会话存储。
|
||||
func NewDBSessionStore(db *gorm.DB) *DBSessionStore {
|
||||
return &DBSessionStore{db: db}
|
||||
}
|
||||
|
||||
func (s *DBSessionStore) Create(ctx context.Context, userType string, refID uint, ttl time.Duration, ip, userAgent string) (string, error) {
|
||||
id, err := pkg.RandomHex(24)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sess := model.Session{
|
||||
ID: id,
|
||||
UserType: userType,
|
||||
RefID: refID,
|
||||
ExpireAt: time.Now().Add(ttl),
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&sess).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *DBSessionStore) Get(ctx context.Context, sessionID string) (*Session, error) {
|
||||
var m model.Session
|
||||
err := s.db.WithContext(ctx).First(&m, "id = ?", sessionID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(m.ExpireAt) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return &Session{ID: m.ID, UserType: m.UserType, RefID: m.RefID, ExpireAt: m.ExpireAt}, nil
|
||||
}
|
||||
|
||||
func (s *DBSessionStore) Delete(ctx context.Context, sessionID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.Session{}, "id = ?", sessionID).Error
|
||||
}
|
||||
Reference in New Issue
Block a user