6e99e32285
实现「一个二进制三个监听」中的管理端:
- 独立监听 ADMIN_LISTEN(默认 127.0.0.1:9443,FromEnv 拒绝 0.0.0.0/:: 等公网绑定)
- 中间件链:mw_ipallow(CIDR 白名单,仅信任 RemoteAddr,默认内网段)→
mw_session(HttpOnly+Secure+SameSite=Strict cookie,Redis 30min 滑动 TTL)
- 登录双因素:argon2id 密码 + RFC6238 TOTP;失败限流与临时锁定;
TOTP 密钥 AES-GCM 加密入库;migration 000008 增补 admins 表;
首个管理员由 cmd/adminctl create 创建(终端输出 otpauth URI/Secret)
- 功能三块(html/template + embed 静态资源,原生 JS 二次确认):
1. 码批次:表单生成→明文仅在本次 CSV 下载出现(不落盘/不入日志);
批次列表 + 整批作废(复用 #3 codes,新增 Store.ListBatches/VoidBatch)
2. 节点操作:列表 + 近期 node_events;replace 经 #14 ProvisionService、
draining/up 经 #5 Lifecycle(二者未就绪→注入 stub,UI 置灰);二次确认 + CSRF
3. audit_log 查看:actor/action/target/时间范围过滤分页;同页查 node_events
- 所有写操作写 audit_log(actor=用户名,meta 不含明文);管理端仅记安全事件
(登录失败/锁定/白名单拦截),不记常规访问日志
- internal/totp 复用包(与 doc/05 用户中心 2FA 同算法,纯标准库)
测试:totp RFC6238 向量、argon2/AES 往返、白名单放行/拦截、会话滑动过期、
登录成功/密码错/TOTP 错/未知用户/锁定、批次 CSV 含明文且 audit 不泄露、
作废/节点操作的二次确认与 CSRF 缺失被拒、mock 断言 service 调用参数、审计过滤。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/wangjia/pangolin/server/internal/totp"
|
|
)
|
|
|
|
// Login outcome sentinel errors.
|
|
var (
|
|
// ErrInvalidCredentials is returned for any wrong username / password /
|
|
// TOTP combination. It is deliberately generic to avoid user enumeration.
|
|
ErrInvalidCredentials = errors.New("admin: invalid credentials")
|
|
// ErrLockedOut is returned when the username is temporarily locked after
|
|
// too many consecutive failures.
|
|
ErrLockedOut = errors.New("admin: account temporarily locked")
|
|
)
|
|
|
|
const loginFailKeyPrefix = "admin:loginfail:"
|
|
|
|
// Authenticator performs two-factor admin login with failure rate-limiting.
|
|
type Authenticator struct {
|
|
store Store
|
|
sessions *SessionStore
|
|
rdb *redis.Client
|
|
secret []byte
|
|
failMax int
|
|
lockDur time.Duration
|
|
sec *SecurityLog
|
|
// now is overridable in tests.
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewAuthenticator wires an Authenticator.
|
|
func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cfg *Config, sec *SecurityLog) *Authenticator {
|
|
return &Authenticator{
|
|
store: store,
|
|
sessions: sessions,
|
|
rdb: rdb,
|
|
secret: cfg.SecretKey,
|
|
failMax: cfg.LoginFailMax,
|
|
lockDur: cfg.LoginLockDuration,
|
|
sec: sec,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
// Login validates username + password + TOTP and, on success, creates a
|
|
// session and returns its id. Every failure is rate-limited and recorded as a
|
|
// security event in the audit log (red line: admin records only security
|
|
// events, never routine access).
|
|
func (a *Authenticator) Login(ctx context.Context, username, password, code, remoteIP string) (sid string, sess *Session, err error) {
|
|
locked, lerr := a.isLocked(ctx, username)
|
|
if lerr != nil {
|
|
return "", nil, fmt.Errorf("admin.Login lock check: %w", lerr)
|
|
}
|
|
if locked {
|
|
a.sec.LoginLocked(ctx, username, remoteIP)
|
|
return "", nil, ErrLockedOut
|
|
}
|
|
|
|
admin, gerr := a.store.GetAdminByUsername(ctx, username)
|
|
if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) {
|
|
return "", nil, fmt.Errorf("admin.Login lookup: %w", gerr)
|
|
}
|
|
|
|
if admin == nil || admin.Status != "active" || !VerifyPassword(admin.PwHash, password) {
|
|
a.recordFail(ctx, username)
|
|
a.sec.LoginFail(ctx, username, remoteIP, "bad_password")
|
|
return "", nil, ErrInvalidCredentials
|
|
}
|
|
|
|
secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc)
|
|
if derr != nil {
|
|
a.recordFail(ctx, username)
|
|
a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt")
|
|
return "", nil, ErrInvalidCredentials
|
|
}
|
|
if !totp.Validate(secret, code, a.now(), 1) {
|
|
a.recordFail(ctx, username)
|
|
a.sec.LoginFail(ctx, username, remoteIP, "bad_totp")
|
|
return "", nil, ErrInvalidCredentials
|
|
}
|
|
|
|
// Success: clear counter, stamp login, create session.
|
|
a.clearFail(ctx, username)
|
|
if uerr := a.store.UpdateLastLogin(ctx, admin.ID, a.now()); uerr != nil {
|
|
return "", nil, fmt.Errorf("admin.Login update: %w", uerr)
|
|
}
|
|
sid, sess, serr := a.sessions.Create(ctx, admin.ID, admin.Username)
|
|
if serr != nil {
|
|
return "", nil, serr
|
|
}
|
|
a.sec.LoginOK(ctx, username, remoteIP)
|
|
return sid, sess, nil
|
|
}
|
|
|
|
func (a *Authenticator) isLocked(ctx context.Context, username string) (bool, error) {
|
|
if a.rdb == nil {
|
|
return false, nil
|
|
}
|
|
n, err := a.rdb.Get(ctx, loginFailKeyPrefix+username).Int()
|
|
if errors.Is(err, redis.Nil) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return n >= a.failMax, nil
|
|
}
|
|
|
|
func (a *Authenticator) recordFail(ctx context.Context, username string) {
|
|
if a.rdb == nil {
|
|
return
|
|
}
|
|
key := loginFailKeyPrefix + username
|
|
pipe := a.rdb.Pipeline()
|
|
pipe.Incr(ctx, key)
|
|
pipe.Expire(ctx, key, a.lockDur)
|
|
_, _ = pipe.Exec(ctx)
|
|
}
|
|
|
|
func (a *Authenticator) clearFail(ctx context.Context, username string) {
|
|
if a.rdb == nil {
|
|
return
|
|
}
|
|
_ = a.rdb.Del(ctx, loginFailKeyPrefix+username).Err()
|
|
}
|