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

Лекция 8. Аутентификация и защита маршрутов

Введение

Большинство реальных мобильных приложений делят функциональность на две части: публичную (экран входа, регистрация, восстановление пароля) и защищённую (личный кабинет, лента, настройки). Чтобы попасть в защищённую часть, пользователь должен пройти аутентификацию — доказать, что он тот, за кого себя выдаёт.

В этой лекции мы разберём полный цикл аутентификации в Expo/React Native:

  • базовый флоу: экран входа → запрос к API → получение токена → хранение → переход на защищённые экраны;
  • безопасное хранение токена (expo-secure-store вместо AsyncStorage);
  • обновление токена (refresh flow);
  • глобальное состояние авторизации через AuthContext и три статуса: «загрузка / гость / авторизован»;
  • защиту маршрутов (route guards) через разделение auth-stack и app-stack;
  • логаут, обработку истёкшего токена и перехват ответов 401;
  • UX-детали: индикаторы загрузки и защиту от двойных нажатий.

1. Базовый флоу аутентификации

Классический сценарий входа по логину и паролю выглядит так:

  1. Пользователь вводит учётные данные на экране входа.
  2. Приложение отправляет POST-запрос на эндпоинт /login.
  3. Сервер проверяет данные и возвращает токен (обычно JWT) и иногда refresh-токен.
  4. Приложение сохраняет токен в защищённом хранилище.
  5. Глобальное состояние переключается в статус «авторизован».
  6. Навигатор автоматически показывает защищённые экраны.

Ключевая идея: сам факт наличия валидного токена и определяет, какие экраны доступны пользователю. Мы не вызываем 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-store
src/storage/token.js
import * 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-токен на новую пару токенов.

src/services/auth.js
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.

src/state/AuthContext.js
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).
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';
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.

src/services/apiClient.js
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.

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 }}>
<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.

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

  1. Как организовать базовый флоу аутентификации в мобильном приложении? Опишите процесс от экрана входа до защищённых экранов.
  2. Как реализовать защиту маршрутов (route guards) для аутентифицированных пользователей?