ReactNative认证(Expo) rn-auth

本文介绍了在 Expo 应用中实现 React Native 认证的模式,包括 OAuth 流程、令牌存储、认证上下文、保护路由和后端集成。

移动开发 0 次安装 0 次浏览 更新于 3/3/2026

name: rn-auth description: React Native 认证模式,适用于 Expo 应用。在实现登录流程、Google/Apple 登录、令牌管理、会话处理或调试 Expo/React Native 中的认证问题时使用。

React Native 认证 (Expo)

核心模式

Expo AuthSession 用于 OAuth

使用 expo-auth-sessionexpo-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'],
});

常见陷阱

  1. 缺少 maybeCompleteAuthSession() - 没有这个,认证重定向会默默失败
  2. 错误的客户端 ID - iOS 需要 iOS 客户端 ID,但后端验证需要 web 客户端 ID
  3. 方案不匹配 - app.json 方案必须与 Google Cloud Console 重定向 URI 匹配
  4. 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 进行验证

测试认证流程

  1. 清除所有令牌:await tokenStorage.clear()
  2. 强制关闭应用
  3. 重新打开并验证重定向到登录
  4. 完成登录流程
  5. 强制关闭并重新打开 - 应该保持登录状态