diff --git a/server/internal/admin/auth.go b/server/internal/admin/auth.go index b120342..6175813 100644 --- a/server/internal/admin/auth.go +++ b/server/internal/admin/auth.go @@ -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) { diff --git a/server/internal/admin/config.go b/server/internal/admin/config.go index 1c77bea..6c365b6 100644 --- a/server/internal/admin/config.go +++ b/server/internal/admin/config.go @@ -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 { diff --git a/server/internal/admin/handlers.go b/server/internal/admin/handlers.go index fc5ea30..f19722a 100644 --- a/server/internal/admin/handlers.go +++ b/server/internal/admin/handlers.go @@ -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")) + 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()), }) } diff --git a/server/internal/admin/login_device_handler_test.go b/server/internal/admin/login_device_handler_test.go new file mode 100644 index 0000000..4671426 --- /dev/null +++ b/server/internal/admin/login_device_handler_test.go @@ -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) + } +} diff --git a/server/internal/admin/login_device_test.go b/server/internal/admin/login_device_test.go new file mode 100644 index 0000000..242eca1 --- /dev/null +++ b/server/internal/admin/login_device_test.go @@ -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) + } +} diff --git a/server/internal/admin/session.go b/server/internal/admin/session.go index f75e502..13899dc 100644 --- a/server/internal/admin/session.go +++ b/server/internal/admin/session.go @@ -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 diff --git a/server/internal/admin/templates/login.html b/server/internal/admin/templates/login.html index e167976..fb4a72d 100644 --- a/server/internal/admin/templates/login.html +++ b/server/internal/admin/templates/login.html @@ -12,9 +12,10 @@ {{if .Flash}}
仅限内网 / SSH 隧道访问。
+仅限白名单设备(客户端证书)访问。已记住的设备可留空动态码。