Перейти к содержимому

Практика 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 blank
cd auth-demo
npx expo install expo-secure-store
npm install @react-navigation/native @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-context

Шаг 1. Хранилище токена (SecureStore)

Создайте src/storage/token.js — тонкую обёртку над SecureStore:

src/storage/token.js
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.

src/services/auth.js
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 меняют статус и хранилище.

src/state/AuthContext.js
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 блокирует кнопку на время запроса, а ошибку показываем пользователю понятным текстом.

src/screens/LoginScreen.js
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 с кнопкой «Выйти». Сопроводите выход подтверждением, чтобы избежать случайного логаута.

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, показываем индикатор загрузки, чтобы экран входа не «мигал» у уже авторизованного пользователя.

App.js
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 можно сэмулировать, вернув такой статус из тестового сервиса.)

src/services/apiClient.js
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. Проверка

  1. Войдите с user@example.com / 123456 — должен открыться Home.
  2. Введите неверный пароль — появится сообщение об ошибке, статус не сменится.
  3. Перезапустите приложение — токен прочитается из SecureStore, вы сразу попадёте на Home (после короткого индикатора).
  4. Нажмите «Выйти», подтвердите — вернётесь на Login.

Критерии оценки

  • 20% — SecureStore: токен сохраняется при входе, читается при старте, удаляется при выходе.
  • 20%AuthContext с тремя статусами (loading/guest/authenticated) и функциями signIn/signOut.
  • 20% — Route guards: условный рендеринг AuthStack/AppStack, защищённые экраны недоступны гостю.
  • 15% — Экран входа: имитация запроса, обработка неверных данных, понятные ошибки.
  • 10% — Индикатор загрузки при старте (нет «мигания» экрана входа).
  • 10% — Защита от двойного нажатия и кнопка выхода с подтверждением.
  • 5% — Перехват 401 с принудительным логаутом.

Вопросы для самопроверки

  1. Почему токен хранят в expo-secure-store, а не в AsyncStorage?
  2. Зачем нужен статус loading отдельно от guest? Что произойдёт без него?
  3. Почему после signIn мы не вызываем navigation.navigate, а меняем статус?
  4. В чём преимущество разделения на AuthStack/AppStack перед проверками внутри экранов?
  5. Что должен сделать клиент при получении ответа 401?

Ресурсы