Практика 8. Практическая работа 8. Аутентификация и защита маршрутов
Цели работы
- Реализовать базовый флоу аутентификации: экран входа → запрос к API → токен → переход в защищённую часть.
- Освоить безопасное хранение токена в
expo-secure-store. - Построить глобальное состояние авторизации через
AuthContextс тремя статусами. - Реализовать защиту маршрутов (route guards) условным рендерингом навигатора.
- Обработать истёкший/неверный токен и перехват ответа
401, добавить UX-детали.
Коротко о теории
В реальном приложении функциональность делится на публичную (вход, регистрация) и защищённую (личный кабинет, лента). Доступ к защищённой части открывает только валидный токен.
Ключевые идеи лекции 8:
- После входа сервер выдаёт токен (обычно JWT); клиент прикладывает его к запросам в заголовке
Authorization: Bearer <token>. - Секреты хранят в
expo-secure-store(Keychain на iOS, Keystore на Android), а не вAsyncStorage, где данные лежат открыто. - Состояние авторизации держим в
AuthContextс тремя статусами:loading(проверяем сохранённый токен),guest(токена нет),authenticated(токен валиден). - Защита маршрутов — это условный рендеринг навигатора: показываем
AuthStackилиAppStackцеликом, а не «закрываем» отдельные экраны. Защищённые экраны физически отсутствуют в дереве для гостя. - Вход не вызывает
navigation.navigateвручную — меняется статус, и навигатор перестраивается сам.
Задание
Реализуйте приложение с входом по логину/паролю, защитой маршрутов и кнопкой выхода. Сервер имитируем — реальный бэкенд не нужен.
Шаг 0. Подготовка проекта
npx create-expo-app@latest auth-demo --template blankcd auth-demonpx expo install expo-secure-storenpm install @react-navigation/native @react-navigation/native-stacknpx expo install react-native-screens react-native-safe-area-contextШаг 1. Хранилище токена (SecureStore)
Создайте src/storage/token.js — тонкую обёртку над SecureStore:
import * as SecureStore from 'expo-secure-store';
const ACCESS_KEY = 'access_token';
export async function saveToken(accessToken) { await SecureStore.setItemAsync(ACCESS_KEY, accessToken);}
export async function getToken() { return SecureStore.getItemAsync(ACCESS_KEY);}
export async function clearToken() { await SecureStore.deleteItemAsync(ACCESS_KEY);}Шаг 2. Имитация запроса к API
Создайте src/services/auth.js. Вместо реального fetch — задержка и проверка пары логин/пароль. Неверные данные дают ошибку INVALID_CREDENTIALS.
const DEMO = { email: 'user@example.com', password: '123456' };
export async function loginRequest({ email, password }) { await new Promise((r) => setTimeout(r, 800)); // имитация сети if (email !== DEMO.email || password !== DEMO.password) { throw new Error('INVALID_CREDENTIALS'); } // имитируем JWT, который «протухает» по времени return { accessToken: `demo.${Date.now()}.token` };}Шаг 3. AuthContext с тремя статусами
Создайте src/state/AuthContext.js. В useEffect при старте читаем токен из хранилища, переключая статус из loading в guest или authenticated. Функции signIn/signOut меняют статус и хранилище.
import { createContext, useContext, useEffect, useMemo, useState } from 'react';import { getToken, saveToken, clearToken } from '../storage/token';import { loginRequest } from '../services/auth';
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 getToken(); if (saved) { setToken(saved); setStatus('authenticated'); } else { setStatus('guest'); } })(); }, []);
async function signIn({ email, password }) { const { accessToken } = await loginRequest({ email, password }); await saveToken(accessToken); setToken(accessToken); setStatus('authenticated'); }
async function signOut() { await clearToken(); 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;}Шаг 4. Экран входа (защита от двойного нажатия)
Создайте src/screens/LoginScreen.js. Флаг 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, flex: 1, justifyContent: 'center' }}> <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> );}Шаг 5. Защищённый экран и выход
Создайте src/screens/HomeScreen.js с кнопкой «Выйти». Сопроводите выход подтверждением, чтобы избежать случайного логаута.
import { View, Text, Button, Alert } from 'react-native';import { useAuth } from '../state/AuthContext';
export default function HomeScreen() { const { signOut } = useAuth();
function confirmLogout() { Alert.alert('Выход', 'Выйти из аккаунта?', [ { text: 'Отмена', style: 'cancel' }, { text: 'Выйти', style: 'destructive', onPress: signOut }, ]); }
return ( <View style={{ flex: 1, justifyContent: 'center', padding: 16 }}> <Text>Защищённый экран. Вы авторизованы.</Text> <Button title="Выйти" onPress={confirmLogout} /> </View> );}Шаг 6. Условный навигатор (route guards) и индикатор старта
В App.js разделите граф на AuthStack и AppStack. Пока статус loading, показываем индикатор загрузки, чтобы экран входа не «мигал» у уже авторизованного пользователя.
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';
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.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> );}Шаг 7. Перехват 401 и обработка истёкшего токена
Добавьте обёртку над fetch для защищённых запросов. При ответе 401 принудительно выполняем логаут — пользователь возвращается на вход. (В демо 401 можно сэмулировать, вернув такой статус из тестового сервиса.)
import { getToken } from '../storage/token';
const API_URL = 'https://api.example.com';
export async function apiFetch(path, options = {}, onAuthFail) { const token = await getToken();
const response = await fetch(`${API_URL}${path}`, { ...options, headers: { ...options.headers, 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, });
if (response.status === 401) { onAuthFail?.(); // токен истёк/неверен — принудительный логаут throw new Error('SESSION_EXPIRED'); } if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json();}onAuthFail связывают с signOut() из AuthContext.
Шаг 8. Проверка
- Войдите с
user@example.com/123456— должен открытьсяHome. - Введите неверный пароль — появится сообщение об ошибке, статус не сменится.
- Перезапустите приложение — токен прочитается из
SecureStore, вы сразу попадёте наHome(после короткого индикатора). - Нажмите «Выйти», подтвердите — вернётесь на
Login.
Критерии оценки
- 20% — SecureStore: токен сохраняется при входе, читается при старте, удаляется при выходе.
- 20% —
AuthContextс тремя статусами (loading/guest/authenticated) и функциямиsignIn/signOut. - 20% — Route guards: условный рендеринг
AuthStack/AppStack, защищённые экраны недоступны гостю. - 15% — Экран входа: имитация запроса, обработка неверных данных, понятные ошибки.
- 10% — Индикатор загрузки при старте (нет «мигания» экрана входа).
- 10% — Защита от двойного нажатия и кнопка выхода с подтверждением.
- 5% — Перехват
401с принудительным логаутом.
Вопросы для самопроверки
- Почему токен хранят в
expo-secure-store, а не вAsyncStorage? - Зачем нужен статус
loadingотдельно отguest? Что произойдёт без него? - Почему после
signInмы не вызываемnavigation.navigate, а меняем статус? - В чём преимущество разделения на
AuthStack/AppStackперед проверками внутри экранов? - Что должен сделать клиент при получении ответа
401?
Ресурсы
- Лекция 8 «Аутентификация и защита маршрутов» (
lecture_08.md). - Expo SecureStore: https://docs.expo.dev/versions/latest/sdk/securestore/
- React Navigation — Authentication flows: https://reactnavigation.org/docs/auth-flow/
- React Context: https://react.dev/reference/react/createContext