feat: 认证失败时停止重连并提示用户

密码错误、账户禁用、令牌过期或限流等永久性认证错误不再无限
重连,改为置 StateError 并通过 EvError 弹窗告知用户具体原因
(中英文本地化)。网络错误仍正常指数退避重试。

- protocol: 新增 AuthErrorCode 类型与消息映射
- transport: AuthError/ServerError 携带 Code 字段
- vpn: run() 循环识别致命认证错误并停止,新增 onError 回调
- ipc/daemon/ui: 打通错误码到 UI 的本地化展示
This commit is contained in:
2026-07-07 14:37:26 +08:00
parent 6daa73fdb0
commit 56102589f6
8 changed files with 150 additions and 8 deletions
+3
View File
@@ -153,6 +153,9 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
s := snap
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s})
},
func(code string, msg string) {
d.server.Broadcast(ipc.Event{Event: ipc.EvError, Code: code, Message: msg})
},
)
if err := d.session.Connect(ctx, cfg); err != nil {
+6
View File
@@ -53,6 +53,12 @@ DlgVPNError = "VPN Error"
DlgSaveError = "Save Error"
DlgKeychainError = "Keychain Error"
DlgError = "Error"
DlgAuthError = "Authentication Failed"
AuthErrWrongCredentials = "Wrong username or password. Please check your credentials."
AuthErrUserDisabled = "This user account does not exist or has been disabled."
AuthErrTokenInvalid = "The authentication token is invalid or expired."
AuthErrRateLimited = "Too many authentication attempts. Please try again later."
AuthErrMalformed = "Authentication message format error."
BtnResetDB = "Reset Database"
DlgResetDBTitle = "Reset Database"
+6
View File
@@ -53,6 +53,12 @@ DlgVPNError = "VPN 错误"
DlgSaveError = "保存错误"
DlgKeychainError = "钥匙串错误"
DlgError = "错误"
DlgAuthError = "认证失败"
AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。"
AuthErrUserDisabled = "用户不存在或已被禁用。"
AuthErrTokenInvalid = "认证令牌无效或已过期。"
AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。"
AuthErrMalformed = "认证消息格式错误。"
BtnResetDB = "重置数据库"
DlgResetDBTitle = "重置数据库"
+1
View File
@@ -66,6 +66,7 @@ type Event struct {
Event string `json:"event"`
State string `json:"state,omitempty"`
Stats *stats.Snapshot `json:"stats,omitempty"`
Code string `json:"code,omitempty"` // stable auth-error code for EvError
Message string `json:"message,omitempty"`
}
+36
View File
@@ -69,3 +69,39 @@ type AuthResponse struct {
func IsError(msgType string) bool {
return msgType == TypeAuthErr || msgType == TypeError
}
// AuthErrorCode is a stable, locale-independent identifier for a fatal
// authentication failure. It is derived from the server's auth_err
// message (or HTTP status for the JWT login path) and carried over IPC
// to the GUI, which maps it to a localized user-facing string.
type AuthErrorCode string
const (
AuthCodeWrongCredentials AuthErrorCode = "wrong_credentials" // 用户名或密码错误 / HTTP 401,403
AuthCodeUserDisabled AuthErrorCode = "user_disabled" // 用户不存在或已禁用
AuthCodeTokenInvalid AuthErrorCode = "token_invalid" // 令牌无效或已过期
AuthCodeRateLimited AuthErrorCode = "rate_limited" // 认证尝试过于频繁 / HTTP 429
AuthCodeMalformed AuthErrorCode = "malformed" // 消息格式错误
)
// AuthErrorCodeFromMessage maps a server auth_err message string to a
// stable AuthErrorCode. It returns the empty string for an unrecognized
// message, in which case the caller should treat the failure as
// non-categorical (and typically still fatal, since the server closes
// the connection after auth_err).
func AuthErrorCodeFromMessage(msg string) AuthErrorCode {
switch msg {
case "用户名或密码错误":
return AuthCodeWrongCredentials
case "用户不存在或已禁用":
return AuthCodeUserDisabled
case "令牌无效或已过期":
return AuthCodeTokenInvalid
case "认证尝试过于频繁,请稍后再试":
return AuthCodeRateLimited
case "消息格式错误":
return AuthCodeMalformed
default:
return ""
}
}
+11 -3
View File
@@ -158,7 +158,7 @@ func (c *Conn) passwordAuth(username, password string) error {
return fmt.Errorf("parse auth response: %w", err)
}
if resp.Type == protocol.TypeAuthErr {
return &AuthError{Message: resp.Message}
return &AuthError{Message: resp.Message, Code: protocol.AuthErrorCodeFromMessage(resp.Message)}
}
if resp.Type != protocol.TypeAuthOK {
return fmt.Errorf("unexpected auth response type: %s", resp.Type)
@@ -186,7 +186,11 @@ func (c *Conn) readInit() (protocol.InitMessage, error) {
return protocol.InitMessage{}, fmt.Errorf("parse init/error: %w (raw: %s)", err, data)
}
if ctrl.Type == protocol.TypeError || ctrl.Type == protocol.TypeAuthErr {
return protocol.InitMessage{}, &ServerError{Type: ctrl.Type, Message: ctrl.Message}
return protocol.InitMessage{}, &ServerError{
Type: ctrl.Type,
Message: ctrl.Message,
Code: protocol.AuthErrorCodeFromMessage(ctrl.Message),
}
}
return protocol.InitMessage{}, fmt.Errorf("unexpected message type: %s", ctrl.Type)
}
@@ -254,7 +258,10 @@ func (c *Conn) Close() error {
}
// AuthError indicates authentication failure (auth_err from server).
type AuthError struct{ Message string }
type AuthError struct {
Message string
Code protocol.AuthErrorCode
}
func (e *AuthError) Error() string { return "auth failed: " + e.Message }
@@ -262,6 +269,7 @@ func (e *AuthError) Error() string { return "auth failed: " + e.Message }
type ServerError struct {
Type string
Message string
Code protocol.AuthErrorCode
}
func (e *ServerError) Error() string {
+23 -2
View File
@@ -241,14 +241,35 @@ func (a *App) eventLoop() {
}
case ipc.EvError:
fyne.Do(func() {
if ev.Message != "" {
showError(i18n.T("DlgVPNError"), ev.Message, a.window)
msg := authErrorMessage(ev.Code, ev.Message)
if msg != "" {
showError(i18n.T("DlgAuthError"), msg, a.window)
}
})
}
}
}
// authErrorMessage maps a stable auth-error code (carried by the EvError
// IPC event from the daemon) to a localized user-facing string. For an
// unknown or empty code it falls back to the raw server message.
func authErrorMessage(code, fallback string) string {
switch code {
case "wrong_credentials":
return i18n.T("AuthErrWrongCredentials")
case "user_disabled":
return i18n.T("AuthErrUserDisabled")
case "token_invalid":
return i18n.T("AuthErrTokenInvalid")
case "rate_limited":
return i18n.T("AuthErrRateLimited")
case "malformed":
return i18n.T("AuthErrMalformed")
default:
return fallback
}
}
// applyState updates UI elements for a state change.
func (a *App) applyState(state string) {
switch stats.State(state) {
+64 -3
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
@@ -44,6 +45,7 @@ type SessionManager struct {
stats *stats.Stats
onState func(stats.State)
onStats func(stats.Snapshot)
onError func(code string, msg string)
mu sync.Mutex
running bool
@@ -67,12 +69,15 @@ type SessionManager struct {
// New creates a SessionManager. The onState callback (if non-nil) is
// invoked on every state transition. The onStats callback (if non-nil)
// is invoked periodically while connected.
func New(onState func(stats.State), onStats func(stats.Snapshot)) *SessionManager {
// is invoked periodically while connected. The onError callback (if
// non-nil) is invoked once when a fatal, non-retryable error (such as
// an authentication failure) terminates the session.
func New(onState func(stats.State), onStats func(stats.Snapshot), onError func(string, string)) *SessionManager {
return &SessionManager{
stats: stats.New(),
onState: onState,
onStats: onStats,
onError: onError,
}
}
@@ -126,7 +131,12 @@ func (sm *SessionManager) Disconnect() {
// run is the main session loop with exponential-backoff reconnection
// and CDN IP failover.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
defer sm.setState(stats.StateDisconnected)
fatal := false
defer func() {
if !fatal {
sm.setState(stats.StateDisconnected)
}
}()
backoff := time.Second
maxBackoff := 60 * time.Second
@@ -153,6 +163,22 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil {
log.L().Error("VPN connection failed", "error", err)
// A fatal authentication failure (wrong password, disabled
// account, expired token, rate limit) is not retryable:
// stop the loop and surface the reason to the user instead
// of hammering the server forever.
if code, msg, isFatal := fatalAuthError(err); isFatal {
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
sm.setState(stats.StateError)
if sm.onError != nil {
sm.onError(string(code), msg)
}
fatal = true
sm.cleanup()
return
}
sm.setState(stats.StateReconnecting)
// Try next CDN IP immediately.
@@ -181,6 +207,41 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
}
}
// fatalAuthError inspects err and, if it represents a permanent
// authentication failure that should not be retried, returns the
// stable error code, the raw server message, and true. It recognises
// all three auth-failure shapes produced by the transport/auth layers:
// - *transport.AuthError (WebSocket auth_err at the auth stage)
// - *transport.ServerError (auth_err at the init stage, JWT path)
// - *auth.LoginError (HTTP /api/login failure, JWT path)
//
// errors.As transparently unwraps fmt.Errorf("...: %w", err) chains,
// so the wrapped LoginError returned by connectOnce is matched too.
// A non-empty code is required: an auth_err with an unrecognized
// message is treated as non-fatal so the loop falls back to retrying
// (the server still closed the connection, but we lack a categorical
// reason to give up).
func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) {
var authErr *transport.AuthError
if errors.As(err, &authErr) {
return authErr.Code, authErr.Message, authErr.Code != ""
}
var serverErr *transport.ServerError
if errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr {
return serverErr.Code, serverErr.Message, serverErr.Code != ""
}
var loginErr *auth.LoginError
if errors.As(err, &loginErr) {
switch loginErr.Code {
case http.StatusTooManyRequests:
return protocol.AuthCodeRateLimited, loginErr.Message, true
case http.StatusUnauthorized, http.StatusForbidden:
return protocol.AuthCodeWrongCredentials, loginErr.Message, true
}
}
return "", "", false
}
// connectOnce performs a single connection lifecycle: authenticate,
// handshake, configure TUN, apply routes, pump packets until failure.
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {