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
|
s := snap
|
||||||
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s})
|
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 {
|
if err := d.session.Connect(ctx, cfg); err != nil {
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ DlgVPNError = "VPN Error"
|
|||||||
DlgSaveError = "Save Error"
|
DlgSaveError = "Save Error"
|
||||||
DlgKeychainError = "Keychain Error"
|
DlgKeychainError = "Keychain Error"
|
||||||
DlgError = "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"
|
BtnResetDB = "Reset Database"
|
||||||
DlgResetDBTitle = "Reset Database"
|
DlgResetDBTitle = "Reset Database"
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ DlgVPNError = "VPN 错误"
|
|||||||
DlgSaveError = "保存错误"
|
DlgSaveError = "保存错误"
|
||||||
DlgKeychainError = "钥匙串错误"
|
DlgKeychainError = "钥匙串错误"
|
||||||
DlgError = "错误"
|
DlgError = "错误"
|
||||||
|
DlgAuthError = "认证失败"
|
||||||
|
AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。"
|
||||||
|
AuthErrUserDisabled = "用户不存在或已被禁用。"
|
||||||
|
AuthErrTokenInvalid = "认证令牌无效或已过期。"
|
||||||
|
AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。"
|
||||||
|
AuthErrMalformed = "认证消息格式错误。"
|
||||||
|
|
||||||
BtnResetDB = "重置数据库"
|
BtnResetDB = "重置数据库"
|
||||||
DlgResetDBTitle = "重置数据库"
|
DlgResetDBTitle = "重置数据库"
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type Event struct {
|
|||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
Stats *stats.Snapshot `json:"stats,omitempty"`
|
Stats *stats.Snapshot `json:"stats,omitempty"`
|
||||||
|
Code string `json:"code,omitempty"` // stable auth-error code for EvError
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,3 +69,39 @@ type AuthResponse struct {
|
|||||||
func IsError(msgType string) bool {
|
func IsError(msgType string) bool {
|
||||||
return msgType == TypeAuthErr || msgType == TypeError
|
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)
|
return fmt.Errorf("parse auth response: %w", err)
|
||||||
}
|
}
|
||||||
if resp.Type == protocol.TypeAuthErr {
|
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 {
|
if resp.Type != protocol.TypeAuthOK {
|
||||||
return fmt.Errorf("unexpected auth response type: %s", resp.Type)
|
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)
|
return protocol.InitMessage{}, fmt.Errorf("parse init/error: %w (raw: %s)", err, data)
|
||||||
}
|
}
|
||||||
if ctrl.Type == protocol.TypeError || ctrl.Type == protocol.TypeAuthErr {
|
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)
|
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).
|
// 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 }
|
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 ServerError struct {
|
||||||
Type string
|
Type string
|
||||||
Message string
|
Message string
|
||||||
|
Code protocol.AuthErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ServerError) Error() string {
|
func (e *ServerError) Error() string {
|
||||||
|
|||||||
+23
-2
@@ -241,14 +241,35 @@ func (a *App) eventLoop() {
|
|||||||
}
|
}
|
||||||
case ipc.EvError:
|
case ipc.EvError:
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if ev.Message != "" {
|
msg := authErrorMessage(ev.Code, ev.Message)
|
||||||
showError(i18n.T("DlgVPNError"), ev.Message, a.window)
|
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.
|
// applyState updates UI elements for a state change.
|
||||||
func (a *App) applyState(state string) {
|
func (a *App) applyState(state string) {
|
||||||
switch stats.State(state) {
|
switch stats.State(state) {
|
||||||
|
|||||||
+64
-3
@@ -12,6 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ type SessionManager struct {
|
|||||||
stats *stats.Stats
|
stats *stats.Stats
|
||||||
onState func(stats.State)
|
onState func(stats.State)
|
||||||
onStats func(stats.Snapshot)
|
onStats func(stats.Snapshot)
|
||||||
|
onError func(code string, msg string)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
@@ -67,12 +69,15 @@ type SessionManager struct {
|
|||||||
|
|
||||||
// New creates a SessionManager. The onState callback (if non-nil) is
|
// New creates a SessionManager. The onState callback (if non-nil) is
|
||||||
// invoked on every state transition. The onStats callback (if non-nil)
|
// invoked on every state transition. The onStats callback (if non-nil)
|
||||||
// is invoked periodically while connected.
|
// is invoked periodically while connected. The onError callback (if
|
||||||
func New(onState func(stats.State), onStats func(stats.Snapshot)) *SessionManager {
|
// 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{
|
return &SessionManager{
|
||||||
stats: stats.New(),
|
stats: stats.New(),
|
||||||
onState: onState,
|
onState: onState,
|
||||||
onStats: onStats,
|
onStats: onStats,
|
||||||
|
onError: onError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +131,12 @@ func (sm *SessionManager) Disconnect() {
|
|||||||
// run is the main session loop with exponential-backoff reconnection
|
// run is the main session loop with exponential-backoff reconnection
|
||||||
// and CDN IP failover.
|
// and CDN IP failover.
|
||||||
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
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
|
backoff := time.Second
|
||||||
maxBackoff := 60 * time.Second
|
maxBackoff := 60 * time.Second
|
||||||
@@ -153,6 +163,22 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.L().Error("VPN connection failed", "error", err)
|
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)
|
sm.setState(stats.StateReconnecting)
|
||||||
|
|
||||||
// Try next CDN IP immediately.
|
// 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,
|
// connectOnce performs a single connection lifecycle: authenticate,
|
||||||
// handshake, configure TUN, apply routes, pump packets until failure.
|
// handshake, configure TUN, apply routes, pump packets until failure.
|
||||||
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {
|
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user