feat(auth): implement user ban detection and optional auth path handling
All checks were successful
Frontend CI / ota (android) (push) Successful in 3m6s
Frontend CI / ota (ios) (push) Successful in 2m34s
Frontend CI / build-and-push-web (push) Successful in 6m19s
Frontend CI / build-android-apk (push) Successful in 45m8s

Add USER_BANNED (403) handling across auth flow:
- Extend FetchCurrentUserResult and RefreshResult with 'banned' type
- Detect USER_BANNED errorCode in authService and api client
- Clear token and show "账号已被封禁,请联系管理员" on ban detection
- Throw non-network refresh errors up to caller instead of silent clear

Add optional auth path support:
- Extract optional auth path prefixes to optionalAuthPaths.ts
- Handle 401 on optional auth GET endpoints by clearing expired token
  and retrying as guest (without triggering navigateToLogin)
- Export helpers for existing consumers

Handle backend behavior change:
- Guest logout now returns 401 instead of 200; treat as silent success

Add tests for optional auth path logic.
This commit is contained in:
lafay
2026-07-05 20:18:35 +08:00
parent 45df579b72
commit 995e0664d5
6 changed files with 385 additions and 12 deletions

View File

@@ -11,6 +11,7 @@ import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO }
* 当前用户获取结果。
* - user成功拿到用户信息
* - auth_failed后端明确拒绝401 / token 失效)→ 应登出
* - banned后端返回 403 USER_BANNED用户被封禁→ 提示"账号已被封禁"
* - network_error网络故障未到达后端→ 不应登出,应保留登录态等待重试
*
* 引入该类型是为了修复"断网即被登出"的问题:
@@ -20,6 +21,7 @@ import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO }
export type FetchCurrentUserResult =
| { kind: 'user'; user: User }
| { kind: 'auth_failed' }
| { kind: 'banned' }
| { kind: 'network_error' };
export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string {
@@ -235,11 +237,20 @@ class AuthService {
}
// 用户登出(只管 API + TokenDB/缓存清理交给 authStore
//
// 后端行为变化:游客调用 /auth/logout 现在返回 401之前返回 200
// 客户端策略401 视为正常路径(用户已处于登出状态),静默吞掉,
// 避免在 finally 清 token 之外产生误导性的错误日志。
async logout(): Promise<void> {
try {
await api.post('/auth/logout');
} catch (error) {
console.error('[AuthService] 登出 API 失败:', error);
} catch (error: any) {
const code = error?.code ?? 0;
if (code === 401) {
// 游客调用登出 → 401属正常情况用户已是登出状态静默处理
} else {
console.error('[AuthService] 登出 API 失败:', error);
}
} finally {
await api.clearToken();
}
@@ -262,6 +273,12 @@ class AuthService {
console.warn('[AuthService] fetchCurrentUserFromAPI 网络失败(保留登录态):', error);
return { kind: 'network_error' };
}
// 用户被封禁:后端返回 403 + error_code=USER_BANNED。
// 单独返回 'banned',避免与"token 失效"混淆(提示文案不同)。
const errorCodeStr = String(error?.errorCode ?? '').toUpperCase();
if (errorCodeStr === 'USER_BANNED') {
return { kind: 'banned' };
}
// 后端明确拒绝401 等):认证失败
const code = error?.code ?? 0;
const isAuthError =
@@ -279,6 +296,11 @@ class AuthService {
}
// 刷新Token
//
// 注意:后端 rotate 后 refresh_token 是一次性使用的,
// 成功响应中后端必然返回新的 refresh_token这里会存新的。
// 网络故障时保留旧 token 不清空,等网络恢复后重试。
// 非网络错误(如 USER_BANNED、token 失效)向上抛出,由调用方决策。
async refreshToken(): Promise<RefreshTokenResponse | null> {
try {
const refreshToken = await api.getRefreshToken();
@@ -296,6 +318,8 @@ class AuthService {
if (response.data.token) {
await api.setToken(response.data.token);
}
// refresh_token rotate后端返回新的 refresh_token 时存新的;
// 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。
const newRefreshToken = response.data.refresh_token || response.data.refreshToken;
if (newRefreshToken) {
await api.setRefreshToken(newRefreshToken);
@@ -308,9 +332,10 @@ class AuthService {
console.warn('[AuthService] 刷新Token网络失败保留登录态:', error);
return null;
}
console.error('刷新Token失败:', error);
await api.clearToken();
return null;
// 非网络错误USER_BANNED、token 失效等):向上抛出,
// 由调用方根据 errorCode 决定提示文案与是否登出。
console.error('[AuthService] 刷新Token失败:', error);
throw error;
}
}

View File

@@ -0,0 +1,218 @@
/**
* OptionalAuth 路径白名单与 401 重试策略单测
*
* 后端 OptionalAuth 中间件行为变化:携带过期 token 不再静默降级为游客,
* 而是返回 401。前端用此白名单识别这些"对游客开放"的接口,
* 在 401 时清掉本地过期 token 并以游客身份重试一次。
*
* 策略:仅 GET 请求应用 OptionalAuth 重试(避免误伤 RequireAuth 写接口)。
*/
import {
isOptionalAuthPath,
shouldRetryAsGuestOn401,
OPTIONAL_AUTH_PATH_PREFIXES,
} from '../optionalAuthPaths';
describe('isOptionalAuthPath', () => {
describe('应识别为 OptionalAuth 路径(返回 true', () => {
const optionalAuthPaths = [
// /posts 全系列
'/posts',
'/posts?page=1',
'/posts/search',
'/posts/suggest',
'/posts/abc123',
'/posts/abc123/view',
'/posts/abc123/share',
'/posts/abc123/related',
'/posts/abc123/refs',
'/posts/abc123/ref-click',
'/posts/abc123/vote',
// /comments
'/comments/post/abc',
'/comments/post/abc/cursor',
'/comments/abc123',
'/comments/abc123/replies',
'/comments/abc123/replies/flat',
// /users/search必须先于 /users/ 通配命中)
'/users/search',
'/users/search?keyword=foo',
// /users/:id 系列OptionalAuth
'/users/abc123',
'/users/abc123/posts',
'/users/abc123/favorites',
// /trade
'/trade',
'/trade?trade_type=sale',
'/trade/abc123',
'/trade/abc123/view',
];
for (const p of optionalAuthPaths) {
it(`返回 true${p}`, () => {
expect(isOptionalAuthPath(p)).toBe(true);
});
}
});
describe('不应识别为 OptionalAuth 路径(返回 false', () => {
const nonOptionalPaths = [
// /users/me* 是 RequireAuth必须排除
'/users/me',
'/users/me/',
'/users/me/email/verify',
'/users/me/email/send-code',
'/users/me/privacy',
'/users/me/deactivate',
'/users/me/deletion-status',
// /auth/* 是公开路径(无需 token
'/auth/login',
'/auth/register',
'/auth/refresh',
'/auth/logout',
'/auth/password/reset',
'/auth/check-username',
'/auth/qrcode',
// /uploads/* 是 RequireAuth
'/uploads/files',
'/uploads/images',
// 其他 RequireAuth 接口
'/notifications',
'/conversations',
];
for (const p of nonOptionalPaths) {
it(`返回 false${p}`, () => {
expect(isOptionalAuthPath(p)).toBe(false);
});
}
});
describe('边界情况', () => {
it('空字符串返回 false', () => {
expect(isOptionalAuthPath('')).toBe(false);
});
it('仅前缀斜杠返回 false', () => {
expect(isOptionalAuthPath('/')).toBe(false);
});
it('/users/me 前缀的精确匹配排除(不误伤 /users/mexico', () => {
// /users/mexico 不应被 /users/me 排除规则误伤,但也不在白名单 → false
expect(isOptionalAuthPath('/users/mexico')).toBe(false);
});
it('/users/search 必须命中(即使 /users/ 也在白名单中)', () => {
expect(isOptionalAuthPath('/users/search')).toBe(true);
});
});
describe('OPTIONAL_AUTH_PATH_PREFIXES 完整性', () => {
it('白名单包含全部 5 个前缀', () => {
expect(OPTIONAL_AUTH_PATH_PREFIXES).toEqual([
'/posts',
'/comments',
'/users/search',
'/users/',
'/trade',
]);
});
it('/users/search 排在 /users/ 之前(优先级,避免被通配吞掉)', () => {
const searchIdx = OPTIONAL_AUTH_PATH_PREFIXES.indexOf('/users/search');
const usersIdx = OPTIONAL_AUTH_PATH_PREFIXES.indexOf('/users/');
expect(searchIdx).toBeGreaterThanOrEqual(0);
expect(usersIdx).toBeGreaterThanOrEqual(0);
expect(searchIdx).toBeLessThan(usersIdx);
});
});
});
describe('shouldRetryAsGuestOn401', () => {
describe('GET 请求OptionalAuth 路径应允许游客重试', () => {
const getOptionalPaths = [
['GET', '/posts'],
['GET', '/posts/search'],
['GET', '/posts/abc123'],
['GET', '/posts/abc123/related'],
['GET', '/posts/abc123/vote'],
['GET', '/comments/post/abc'],
['GET', '/comments/abc123/replies'],
['GET', '/users/search'],
['GET', '/users/abc123'],
['GET', '/users/abc123/posts'],
['GET', '/trade'],
['GET', '/trade/abc123'],
] as const;
for (const [method, path] of getOptionalPaths) {
it(`返回 true${method} ${path}`, () => {
expect(shouldRetryAsGuestOn401(method, path)).toBe(true);
});
}
});
describe('GET 请求RequireAuth/公开路径不应游客重试', () => {
const getNonOptionalPaths = [
['GET', '/users/me'],
['GET', '/users/me/email/verify'],
['GET', '/auth/login'],
['GET', '/notifications'],
['GET', '/conversations'],
] as const;
for (const [method, path] of getNonOptionalPaths) {
it(`返回 false${method} ${path}`, () => {
expect(shouldRetryAsGuestOn401(method, path)).toBe(false);
});
}
});
describe('写请求POST/PUT/DELETE一律不游客重试', () => {
// 即便 path 命中 OptionalAuth 白名单,写接口也走 RequireAuth refresh 流程,
// 避免把"本可 refresh 续期"的用户误判为登出。
const writeRequests = [
// OptionalAuth 写接口(合法但策略上仍走 refresh
['POST', '/posts/abc123/view'],
['POST', '/posts/abc123/share'],
['POST', '/posts/abc123/ref-click'],
['POST', '/trade/abc123/view'],
// RequireAuth 写接口(必须走 refresh
['POST', '/posts'],
['POST', '/posts/abc123/like'],
['DELETE', '/posts/abc123/like'],
['POST', '/posts/abc123/favorite'],
['DELETE', '/posts/abc123/favorite'],
['POST', '/posts/vote'],
['POST', '/posts/abc123/vote'],
['DELETE', '/posts/abc123/vote'],
['POST', '/trade'],
['PUT', '/trade/abc123'],
['DELETE', '/trade/abc123'],
['POST', '/trade/abc123/favorite'],
['DELETE', '/trade/abc123/favorite'],
// 其他 RequireAuth 写接口
['POST', '/auth/logout'],
['POST', '/users/me/deactivate'],
['PUT', '/users/me/privacy'],
] as const;
for (const [method, path] of writeRequests) {
it(`返回 false${method} ${path}`, () => {
expect(shouldRetryAsGuestOn401(method, path)).toBe(false);
});
}
});
describe('大小写与方法名归一化', () => {
it('小写方法名也能识别', () => {
expect(shouldRetryAsGuestOn401('get', '/posts')).toBe(true);
expect(shouldRetryAsGuestOn401('Get', '/posts')).toBe(true);
});
it('空方法返回 false', () => {
expect(shouldRetryAsGuestOn401('', '/posts')).toBe(false);
});
});
});

View File

@@ -12,6 +12,14 @@ import { Platform } from 'react-native';
import { eventBus } from '@/core/events/EventBus';
import { showVerificationModal } from './verification';
import {
OPTIONAL_AUTH_PATH_PREFIXES,
isOptionalAuthPath,
shouldRetryAsGuestOn401,
} from './optionalAuthPaths';
// 重新导出,便于现有从 '@/services/core/api' 导入的调用方使用
export { OPTIONAL_AUTH_PATH_PREFIXES, isOptionalAuthPath, shouldRetryAsGuestOn401 };
// 生产地址 https://withyou.littlelan.cn
const getBaseUrl = () => {
@@ -76,7 +84,10 @@ interface JwtPayload {
// - 'ok' 刷新成功
// - 'auth_failed' 后端明确拒绝refresh token 失效)→ 可安全登出
// - 'network_error' 网络故障(未收到后端响应)→ 不应登出
type RefreshResult = 'ok' | 'auth_failed' | 'network_error';
// - 'banned' 用户被封禁(后端返回 403 + USER_BANNED→ 提示"账号已被封禁"
type RefreshResult = 'ok' | 'auth_failed' | 'network_error' | 'banned';
// OptionalAuth 路径白名单与判断函数见 ./optionalAuthPaths保持纯函数便于单测
// 判断是否为网络错误fetch 未收到后端响应)。
// 与"后端明确拒绝401 等)"区分开,避免网络故障被误判为账号退出。
@@ -238,6 +249,12 @@ class ApiClient {
this.navigateToLogin();
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
}
if (refreshResult === 'banned') {
// 用户被封禁:清登录态并跳登录页,提示"账号已被封禁"
await this.clearToken();
this.navigateToLogin();
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
}
// 'network_error':网络故障,保留登录态,沿用旧 token 继续请求
// (旧 token 可能仍有效;若已过期,后端会返回 401届时再走重试逻辑
const newToken = await this.getToken();
@@ -265,6 +282,18 @@ class ApiClient {
// 处理 401 未授权
if (response.status === 401) {
// OptionalAuth 接口(仅 GET后端检测到携带了过期/失效 token 就会返回 401
// (不再静默降级为游客)。客户端清掉本地过期 token以游客身份重试一次。
// 这是可选认证场景,不应触发 navigateToLogin —— 用户可能是游客或 token 自然过期。
//
// 仅 GET写接口POST/PUT/DELETE的前缀与 RequireAuth 写接口完全重叠,
// 仅靠 path 无法区分,故写接口统一走下方 RequireAuth 的 refresh 重试流程,
// 避免把"本可 refresh 续期"的用户误判为登出。
if (shouldRetryAsGuestOn401(method, path) && _retryCount === 0) {
await this.clearToken();
return this.request(method, path, params, body, _retryCount + 1);
}
if (_retryCount >= 1) {
// 已重试过仍 401 → 后端明确拒绝该账号 → 登出
await this.clearToken();
@@ -278,6 +307,12 @@ class ApiClient {
this.navigateToLogin();
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
}
if (refreshResult === 'banned') {
// 用户被封禁:清登录态并跳登录页,提示"账号已被封禁"
await this.clearToken();
this.navigateToLogin();
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
}
if (refreshResult === 'network_error') {
// 刷新请求网络失败:此时无法判断 401 是真失效还是旧 token 临时过期,
// 保留登录态,向上抛出可重试的网络错误,避免误判登出
@@ -382,13 +417,20 @@ class ApiClient {
});
// 后端明确拒绝refresh token 失效/黑名单)→ 认证失败
// 但需先判断是否为用户被封禁403 + error_code=USER_BANNED
// 单独返回 'banned',让上层给出"账号已被封禁"提示而非通用"登录已过期"。
if (!response.ok) {
if (await this.isBannedResponse(response)) {
return 'banned';
}
return 'auth_failed';
}
const data = await response.json();
if (data.code === 0 && data.data?.token) {
await this.setToken(data.data.token);
// refresh token rotate后端返回新的 refresh_token 时存新的;
// 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。
if (data.data.refresh_token || data.data.refreshToken) {
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
}
@@ -396,7 +438,11 @@ class ApiClient {
return 'ok';
}
// 业务码非 0后端明确返回失败 → 认证失败
// 业务码非 0同样需先排除 USER_BANNED
if (this.isBannedErrorCode(data)) {
return 'banned';
}
// 后端明确返回失败 → 认证失败
return 'auth_failed';
} catch (error) {
// fetch 抛异常DNS 失败、断网、超时)→ 网络故障,不应登出
@@ -409,6 +455,24 @@ class ApiClient {
}
}
// 判断 /auth/refresh 的失败响应是否为"用户被封禁"403 + error_code=USER_BANNED
// 已 clone response避免消耗原始 body stream。
private async isBannedResponse(response: Response): Promise<boolean> {
try {
const body = await response.json();
return this.isBannedErrorCode(body);
} catch {
return false;
}
}
// 判断响应体是否携带 USER_BANNED 错误码。
private isBannedErrorCode(body: any): boolean {
if (!body || typeof body !== 'object') return false;
const code = String(body.error_code ?? body.errorCode ?? '').toUpperCase();
return code === 'USER_BANNED';
}
// 冷启动/刷新token后自动注册设备方便存量设备接入
private registerDeviceOnRefresh(): void {
(async () => {
@@ -493,6 +557,12 @@ class ApiClient {
this.navigateToLogin();
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
}
if (refreshResult === 'banned') {
// 用户被封禁:清登录态并跳登录页
await this.clearToken();
this.navigateToLogin();
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
}
// 'ok' 或 'network_error'尝试用可能已刷新的token 继续
const newToken = await this.getToken();
if (newToken) {

View File

@@ -52,6 +52,10 @@ function classifyError(error: unknown): AppErrorCode {
if (isNetworkError(error)) return AppErrorCode.NETWORK_ERROR;
if (error instanceof ApiError) {
if (error.errorCode === 'AUTH_ERROR' || error.code === 401) return AppErrorCode.AUTH_ERROR;
// USER_BANNED 优先识别:后端封禁用户的 errorCode
// 复用 FORBIDDEN 分类(语义相近),具体中文提示由调用方按 errorCode 给出。
// (不在此处新增 BANNED 枚举值,保持改动面最小。)
if (error.errorCode === 'USER_BANNED') return AppErrorCode.FORBIDDEN;
if (error.errorCode === 'FORBIDDEN' || error.code === 403) return AppErrorCode.FORBIDDEN;
if (error.errorCode === 'NOT_FOUND' || error.code === 404) return AppErrorCode.NOT_FOUND;
if (error.errorCode === 'VERIFICATION_REQUIRED') return AppErrorCode.VALIDATION_ERROR;

View File

@@ -0,0 +1,44 @@
/**
* OptionalAuth 中间件路径白名单(纯数据,无副作用,便于单测)。
*
* 与后端 router.go 中走 optionalAuth 中间件的路由保持同步。
* 这些接口对游客开放,但若客户端携带了过期/失效 token后端会返回 401
* 不再静默降级为游客。客户端策略401 时清掉本地过期 token并以游客身份重试一次。
*
* 注意:/users/me 及其子路径(/users/me/email/verify 等)是 RequireAuth必须排除。
*/
// OptionalAuth 中间件覆盖的路径前缀。
export const OPTIONAL_AUTH_PATH_PREFIXES = [
'/posts', // GET /posts, /posts/:id, /posts/search, /posts/suggest, /posts/:id/view, /posts/:id/share, /posts/:id/related, /posts/:id/refs, /posts/:id/ref-click, /posts/:id/vote
'/comments', // GET /comments/...
'/users/search', // 必须放在 /users/ 之前,避免 /users/me 被通配吞掉
'/users/', // GET /users/:id、/users/:id/posts、/users/:id/favorites
'/trade', // GET /trade, /trade/:id, POST /trade/:id/view
];
// 判断请求路径是否走 OptionalAuth 中间件(对游客开放、但携带过期 token 会被拒)。
//
// /users/me* 是 RequireAuth/users/me、/users/me/email/verify、/users/me/privacy 等),
// 必须排除,避免把强制认证接口误判为可选认证。
export function isOptionalAuthPath(path: string): boolean {
// /users/me* 是 RequireAuth必须排除
if (path.startsWith('/users/me')) return false;
return OPTIONAL_AUTH_PATH_PREFIXES.some(p => path.startsWith(p));
}
// 判断"携带过期 token 触发 401 时,能否按 OptionalAuth 语义以游客身份重试"。
//
// 策略(保守,避免误伤 RequireAuth 接口):
// - 仅对 GET 请求应用 OptionalAuth 重试。
// - 这些前缀下的 POST/PUT/DELETE 既有 OptionalAuth 接口(如 /posts/:id/view、
// /posts/:id/share、/trade/:id/view也有 RequireAuth 接口(如 /posts/:id/like、
// /posts/:id/favorite、/trade/:id/favorite。两者前缀完全重叠仅靠 path 无法区分。
// - 若对 RequireAuth 写接口误用 OptionalAuth 重试,会把"本可 refresh 续期成功"的
// 用户误判为登出(清 token → 游客重试仍 401 → 登出)。为避免该回归,
// POST/PUT/DELETE 一律走 RequireAuth 的 refresh 重试流程(即便个别 OptionalAuth
// POST 接口因此多走一次 refresh也只影响性能不影响正确性
export function shouldRetryAsGuestOn401(method: string, path: string): boolean {
if (method.toUpperCase() !== 'GET') return false;
return isOptionalAuthPath(path);
}

View File

@@ -332,8 +332,9 @@ export const useAuthStore = create<AuthState>()(
// 3. 纯 API 调用验证 Token 有效性fetchCurrentUserFromAPI 完全不碰 DB
// 返回判别类型:
// - 'user' 成功,继续登录流程
// - 'auth_failed' 后端明确拒绝401→ 清除登录态
// - 'user' 成功,继续登录流程
// - 'auth_failed' 后端明确拒绝401→ 清除登录态
// - 'banned' 用户被封禁403 USER_BANNED→ 清除登录态 + 提示"账号已被封禁"
// - 'network_error' 网络故障 → 保留旧登录态(持久化的 isAuthenticated
// 等待网络恢复后重试,避免断网即被登出
const result = await authService.fetchCurrentUserFromAPI();
@@ -384,6 +385,17 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: '网络连接失败,已使用本地登录态',
});
} else if (result.kind === 'banned') {
// 用户被封禁:后端返回 403 USER_BANNED。
// 清除登录态 + 提示"账号已被封禁,请联系管理员"。
await clearUserId();
useSessionStore.getState().setUserId(null);
set({
isAuthenticated: false,
currentUser: null,
isLoading: false,
error: '账号已被封禁,请联系管理员',
});
} else {
// auth_failed后端明确拒绝Token 真失效 → 清除登录态
await clearUserId();