Лекция 8. Аутентификация и защита маршрутов
Введение
Большинство реальных мобильных приложений делят функциональность на две части: публичную (экран входа, регистрация, восстановление пароля) и защищённую (личный кабинет, лента, настройки). Чтобы попасть в защищённую часть, пользователь должен пройти аутентификацию — доказать, что он тот, за кого себя выдаёт.
В этой лекции мы разберём полный цикл аутентификации в Expo/React Native:
- базовый флоу: экран входа → запрос к API → получение токена → хранение → переход на защищённые экраны;
- безопасное хранение токена (
expo-secure-storeвместоAsyncStorage); - обновление токена (refresh flow);
- глобальное состояние авторизации через
AuthContextи три статуса: «загрузка / гость / авторизован»; - защиту маршрутов (route guards) через разделение auth-stack и app-stack;
- логаут, обработку истёкшего токена и перехват ответов
401; - UX-детали: индикаторы загрузки и защиту от двойных нажатий.
1. Базовый флоу аутентификации
Классический сценарий входа по логину и паролю выглядит так:
- Пользователь вводит учётные данные на экране входа.
- Приложение отправляет
POST-запрос на эндпоинт/login. - Сервер проверяет данные и возвращает токен (обычно JWT) и иногда refresh-токен.
- Приложение сохраняет токен в защищённом хранилище.
- Глобальное состояние переключается в статус «авторизован».
- Навигатор автоматически показывает защищённые экраны.
Ключевая идея: сам факт наличия валидного токена и определяет, какие экраны
доступны пользователю. Мы не вызываем navigation.navigate вручную после
входа — вместо этого меняем состояние авторизации, а навигатор перестраивается
сам (об этом в разделе 5).
Что такое токен
После успешного входа сервер выдаёт строку-токен. Чаще всего это JWT (JSON Web Token) — подписанная сервером строка, содержащая идентификатор пользователя и срок действия. Клиент прикладывает токен к каждому запросу в заголовке:
Authorization: Bearer <token>Сервер проверяет подпись и срок действия токена и решает, выполнять ли запрос. Токен живёт ограниченное время (например, 15 минут) — это снижает ущерб при его утечке.
2. Безопасное хранение токена
Токен нужно сохранять между запусками приложения, иначе пользователь будет входить заново при каждом открытии. Но где его хранить — вопрос безопасности.
AsyncStorage не подходит для секретов
AsyncStorage хранит данные в открытом виде (на Android — в простом файле базы
данных). Любой, кто получил доступ к файловой системе устройства (особенно на
устройстве с root/jailbreak), сможет прочитать токен. Поэтому секреты
(токены, ключи) нельзя хранить в AsyncStorage.
SecureStore — хранилище для секретов
expo-secure-store использует системные защищённые хранилища: Keychain на
iOS и Keystore/EncryptedSharedPreferences на Android. Данные шифруются
операционной системой.
npx expo install expo-secure-storeimport * as SecureStore from 'expo-secure-store';
const ACCESS_KEY = 'access_token';const REFRESH_KEY = 'refresh_token';
export async function saveTokens({ accessToken, refreshToken }) { await SecureStore.setItemAsync(ACCESS_KEY, accessToken); if (refreshToken) { await SecureStore.setItemAsync(REFRESH_KEY, refreshToken); }}
export async function getAccessToken() { return SecureStore.getItemAsync(ACCESS_KEY);}
export async function getRefreshToken() { return SecureStore.getItemAsync(REFRESH_KEY);}
export async function clearTokens() { await SecureStore.deleteItemAsync(ACCESS_KEY); await SecureStore.deleteItemAsync(REFRESH_KEY);}Примечание:
SecureStoreподходит для небольших значений (несколько КБ). Объёмные несекретные данные (кэш списков, настройки интерфейса) по-прежнему удобно держать вAsyncStorage.
3. Обновление токена (refresh flow)
Access-токен живёт недолго. Чтобы пользователь не вводил пароль каждые 15 минут, сервер вместе с access-токеном выдаёт refresh-токен с большим сроком жизни (дни/недели). Когда access-токен истекает, клиент обменивает refresh-токен на новую пару токенов.
import { getRefreshToken, saveTokens, clearTokens } from '../storage/token';
const API_URL = 'https://api.example.com';
export async function refreshAccessToken() { const refreshToken = await getRefreshToken(); if (!refreshToken) throw new Error('NO_REFRESH_TOKEN');
const response = await fetch(`${API_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), });
if (!response.ok) { await clearTokens(); // refresh недействителен — выходим throw new Error('REFRESH_FAILED'); }
const data = await response.json(); await saveTokens(data); // сохраняем новую пару return data.accessToken;}Если обновление не удалось (refresh-токен тоже истёк или отозван) — токены очищаются, и пользователь возвращается на экран входа.
4. AuthContext: глобальное состояние авторизации
Статус авторизации нужен всему приложению: навигатору — чтобы выбрать стек,
экранам — чтобы показать данные пользователя, кнопке «Выйти» — чтобы сбросить
сессию. Это классическое глобальное состояние, и его удобно держать в
Context.
Важно выделять три состояния, а не два:
loading— приложение ещё проверяет, есть ли сохранённый токен (первый запуск);guest— токена нет, пользователь не авторизован;authenticated— токен есть и валиден.
Без статуса loading при старте приложения на доли секунды покажется экран
входа, даже если пользователь уже авторизован — это заметный дефект UX.
import { createContext, useContext, useEffect, useMemo, useState } from 'react';import { getAccessToken, saveTokens, clearTokens } from '../storage/token';
const AuthContext = createContext(null);
export function AuthProvider({ children }) { const [token, setToken] = useState(null); const [status, setStatus] = useState('loading'); // loading | guest | authenticated
// При старте приложения проверяем сохранённый токен useEffect(() => { (async () => { const saved = await getAccessToken(); if (saved) { setToken(saved); setStatus('authenticated'); } else { setStatus('guest'); } })(); }, []);
async function signIn({ email, password }) { const response = await fetch('https://api.example.com/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!response.ok) throw new Error('INVALID_CREDENTIALS');
const data = await response.json(); // { accessToken, refreshToken } await saveTokens(data); setToken(data.accessToken); setStatus('authenticated'); }
async function signOut() { await clearTokens(); setToken(null); setStatus('guest'); }
const value = useMemo( () => ({ token, status, signIn, signOut }), [token, status] );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;}
export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth должен использоваться внутри AuthProvider'); return ctx;}5. Защита маршрутов (route guards)
Главный принцип защиты маршрутов в React Native: условный рендеринг навигатора. Мы не «закрываем» отдельные экраны проверками внутри них, а показываем целиком разный набор экранов в зависимости от статуса.
Разделяем граф навигации на два стека:
- auth-stack — публичные экраны (Login, Register, ForgotPassword);
- app-stack — защищённые экраны (Home, Profile, Settings).
import { NavigationContainer } from '@react-navigation/native';import { createNativeStackNavigator } from '@react-navigation/native-stack';import { ActivityIndicator, View } from 'react-native';
import { AuthProvider, useAuth } from './src/state/AuthContext';import LoginScreen from './src/screens/LoginScreen';import HomeScreen from './src/screens/HomeScreen';import ProfileScreen from './src/screens/ProfileScreen';
const Stack = createNativeStackNavigator();
function AuthStack() { return ( <Stack.Navigator> <Stack.Screen name="Login" component={LoginScreen} /> </Stack.Navigator> );}
function AppStack() { return ( <Stack.Navigator> <Stack.Screen name="Home" component={HomeScreen} /> <Stack.Screen name="Profile" component={ProfileScreen} /> </Stack.Navigator> );}
function RootNavigator() { const { status } = useAuth();
if (status === 'loading') { return ( <View style={{ flex: 1, justifyContent: 'center' }}> <ActivityIndicator size="large" /> </View> ); }
return status === 'authenticated' ? <AppStack /> : <AuthStack />;}
export default function App() { return ( <AuthProvider> <NavigationContainer> <RootNavigator /> </NavigationContainer> </AuthProvider> );}Преимущества такого подхода:
- защищённые экраны физически отсутствуют в дереве для гостя — на них нельзя попасть ни кнопкой «назад», ни глубокой ссылкой;
- переключение происходит автоматически при смене статуса — после
signIn()навигатор сам показываетAppStack, послеsignOut()—AuthStack; - логика входа отделена от логики навигации.
6. Перехват 401 и обработка истёкшего токена
Даже с refresh-токеном бывает ситуация: токен истёк, и сервер вернул 401 Unauthorized. Грамотный клиент должен попытаться обновить токен и повторить
запрос, а если не вышло — выполнить логаут. Удобно инкапсулировать это в обёртку
над fetch.
import { getAccessToken } from '../storage/token';import { refreshAccessToken } from './auth';
const API_URL = 'https://api.example.com';
export async function apiFetch(path, options = {}, onAuthFail) { const token = await getAccessToken();
const doRequest = (accessToken) => fetch(`${API_URL}${path}`, { ...options, headers: { ...options.headers, 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, });
let response = await doRequest(token);
// Токен истёк — пробуем обновить и повторить один раз if (response.status === 401) { try { const newToken = await refreshAccessToken(); response = await doRequest(newToken); } catch (err) { onAuthFail?.(); // обновить не удалось — принудительный логаут throw new Error('SESSION_EXPIRED'); } }
if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json();}onAuthFail обычно связывают с методом signOut() из AuthContext: когда
сессия окончательно недействительна, приложение само возвращает пользователя на
экран входа.
7. UX-моменты
Аутентификация — это первый экран, который видит пользователь, и здесь важны детали взаимодействия.
Индикатор загрузки
Любой сетевой запрос требует времени. Пока идёт вход, кнопка должна показывать загрузку, а интерфейс — блокировать повторный ввод.
Защита от двойных нажатий
Если пользователь нажмёт «Войти» дважды, уйдут два запроса на вход. Чтобы этого
избежать, блокируем кнопку на время запроса флагом submitting.
import { useState } from 'react';import { View, TextInput, Button, Text, ActivityIndicator } from 'react-native';import { useAuth } from '../state/AuthContext';
export default function LoginScreen() { const { signIn } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null);
async function onSubmit() { if (submitting) return; // защита от двойного нажатия setSubmitting(true); setError(null); try { await signIn({ email, password }); // переход на AppStack произойдёт автоматически } catch (e) { setError('Неверный логин или пароль'); } finally { setSubmitting(false); } }
return ( <View style={{ padding: 16 }}> <TextInput placeholder="Email" value={email} onChangeText={setEmail} autoCapitalize="none" keyboardType="email-address" /> <TextInput placeholder="Пароль" value={password} onChangeText={setPassword} secureTextEntry /> {error && <Text style={{ color: 'red' }}>{error}</Text>} {submitting ? ( <ActivityIndicator /> ) : ( <Button title="Войти" onPress={onSubmit} disabled={submitting} /> )} </View> );}Дополнительные рекомендации по UX и безопасности:
- не сохранять пароль, только токены;
- использовать
secureTextEntryдля поля пароля; - показывать понятные сообщения об ошибках, не раскрывая деталей сервера;
- всегда работать по HTTPS, при повышенных требованиях — TLS-пиннинг;
- кнопку «Выйти» сопровождать подтверждением, чтобы избежать случайного логаута.
Краткие итоги
- Базовый флоу: экран входа → запрос к API → получение токена → безопасное хранение → переключение состояния авторизации → защищённые экраны.
- Секреты (токены) хранят в
expo-secure-store(Keychain/Keystore), а не вAsyncStorage, который держит данные открыто. - Короткоживущий access-токен обновляют по refresh-токену; если refresh недействителен — пользователь возвращается на вход.
- Глобальное состояние авторизации удобно держать в
AuthContextс тремя статусами:loading,guest,authenticated. - Защита маршрутов реализуется условным рендерингом навигатора: разделяем auth-stack и app-stack и показываем нужный по статусу — защищённые экраны физически отсутствуют для гостя.
- Ответ
401перехватываем в обёртке надfetch: пробуем обновить токен и повторить запрос, при неудаче — принудительный логаут. - UX: индикаторы загрузки, блокировка кнопки от двойных нажатий, понятные ошибки, HTTPS.
Вопросы для самопроверки
- Как организовать базовый флоу аутентификации в мобильном приложении? Опишите процесс от экрана входа до защищённых экранов.
- Как реализовать защиту маршрутов (route guards) для аутентифицированных пользователей?