feat: 认证失败时停止重连并提示用户
密码错误、账户禁用、令牌过期或限流等永久性认证错误不再无限 重连,改为置 StateError 并通过 EvError 弹窗告知用户具体原因 (中英文本地化)。网络错误仍正常指数退避重试。 - protocol: 新增 AuthErrorCode 类型与消息映射 - transport: AuthError/ServerError 携带 Code 字段 - vpn: run() 循环识别致命认证错误并停止,新增 onError 回调 - ipc/daemon/ui: 打通错误码到 UI 的本地化展示
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -53,6 +53,12 @@ DlgVPNError = "VPN 错误"
|
||||
DlgSaveError = "保存错误"
|
||||
DlgKeychainError = "钥匙串错误"
|
||||
DlgError = "错误"
|
||||
DlgAuthError = "认证失败"
|
||||
AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。"
|
||||
AuthErrUserDisabled = "用户不存在或已被禁用。"
|
||||
AuthErrTokenInvalid = "认证令牌无效或已过期。"
|
||||
AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。"
|
||||
AuthErrMalformed = "认证消息格式错误。"
|
||||
|
||||
BtnResetDB = "重置数据库"
|
||||
DlgResetDBTitle = "重置数据库"
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user