name: rn-auth description: React Native 认证模式,适用于 Expo 应用。在实现登录流程、Google/Apple 登录、令牌管理、会话处理或调试 Expo/React Native 中的认证问题时使用。
React Native 认证 (Expo)
核心模式
Expo AuthSession 用于 OAuth
使用 expo-auth-session 和 expo-web-browser 进行 OAuth 流程:
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import * as Google from 'expo-auth-session/providers/google';
// 重要:在模块级别调用此函数以正确处理重定向
WebBrowser.maybeCompleteAuthSession();
// 在组件内部
const [request, response, promptAsync] = Google.useAuthRequest({
iosClientId: 'YOUR_IOS_CLIENT_ID.apps.googleusercontent.com',
webClientId: 'YOUR_WEB_CLIENT_ID.apps.googleusercontent.com', // 用于后端验证
scopes: ['profile', 'email'],
});
常见陷阱
- 缺少
maybeCompleteAuthSession()- 没有这个,认证重定向会默默失败 - 错误的客户端 ID - iOS 需要 iOS 客户端 ID,但后端验证需要 web 客户端 ID
- 方案不匹配 -
app.json方案必须与 Google Cloud Console 重定向 URI 匹配 - Expo Go 与独立应用 - 使用不同的重定向 URI;使用
AuthSession.makeRedirectUri()来处理两者
令牌存储
使用 expo-secure-store 存储令牌(而不是 AsyncStorage):
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'auth_token';
const REFRESH_KEY = 'refresh_token';
export const tokenStorage = {
async save(token: string, refresh?: string) {
await SecureStore.setItemAsync(TOKEN_KEY, token);
if (refresh) {
await SecureStore.setItemAsync(REFRESH_KEY, refresh);
}
},
async get() {
return SecureStore.getItemAsync(TOKEN_KEY);
},
async getRefresh() {
return SecureStore.getItemAsync(REFRESH_KEY);
},
async clear() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(REFRESH_KEY);
},
};
认证上下文模式
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
type AuthState = {
token: string | null;
user: User | null;
isLoading: boolean;
signIn: (token: string, user: User) => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 在挂载时恢复会话
async function restore() {
try {
const savedToken = await tokenStorage.get();
if (savedToken) {
// 在信任令牌之前先验证后端令牌
const userData = await validateToken(savedToken);
setToken(savedToken);
setUser(userData);
}
} catch {
await tokenStorage.clear();
} finally {
setIsLoading(false);
}
}
restore();
}, []);
const signIn = async (newToken: string, userData: User) => {
await tokenStorage.save(newToken);
setToken(newToken);
setUser(userData);
};
const signOut = async () => {
await tokenStorage.clear();
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ token, user, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
return ctx;
};
用 Expo Router 保护路由
// app/_layout.tsx
import { Slot, useRouter, useSegments } from 'expo-router';
import { useAuth } from '@/contexts/auth';
import { useEffect } from 'react';
export default function RootLayout() {
const { token, isLoading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!token && !inAuthGroup) {
router.replace('/(auth)/login');
} else if (token && inAuthGroup) {
router.replace('/(app)/home');
}
}, [token, isLoading, segments]);
if (isLoading) {
return <LoadingScreen />;
}
return <Slot />;
}
后端集成
发送认证头
// api/client.ts
import { tokenStorage } from '@/utils/tokenStorage';
const API_BASE = process.env.EXPO_PUBLIC_API_URL;
async function authFetch(path: string, options: RequestInit = {}) {
const token = await tokenStorage.get();
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
if (response.status === 401) {
// 令牌过期 - 尝试刷新或强制注销
const refreshed = await attemptTokenRefresh();
if (!refreshed) {
await tokenStorage.clear();
// 触发认证状态更新(发出事件或使用回调)
}
}
return response;
}
Google 令牌验证(FastAPI 后端)
# 供参考:后端应这样验证 Google 令牌
from google.oauth2 import id_token
from google.auth.transport import requests
def verify_google_token(token: str, client_id: str) -> dict:
"""验证 Google ID 令牌并返回用户信息。"""
idinfo = id_token.verify_oauth2_token(
token,
requests.Request(),
client_id # 这里使用 WEB 客户端 ID,不是 iOS
)
return {
"google_id": idinfo["sub"],
"email": idinfo["email"],
"name": idinfo.get("name"),
}
调试认证问题
检查重定向 URI 配置
// 记录使用的重定向 URI
console.log('Redirect URI:', AuthSession.makeRedirectUri());
与以下配置进行比较:
- Google Cloud Console > Credentials > OAuth 2.0 Client IDs
app.json方案字段
常见错误模式
| 错误 | 可能的原因 |
|---|---|
| “redirect_uri_mismatch” | 控制台中的重定向 URI 与应用不匹配 |
| 认证弹出窗口打开但无反应 | 缺少 maybeCompleteAuthSession() |
| 在 Expo Go 中工作,在构建中失败 | 在独立配置中使用 Expo Go 重定向 URI |
| 后端令牌验证失败 | 使用 iOS 客户端 ID 而不是 web 客户端 ID 进行验证 |
测试认证流程
- 清除所有令牌:
await tokenStorage.clear() - 强制关闭应用
- 重新打开并验证重定向到登录
- 完成登录流程
- 强制关闭并重新打开 - 应该保持登录状态