Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d154bd627 | |||
| f819a77d83 |
@@ -31,11 +31,14 @@ type Authenticator struct {
|
||||
failMax int
|
||||
lockDur time.Duration
|
||||
sec *SecurityLog
|
||||
trusted *TrustedStore
|
||||
trustTTL time.Duration
|
||||
// now is overridable in tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewAuthenticator wires an Authenticator.
|
||||
// NewAuthenticator wires an Authenticator. Device-trust ("记住此设备") is
|
||||
// enabled when cfg.TrustedDeviceTTL > 0.
|
||||
func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cfg *Config, sec *SecurityLog) *Authenticator {
|
||||
return &Authenticator{
|
||||
store: store,
|
||||
@@ -45,58 +48,105 @@ func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cf
|
||||
failMax: cfg.LoginFailMax,
|
||||
lockDur: cfg.LoginLockDuration,
|
||||
sec: sec,
|
||||
trusted: NewTrustedStore(rdb, cfg.TrustedDeviceTTL),
|
||||
trustTTL: cfg.TrustedDeviceTTL,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// LoginResult is the outcome of a device-aware login.
|
||||
type LoginResult struct {
|
||||
SID string
|
||||
Session *Session
|
||||
// NewTrustToken is non-empty when the caller ticked "记住此设备" and a fresh
|
||||
// device-trust token was issued — the handler sets it as the trust cookie.
|
||||
NewTrustToken string
|
||||
// Persistent is true when this login should use a long-lived (trust-TTL)
|
||||
// session + cookie instead of the short idle default.
|
||||
Persistent bool
|
||||
}
|
||||
|
||||
// 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) {
|
||||
res, lerr := a.LoginDevice(ctx, username, password, code, remoteIP, "", false)
|
||||
return res.SID, res.Session, lerr
|
||||
}
|
||||
|
||||
// LoginDevice is the device-aware login: username + password are ALWAYS
|
||||
// required; the TOTP second factor is skipped only when trustToken is a live
|
||||
// trust token bound to this admin ("记住此设备"). When remember is set, a fresh
|
||||
// trust token is issued and the session is made persistent (trust-TTL long).
|
||||
//
|
||||
// Security invariant: a trust token never substitutes for the password — a
|
||||
// wrong password fails regardless of trust.
|
||||
func (a *Authenticator) LoginDevice(ctx context.Context, username, password, code, remoteIP, trustToken string, remember bool) (LoginResult, error) {
|
||||
locked, lerr := a.isLocked(ctx, username)
|
||||
if lerr != nil {
|
||||
return "", nil, fmt.Errorf("admin.Login lock check: %w", lerr)
|
||||
return LoginResult{}, fmt.Errorf("admin.Login lock check: %w", lerr)
|
||||
}
|
||||
if locked {
|
||||
a.sec.LoginLocked(ctx, username, remoteIP)
|
||||
return "", nil, ErrLockedOut
|
||||
return LoginResult{}, ErrLockedOut
|
||||
}
|
||||
|
||||
admin, gerr := a.store.GetAdminByUsername(ctx, username)
|
||||
if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) {
|
||||
return "", nil, fmt.Errorf("admin.Login lookup: %w", gerr)
|
||||
return LoginResult{}, 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
|
||||
return LoginResult{}, 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
|
||||
// Second factor: skip only for a device already trusted by THIS admin.
|
||||
if !a.trusted.Check(ctx, trustToken, admin.ID) {
|
||||
secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc)
|
||||
if derr != nil {
|
||||
a.recordFail(ctx, username)
|
||||
a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt")
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
if !totp.Validate(secret, code, a.now(), 1) {
|
||||
a.recordFail(ctx, username)
|
||||
a.sec.LoginFail(ctx, username, remoteIP, "bad_totp")
|
||||
return LoginResult{}, 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)
|
||||
return LoginResult{}, fmt.Errorf("admin.Login update: %w", uerr)
|
||||
}
|
||||
|
||||
persistent := remember && a.trusted.Enabled()
|
||||
var (
|
||||
sid string
|
||||
sess *Session
|
||||
serr error
|
||||
)
|
||||
if persistent {
|
||||
sid, sess, serr = a.sessions.CreateWithTTL(ctx, admin.ID, admin.Username, a.trustTTL)
|
||||
} else {
|
||||
sid, sess, serr = a.sessions.Create(ctx, admin.ID, admin.Username)
|
||||
}
|
||||
sid, sess, serr := a.sessions.Create(ctx, admin.ID, admin.Username)
|
||||
if serr != nil {
|
||||
return "", nil, serr
|
||||
return LoginResult{}, serr
|
||||
}
|
||||
|
||||
res := LoginResult{SID: sid, Session: sess, Persistent: persistent}
|
||||
if remember {
|
||||
if tok, terr := a.trusted.Issue(ctx, admin.ID); terr == nil {
|
||||
res.NewTrustToken = tok
|
||||
}
|
||||
}
|
||||
a.sec.LoginOK(ctx, username, remoteIP)
|
||||
return sid, sess, nil
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) isLocked(ctx context.Context, username string) (bool, error) {
|
||||
|
||||
@@ -44,6 +44,12 @@ type Config struct {
|
||||
// CookieSecure controls the Secure attribute on the session cookie.
|
||||
// Defaults to true; only disabled explicitly for local/dev over plain HTTP.
|
||||
CookieSecure bool
|
||||
|
||||
// TrustedDeviceTTL is how long a "记住此设备" trust lasts. Within this window
|
||||
// the device (a) skips the TOTP second factor on re-login and (b) keeps a
|
||||
// persistent session of the same length. Password is ALWAYS still required.
|
||||
// Zero disables the feature (checkbox has no effect).
|
||||
TrustedDeviceTTL time.Duration
|
||||
}
|
||||
|
||||
// defaultInternalCIDRs are the loopback and private/ULA ranges allowed by
|
||||
@@ -66,6 +72,7 @@ var defaultInternalCIDRs = []string{
|
||||
// ADMIN_LOGIN_FAIL_MAX int (default 5)
|
||||
// ADMIN_LOGIN_LOCK Go duration (default 15m)
|
||||
// ADMIN_COOKIE_INSECURE "1" disables Secure flag (dev only)
|
||||
// ADMIN_TRUSTED_DEVICE_TTL Go duration (default 720h = 30d; 0 disables)
|
||||
func FromEnv() (*Config, error) {
|
||||
c := &Config{
|
||||
Listen: getEnvDefault("ADMIN_LISTEN", "127.0.0.1:9443"),
|
||||
@@ -73,6 +80,14 @@ func FromEnv() (*Config, error) {
|
||||
LoginFailMax: 5,
|
||||
LoginLockDuration: 15 * time.Minute,
|
||||
CookieSecure: os.Getenv("ADMIN_COOKIE_INSECURE") != "1",
|
||||
TrustedDeviceTTL: 30 * 24 * time.Hour,
|
||||
}
|
||||
if v := os.Getenv("ADMIN_TRUSTED_DEVICE_TTL"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: ADMIN_TRUSTED_DEVICE_TTL %q invalid: %w", v, err)
|
||||
}
|
||||
c.TrustedDeviceTTL = d
|
||||
}
|
||||
|
||||
if err := validateListen(c.Listen); err != nil {
|
||||
|
||||
@@ -61,9 +61,15 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
code := strings.TrimSpace(r.PostFormValue("totp"))
|
||||
ip := hostOnly(r.RemoteAddr)
|
||||
remember := r.PostFormValue("remember") != ""
|
||||
ip := realIP(r) // 经本机 caddy 反代时取 XFF 末跳,否则 TCP 对端(见 mw_ipallow.go)
|
||||
|
||||
sid, _, err := h.auth.Login(r.Context(), username, password, code, ip)
|
||||
var trustToken string
|
||||
if c, cerr := r.Cookie(TrustedDeviceCookieName); cerr == nil {
|
||||
trustToken = c.Value
|
||||
}
|
||||
|
||||
res, err := h.auth.LoginDevice(r.Context(), username, password, code, ip, trustToken, remember)
|
||||
if err != nil {
|
||||
flash := "用户名、密码或动态验证码有误"
|
||||
if err == ErrLockedOut {
|
||||
@@ -73,7 +79,14 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
h.render.render(w, "login", pageData{Flash: flash})
|
||||
return
|
||||
}
|
||||
h.setSessionCookie(w, sid)
|
||||
if res.Persistent {
|
||||
h.setSessionCookieTTL(w, res.SID, h.cfg.TrustedDeviceTTL)
|
||||
} else {
|
||||
h.setSessionCookie(w, res.SID)
|
||||
}
|
||||
if res.NewTrustToken != "" {
|
||||
h.setTrustedCookie(w, res.NewTrustToken)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -394,6 +407,14 @@ func (h *Handlers) writeAudit(ctx context.Context, actor, action, target, metaJS
|
||||
}
|
||||
|
||||
func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) {
|
||||
h.setSessionCookieTTL(w, sid, h.cfg.SessionTTL)
|
||||
}
|
||||
|
||||
// setSessionCookieTTL sets the session cookie with an explicit Max-Age. For a
|
||||
// persistent ("记住此设备") session ttl is the long trust-TTL; otherwise the
|
||||
// short idle default. Max-Age <= 0 would make it a session cookie, so ttl must
|
||||
// be positive here.
|
||||
func (h *Handlers) setSessionCookieTTL(w http.ResponseWriter, sid string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: sid,
|
||||
@@ -401,7 +422,21 @@ func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) {
|
||||
HttpOnly: true,
|
||||
Secure: h.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.SessionTTL.Seconds()),
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// setTrustedCookie stores the device-trust token (HttpOnly, Secure, Strict) so
|
||||
// this device can skip TOTP on future logins for the trust TTL.
|
||||
func (h *Handlers) setTrustedCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: TrustedDeviceCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: h.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.TrustedDeviceTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wangjia/pangolin/server/internal/totp"
|
||||
)
|
||||
|
||||
// newTrustEnv wires a full admin router with device-trust ENABLED and seeds
|
||||
// one admin, returning the router and that admin's TOTP secret.
|
||||
func newTrustEnv(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allow, _ := ParseCIDRs([]string{"127.0.0.0/8"})
|
||||
cfg := &Config{
|
||||
Listen: "127.0.0.1:9443", AllowCIDRs: allow, SecretKey: key,
|
||||
SessionTTL: 30 * time.Minute, LoginFailMax: 3, LoginLockDuration: time.Minute,
|
||||
CookieSecure: false, TrustedDeviceTTL: 30 * 24 * time.Hour,
|
||||
}
|
||||
store := newFakeStore()
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
|
||||
sessions := NewSessionStore(rdb, cfg.SessionTTL)
|
||||
sec := NewSecurityLog(store, nil)
|
||||
auth := NewAuthenticator(store, sessions, rdb, cfg, sec)
|
||||
svc := Services{Codes: &fakeCodes{}, Lifecycle: &recordingLifecycle{ready: true}, Provision: &recordingProvision{ready: true}}
|
||||
h, err := NewHandlers(cfg, store, sessions, auth, svc, sec, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewRouter(h, sessions, cfg, sec), secret
|
||||
}
|
||||
|
||||
func cookieByName(resp *http.Response, name string) *http.Cookie {
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postLogin(t *testing.T, router http.Handler, form url.Values, cookies ...*http.Cookie) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.RemoteAddr = "127.0.0.1:5000"
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr.Result()
|
||||
}
|
||||
|
||||
// 端到端:勾选记住 → 登录成功 → 同时下发会话 cookie 与信任 cookie;
|
||||
// 随后仅凭信任 cookie + 空 TOTP 再次登录成功(免二次验证)。
|
||||
func TestLoginHandler_RememberThenSkipTOTP(t *testing.T) {
|
||||
router, secret := newTrustEnv(t)
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
|
||||
resp := postLogin(t, router, url.Values{
|
||||
"username": {"alice"}, "password": {"s3cret-pass"},
|
||||
"totp": {code}, "remember": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("remember login: status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
trust := cookieByName(resp, TrustedDeviceCookieName)
|
||||
if trust == nil || trust.Value == "" {
|
||||
t.Fatal("remember login should set a non-empty trusted cookie")
|
||||
}
|
||||
if trust.MaxAge <= 0 {
|
||||
t.Errorf("trusted cookie MaxAge = %d, want > 0 (persistent)", trust.MaxAge)
|
||||
}
|
||||
sessCookie := cookieByName(resp, SessionCookieName)
|
||||
if sessCookie == nil || sessCookie.MaxAge <= int((30 * time.Minute).Seconds()) {
|
||||
t.Error("remember login should set a long-lived (persistent) session cookie")
|
||||
}
|
||||
|
||||
// 第二次:只带信任 cookie,TOTP 留空 → 成功
|
||||
resp2 := postLogin(t, router, url.Values{
|
||||
"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""},
|
||||
}, &http.Cookie{Name: TrustedDeviceCookieName, Value: trust.Value})
|
||||
if resp2.StatusCode != http.StatusFound {
|
||||
t.Fatalf("trusted re-login: status = %d, want 302 (TOTP skipped)", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 无信任 cookie + 空 TOTP → 401(二次验证仍强制)。
|
||||
func TestLoginHandler_NoTrustEmptyTOTPRejected(t *testing.T) {
|
||||
router, _ := newTrustEnv(t)
|
||||
resp := postLogin(t, router, url.Values{
|
||||
"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""},
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("empty TOTP without trust: status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/totp"
|
||||
)
|
||||
|
||||
// newTrustAuth builds an Authenticator with device-trust ENABLED (30d).
|
||||
func newTrustAuth(t *testing.T) (*Authenticator, *fakeStore, []byte) {
|
||||
t.Helper()
|
||||
rdb, _ := newTestRedis(t)
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := newFakeStore()
|
||||
cfg := &Config{
|
||||
SecretKey: key, LoginFailMax: 3, LoginLockDuration: time.Minute,
|
||||
SessionTTL: 30 * time.Minute, TrustedDeviceTTL: 30 * 24 * time.Hour,
|
||||
}
|
||||
sessions := NewSessionStore(rdb, cfg.SessionTTL)
|
||||
sec := NewSecurityLog(store, nil)
|
||||
return NewAuthenticator(store, sessions, rdb, cfg, sec), store, key
|
||||
}
|
||||
|
||||
// 勾选「记住此设备」成功登录 → 返回可用于下次跳过 TOTP 的信任令牌。
|
||||
func TestLoginDevice_RememberIssuesTrust(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
ctx := context.Background()
|
||||
|
||||
res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if res.NewTrustToken == "" {
|
||||
t.Fatal("remember=true should issue a trust token")
|
||||
}
|
||||
if !res.Persistent {
|
||||
t.Error("remember=true should mark session persistent")
|
||||
}
|
||||
// 下次:带该令牌 + 空 TOTP 也能登录(跳过二次验证)
|
||||
res2, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", res.NewTrustToken, false)
|
||||
if err != nil {
|
||||
t.Fatalf("trusted re-login should skip TOTP: %v", err)
|
||||
}
|
||||
if res2.SID == "" {
|
||||
t.Fatal("no session on trusted re-login")
|
||||
}
|
||||
}
|
||||
|
||||
// 不勾选:不签发令牌,且 TOTP 仍必填。
|
||||
func TestLoginDevice_NoRememberRequiresTOTP(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
ctx := context.Background()
|
||||
|
||||
res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if res.NewTrustToken != "" {
|
||||
t.Error("remember=false must not issue a trust token")
|
||||
}
|
||||
// 无令牌 + 空 TOTP → 拒
|
||||
if _, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", "", false); err != ErrInvalidCredentials {
|
||||
t.Errorf("untrusted device with empty TOTP should fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 铁律:即便持有效信任令牌,密码错误一律拒(只跳过 TOTP,不跳过密码)。
|
||||
func TestLoginDevice_TrustNeverSkipsPassword(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
ctx := context.Background()
|
||||
|
||||
res, _ := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true)
|
||||
if res.NewTrustToken == "" {
|
||||
t.Fatal("precondition: expected trust token")
|
||||
}
|
||||
if _, err := auth.LoginDevice(ctx, "alice", "WRONG", "", "127.0.0.1", res.NewTrustToken, false); err != ErrInvalidCredentials {
|
||||
t.Errorf("trusted device must still require correct password, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 信任令牌绑定 admin:换个用户名不认(令牌属 alice,拿去登 bob 无效)。
|
||||
func TestLoginDevice_TrustBoundToAdmin(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
sa := newTestAdmin(t, store, key, "alice", "alice-pass")
|
||||
_ = sa
|
||||
// bob:另一个 admin,不同 ID
|
||||
hash, _ := HashPassword("bob-pass")
|
||||
bsecret, _ := totp.GenerateSecret()
|
||||
benc, _ := EncryptSecret(key, bsecret)
|
||||
store.admins["bob"] = &Admin{ID: 2, Username: "bob", PwHash: hash, TOTPSecretEnc: benc, Status: "active"}
|
||||
ctx := context.Background()
|
||||
|
||||
acode, _ := totp.Code(sa, time.Now().UTC())
|
||||
ares, _ := auth.LoginDevice(ctx, "alice", "alice-pass", acode, "127.0.0.1", "", true)
|
||||
if ares.NewTrustToken == "" {
|
||||
t.Fatal("precondition: alice trust token")
|
||||
}
|
||||
// 用 alice 的令牌 + 空 TOTP 登 bob → 应要求 TOTP(令牌对 bob 无效)
|
||||
if _, err := auth.LoginDevice(ctx, "bob", "bob-pass", "", "127.0.0.1", ares.NewTrustToken, false); err != ErrInvalidCredentials {
|
||||
t.Errorf("alice's trust token must not skip TOTP for bob, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IPAllow is middleware that rejects any request whose source IP is not within
|
||||
@@ -58,3 +59,28 @@ func hostOnly(remoteAddr string) string {
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// realIP returns the client IP for **audit logging** (admin_login_ok 等)。
|
||||
//
|
||||
// 与上面 IPAllow 的取址原则刻意不同:白名单闸继续只看 TCP 对端(不可伪造);
|
||||
// 而审计日志要的是"人从哪来"——经本机反代(caddy mTLS 网关在同机回环上反代
|
||||
// 127.0.0.1:9444)时 RemoteAddr 恒为 loopback,没有溯源价值。仅当 TCP 对端是
|
||||
// loopback(即请求确实来自本机可信反代)才信 X-Forwarded-For,且取**最后一跳**
|
||||
// (Caddy 把真实 TCP 对端追加在末位;更早的段可被客户端伪造预置,不可信)。
|
||||
func realIP(r *http.Request) string {
|
||||
peer := hostOnly(r.RemoteAddr)
|
||||
ip := net.ParseIP(peer)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return peer
|
||||
}
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff == "" {
|
||||
return peer
|
||||
}
|
||||
parts := strings.Split(xff, ",")
|
||||
last := strings.TrimSpace(parts[len(parts)-1])
|
||||
if net.ParseIP(last) == nil {
|
||||
return peer
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realIP:仅当 TCP 对端为 loopback(本机可信反代,如 caddy mTLS 网关)时才信
|
||||
// X-Forwarded-For 的最后一跳;其余情况一律用 TCP 对端,防止伪造头污染审计。
|
||||
func TestRealIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
want string
|
||||
}{
|
||||
{"直连无代理", "203.0.113.7:52011", "", "203.0.113.7"},
|
||||
{"直连时伪造 XFF 不可信", "203.0.113.7:52011", "1.2.3.4", "203.0.113.7"},
|
||||
{"经本机反代取 XFF 最后一跳", "127.0.0.1:38200", "198.51.100.23", "198.51.100.23"},
|
||||
{"多跳取最后一跳(前段可伪造)", "127.0.0.1:38200", "6.6.6.6, 198.51.100.23", "198.51.100.23"},
|
||||
{"本机反代但无 XFF 回退 loopback", "127.0.0.1:38200", "", "127.0.0.1"},
|
||||
{"XFF 非法值回退", "127.0.0.1:38200", "not-an-ip", "127.0.0.1"},
|
||||
{"IPv6 loopback 反代", "[::1]:38200", "198.51.100.23", "198.51.100.23"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/login", nil)
|
||||
r.RemoteAddr = c.remoteAddr
|
||||
if c.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", c.xff)
|
||||
}
|
||||
if got := realIP(r); got != c.want {
|
||||
t.Errorf("realIP(remote=%s, xff=%q) = %q, want %q", c.remoteAddr, c.xff, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type Session struct {
|
||||
Username string `json:"username"`
|
||||
CSRFToken string `json:"csrf"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// TTLSeconds, when > 0, overrides the store's default sliding idle TTL for
|
||||
// this session (used by "记住此设备" persistent sessions). 0 = use default.
|
||||
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// SessionStore persists admin sessions in Redis with a sliding idle TTL.
|
||||
@@ -41,9 +44,15 @@ func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore {
|
||||
return &SessionStore{rdb: rdb, ttl: ttl}
|
||||
}
|
||||
|
||||
// Create starts a new session for the given admin and returns the opaque
|
||||
// session id (to be set as the cookie value).
|
||||
// Create starts a new session with the store's default sliding idle TTL.
|
||||
func (s *SessionStore) Create(ctx context.Context, adminID int64, username string) (string, *Session, error) {
|
||||
return s.CreateWithTTL(ctx, adminID, username, 0)
|
||||
}
|
||||
|
||||
// CreateWithTTL starts a new session. When ttl > 0 the session uses that TTL
|
||||
// (both the initial expiry and the per-request slide) instead of the store
|
||||
// default — this is how "记住此设备" keeps a device logged in for e.g. 30 days.
|
||||
func (s *SessionStore) CreateWithTTL(ctx context.Context, adminID int64, username string, ttl time.Duration) (string, *Session, error) {
|
||||
sid, err := randToken(32)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
@@ -58,12 +67,24 @@ func (s *SessionStore) Create(ctx context.Context, adminID int64, username strin
|
||||
CSRFToken: csrf,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if ttl > 0 {
|
||||
sess.TTLSeconds = int64(ttl / time.Second)
|
||||
}
|
||||
if err := s.save(ctx, sid, sess); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return sid, sess, nil
|
||||
}
|
||||
|
||||
// slideTTL is the TTL to (re)apply to a session: its own override if set,
|
||||
// otherwise the store default.
|
||||
func (s *SessionStore) slideTTL(sess *Session) time.Duration {
|
||||
if sess != nil && sess.TTLSeconds > 0 {
|
||||
return time.Duration(sess.TTLSeconds) * time.Second
|
||||
}
|
||||
return s.ttl
|
||||
}
|
||||
|
||||
// Get loads a session and slides its TTL forward. Returns (nil, nil) when the
|
||||
// session is absent or expired.
|
||||
func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) {
|
||||
@@ -81,8 +102,8 @@ func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) {
|
||||
if err := json.Unmarshal(val, &sess); err != nil {
|
||||
return nil, fmt.Errorf("admin.SessionStore.Get unmarshal: %w", err)
|
||||
}
|
||||
// Slide the idle timeout.
|
||||
if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.ttl).Err(); err != nil {
|
||||
// Slide the idle timeout (persistent sessions slide by their own TTL).
|
||||
if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.slideTTL(&sess)).Err(); err != nil {
|
||||
return nil, fmt.Errorf("admin.SessionStore.Get expire: %w", err)
|
||||
}
|
||||
return &sess, nil
|
||||
@@ -101,7 +122,7 @@ func (s *SessionStore) save(ctx context.Context, sid string, sess *Session) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin.SessionStore.save: %w", err)
|
||||
}
|
||||
if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.ttl).Err(); err != nil {
|
||||
if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.slideTTL(sess)).Err(); err != nil {
|
||||
return fmt.Errorf("admin.SessionStore.save set: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
{{if .Flash}}<div class="error">{{.Flash}}</div>{{end}}
|
||||
<label>用户名<input type="text" name="username" autocomplete="username" required autofocus></label>
|
||||
<label>密码<input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6" required></label>
|
||||
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6"></label>
|
||||
<label class="checkbox"><input type="checkbox" name="remember" value="1"> 记住此设备(30 天内免动态码、保持登录)</label>
|
||||
<button type="submit">登录</button>
|
||||
<p class="hint">仅限内网 / SSH 隧道访问。</p>
|
||||
<p class="hint">仅限白名单设备(客户端证书)访问。已记住的设备可留空动态码。</p>
|
||||
</form>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// TrustedDeviceCookieName is the cookie holding a device-trust token.
|
||||
const TrustedDeviceCookieName = "admin_trusted"
|
||||
|
||||
// trustedKeyPrefix namespaces device-trust tokens in Redis.
|
||||
const trustedKeyPrefix = "admin:trusted:"
|
||||
|
||||
// TrustedStore records "记住此设备" device-trust tokens in Redis.
|
||||
//
|
||||
// A trusted token lets a device SKIP THE TOTP SECOND FACTOR on re-login
|
||||
// (password is still always required). Each token is opaque (32 bytes of
|
||||
// entropy), bound to one admin id, and expires after ttl — so a flushed
|
||||
// Redis, an expired token, or a mismatched admin all fail closed to
|
||||
// "TOTP required".
|
||||
type TrustedStore struct {
|
||||
rdb *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewTrustedStore wires a TrustedStore. A ttl <= 0 disables the feature:
|
||||
// Issue returns an empty token and Check always returns false.
|
||||
func NewTrustedStore(rdb *redis.Client, ttl time.Duration) *TrustedStore {
|
||||
return &TrustedStore{rdb: rdb, ttl: ttl}
|
||||
}
|
||||
|
||||
// Enabled reports whether device-trust is active.
|
||||
func (s *TrustedStore) Enabled() bool { return s != nil && s.rdb != nil && s.ttl > 0 }
|
||||
|
||||
// Issue mints a new trust token bound to adminID and stores it with the TTL.
|
||||
// Returns "" (no error) when the feature is disabled.
|
||||
func (s *TrustedStore) Issue(ctx context.Context, adminID int64) (string, error) {
|
||||
if !s.Enabled() {
|
||||
return "", nil
|
||||
}
|
||||
tok, err := randToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.rdb.Set(ctx, trustedKeyPrefix+tok, strconv.FormatInt(adminID, 10), s.ttl).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Check reports whether tok is a live trust token bound to adminID.
|
||||
// Any miss (disabled, empty, unknown, expired, wrong admin) returns false.
|
||||
func (s *TrustedStore) Check(ctx context.Context, tok string, adminID int64) bool {
|
||||
if !s.Enabled() || tok == "" {
|
||||
return false
|
||||
}
|
||||
v, err := s.rdb.Get(ctx, trustedKeyPrefix+tok).Result()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return v == strconv.FormatInt(adminID, 10)
|
||||
}
|
||||
|
||||
// Revoke deletes a trust token (e.g. on explicit logout-all). A no-op when
|
||||
// disabled or empty.
|
||||
func (s *TrustedStore) Revoke(ctx context.Context, tok string) error {
|
||||
if !s.Enabled() || tok == "" {
|
||||
return nil
|
||||
}
|
||||
return s.rdb.Del(ctx, trustedKeyPrefix+tok).Err()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func newTestTrustedStore(t *testing.T) (*TrustedStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis: %v", err)
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
return NewTrustedStore(rdb, 30*24*time.Hour), mr
|
||||
}
|
||||
|
||||
func TestTrustedStore_IssueThenCheck(t *testing.T) {
|
||||
ts, _ := newTestTrustedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok, err := ts.Issue(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
if tok == "" {
|
||||
t.Fatal("Issue returned empty token")
|
||||
}
|
||||
// 正确 admin 命中
|
||||
if !ts.Check(ctx, tok, 7) {
|
||||
t.Error("Check should accept the token for the issuing admin")
|
||||
}
|
||||
// 令牌绑定 admin:换个 admin id 不认
|
||||
if ts.Check(ctx, tok, 8) {
|
||||
t.Error("Check must reject a token bound to a different admin")
|
||||
}
|
||||
// 未知令牌不认
|
||||
if ts.Check(ctx, "bogus-token", 7) {
|
||||
t.Error("Check must reject an unknown token")
|
||||
}
|
||||
// 空令牌不认
|
||||
if ts.Check(ctx, "", 7) {
|
||||
t.Error("Check must reject an empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedStore_Revoke(t *testing.T) {
|
||||
ts, _ := newTestTrustedStore(t)
|
||||
ctx := context.Background()
|
||||
tok, _ := ts.Issue(ctx, 7)
|
||||
if !ts.Check(ctx, tok, 7) {
|
||||
t.Fatal("precondition: token should be valid")
|
||||
}
|
||||
if err := ts.Revoke(ctx, tok); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
if ts.Check(ctx, tok, 7) {
|
||||
t.Error("Check must reject a revoked token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedStore_Expires(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis: %v", err)
|
||||
}
|
||||
defer mr.Close()
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
ts := NewTrustedStore(rdb, time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
tok, _ := ts.Issue(ctx, 7)
|
||||
if !ts.Check(ctx, tok, 7) {
|
||||
t.Fatal("precondition: token valid before expiry")
|
||||
}
|
||||
mr.FastForward(2 * time.Hour) // 超过 TTL
|
||||
if ts.Check(ctx, tok, 7) {
|
||||
t.Error("Check must reject an expired token (fail-closed)")
|
||||
}
|
||||
}
|
||||
|
||||
// TTL<=0 视为功能关闭:Issue 返回空、Check 恒 false。
|
||||
func TestTrustedStore_Disabled(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis: %v", err)
|
||||
}
|
||||
defer mr.Close()
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
ts := NewTrustedStore(rdb, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
tok, err := ts.Issue(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
if tok != "" {
|
||||
t.Error("disabled store should issue empty token")
|
||||
}
|
||||
if ts.Check(ctx, "anything", 7) {
|
||||
t.Error("disabled store should never trust")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user