Skip to content

Аутентификация в Axum

Аутентификация - критически важная часть большинства веб-приложений. Axum предоставляет гибкие механизмы для реализации различных стратегий аутентификации. В этом разделе рассмотрим основные подходы к реализации аутентификации в приложениях на базе Axum.

Содержание

Основные концепции

В Axum аутентификация обычно реализуется через:

  1. Экстракторы - для получения и валидации учетных данных из запроса
  2. Middleware - для проверки аутентификации перед обработкой запроса
  3. TypedHeader - для работы с заголовками аутентификации
  4. Кастомные типы - для представления аутентифицированного пользователя

Реализация JWT-аутентификации

JSON Web Token (JWT) - популярный метод аутентификации для REST API.

Настройка зависимостей

toml
[dependencies]
axum = "0.7.2"
jsonwebtoken = "9.1.0"
serde = { version = "1.0", features = ["derive"] }
time = "0.3"

Определение типов данных

rust
use axum::{
    async_trait,
    extract::{FromRequest, RequestParts, TypedHeader},
    headers::{authorization::Bearer, Authorization},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

// Определение структуры данных для хранения информации о пользователе в токене
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    sub: String,         // идентификатор пользователя
    exp: usize,          // timestamp истечения срока действия
    name: String,        // имя пользователя
    role: String,        // роль пользователя
}

// Структура, представляющая аутентифицированного пользователя
#[derive(Debug)]
struct AuthUser {
    user_id: String,
    name: String,
    role: String,
}

Создание экстрактора для JWT

rust
#[async_trait]
impl<B> FromRequest<B> for AuthUser
where
    B: Send,
{
    type Rejection = (StatusCode, String);

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получение заголовка авторизации
        let TypedHeader(Authorization(bearer)) = 
            TypedHeader::<Authorization<Bearer>>::from_request(req)
                .await
                .map_err(|_| {
                    (StatusCode::UNAUTHORIZED, "Отсутствует заголовок авторизации".to_string())
                })?;
                
        // Декодирование и проверка JWT
        let token_data = decode::<Claims>(
            bearer.token(),
            &DecodingKey::from_secret("мой_секретный_ключ".as_bytes()),
            &Validation::default(),
        )
        .map_err(|_| (StatusCode::UNAUTHORIZED, "Некорректный токен".to_string()))?;
        
        // Проверка срока действия токена
        let claims = token_data.claims;
        let current_time = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as usize;
            
        if claims.exp < current_time {
            return Err((StatusCode::UNAUTHORIZED, "Токен истек".to_string()));
        }
        
        // Создание объекта пользователя
        Ok(AuthUser {
            user_id: claims.sub,
            name: claims.name,
            role: claims.role,
        })
    }
}

Реализация аутентификации

rust
// Структура для запроса авторизации
#[derive(Debug, Deserialize)]
struct LoginRequest {
    username: String,
    password: String,
}

// Структура для ответа с токеном
#[derive(Debug, Serialize)]
struct LoginResponse {
    access_token: String,
    token_type: String,
    expires_in: u64,
}

// Обработчик для логина
async fn login(Json(payload): Json<LoginRequest>) -> Result<impl IntoResponse, StatusCode> {
    // В реальном приложении здесь будет проверка в БД
    if payload.username == "admin" && payload.password == "password" {
        let expires_in = 3600; // срок действия токена в секундах
        
        // Текущее время и срок истечения
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as usize;
        
        let exp = now + expires_in as usize;
        
        // Создание claims
        let claims = Claims {
            sub: "1".to_string(),
            exp,
            name: payload.username,
            role: "admin".to_string(),
        };
        
        // Генерация JWT
        let token = encode(
            &Header::default(),
            &claims,
            &EncodingKey::from_secret("мой_секретный_ключ".as_bytes()),
        )
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        
        // Формирование ответа
        let response = LoginResponse {
            access_token: token,
            token_type: "Bearer".to_string(),
            expires_in,
        };
        
        Ok(Json(response))
    } else {
        Err(StatusCode::UNAUTHORIZED)
    }
}

// Защищенный маршрут, требующий аутентификации
async fn protected_route(auth_user: AuthUser) -> impl IntoResponse {
    format!("Привет, {}! Ваша роль: {}", auth_user.name, auth_user.role)
}

// Настройка маршрутов
fn routes() -> Router {
    Router::new()
        .route("/login", post(login))
        .route("/protected", get(protected_route))
}

Сессионная аутентификация

Для приложений, использующих сессии, можно реализовать сессионную аутентификацию:

rust
use axum::{
    extract::{FromRef, State},
    http::{header, StatusCode},
    response::{IntoResponse, Response},
};
use axum_extra::extract::{cookie::{Cookie, SameSite}, CookieJar};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;

// Структура сессии
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Session {
    id: String,
    user_id: String,
    username: String,
    expiry: OffsetDateTime,
}

// Хранилище сессий (в реальном приложении это будет Redis или БД)
#[derive(Clone)]
struct SessionStore {
    sessions: Arc<RwLock<Vec<Session>>>,
}

impl SessionStore {
    fn new() -> Self {
        Self {
            sessions: Arc::new(RwLock::new(Vec::new())),
        }
    }
    
    fn create_session(&self, user_id: String, username: String) -> Session {
        let id = Uuid::new_v4().to_string();
        let expiry = OffsetDateTime::now_utc() + Duration::hours(24);
        
        let session = Session {
            id,
            user_id,
            username,
            expiry,
        };
        
        // Сохранение сессии
        self.sessions.write().unwrap().push(session.clone());
        
        session
    }
    
    fn get_session(&self, id: &str) -> Option<Session> {
        let sessions = self.sessions.read().unwrap();
        sessions.iter()
            .find(|s| s.id == id && s.expiry > OffsetDateTime::now_utc())
            .cloned()
    }
    
    fn delete_session(&self, id: &str) {
        let mut sessions = self.sessions.write().unwrap();
        sessions.retain(|s| s.id != id);
    }
}

// Обработчик для логина с сессией
async fn login_with_session(
    State(session_store): State<SessionStore>,
    jar: CookieJar,
    Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, StatusCode> {
    // Проверка учетных данных
    if payload.username == "admin" && payload.password == "password" {
        // Создание сессии
        let session = session_store.create_session("1".to_string(), payload.username);
        
        // Установка куки
        let cookie = Cookie::build(("session_id", session.id))
            .path("/")
            .secure(true)
            .http_only(true)
            .same_site(SameSite::Lax)
            .expires(session.expiry)
            .build();
            
        // Возврат ответа с кукой
        Ok((jar.add(cookie), "Успешный вход"))
    } else {
        Err(StatusCode::UNAUTHORIZED)
    }
}

// Экстрактор для текущего пользователя
#[async_trait]
impl<B> FromRequest<B> for AuthUser
where
    B: Send,
    SessionStore: FromRef<SessionStore>,
{
    type Rejection = StatusCode;
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получение куки
        let jar = CookieJar::from_request(req).await
            .map_err(|_| StatusCode::UNAUTHORIZED)?;
            
        let session_id = jar.get("session_id")
            .ok_or(StatusCode::UNAUTHORIZED)?
            .value();
            
        // Получение хранилища сессий
        let session_store = SessionStore::from_ref(&req.extensions().get::<SessionStore>()
            .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?);
            
        // Получение сессии по ID
        let session = session_store.get_session(session_id)
            .ok_or(StatusCode::UNAUTHORIZED)?;
            
        // Создание пользователя из сессии
        Ok(AuthUser {
            user_id: session.user_id,
            name: session.username,
            role: "user".to_string(), // В реальном приложении роль хранится в сессии
        })
    }
}

// Обработчик для выхода
async fn logout(
    State(session_store): State<SessionStore>,
    jar: CookieJar,
) -> impl IntoResponse {
    if let Some(cookie) = jar.get("session_id") {
        session_store.delete_session(cookie.value());
        
        // Удаление куки
        let removed_cookie = Cookie::build("session_id")
            .path("/")
            .max_age(time::Duration::seconds(-1))
            .build();
            
        return (jar.remove(removed_cookie), "Успешный выход");
    }
    
    (jar, "Сессия не найдена")
}

OAuth и сторонние провайдеры

Для интеграции с OAuth провайдерами (Google, GitHub и др.) можно использовать библиотеку oauth2:

rust
use axum::{
    extract::{Query, State},
    response::{IntoResponse, Redirect},
    routing::get,
    Router,
};
use oauth2::{
    basic::BasicClient,
    reqwest::async_http_client,
    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl,
    Scope, TokenResponse, TokenUrl,
};
use serde::Deserialize;
use std::sync::Arc;

// Структура для провайдера OAuth
struct OAuthClient {
    client: BasicClient,
}

// Параметры запроса для callback
#[derive(Debug, Deserialize)]
struct AuthRequest {
    code: String,
    state: String,
}

// Создание клиента OAuth для GitHub
fn create_github_client() -> OAuthClient {
    let client = BasicClient::new(
        ClientId::new("client_id".to_string()),
        Some(ClientSecret::new("client_secret".to_string())),
        AuthUrl::new("https://github.com/login/oauth/authorize".to_string()).unwrap(),
        Some(TokenUrl::new("https://github.com/login/oauth/access_token".to_string()).unwrap()),
    )
    .set_redirect_uri(RedirectUrl::new("http://localhost:3000/auth/github/callback".to_string()).unwrap());
    
    OAuthClient { client }
}

// Обработчик для начала процесса аутентификации
async fn github_auth(State(oauth_client): State<Arc<OAuthClient>>) -> impl IntoResponse {
    // Генерация URL для аутентификации
    let (auth_url, _csrf_token) = oauth_client
        .client
        .authorize_url(CsrfToken::new_random)
        .add_scope(Scope::new("user:email".to_string()))
        .url();
        
    // Редирект на страницу авторизации GitHub
    Redirect::to(auth_url.as_str())
}

// Обработчик для callback
async fn github_callback(
    State(oauth_client): State<Arc<OAuthClient>>,
    Query(params): Query<AuthRequest>,
) -> impl IntoResponse {
    // Обмен кода на токен
    let token = oauth_client
        .client
        .exchange_code(AuthorizationCode::new(params.code))
        .request_async(async_http_client)
        .await;
        
    match token {
        Ok(token) => {
            // Получение данных пользователя с помощью токена
            let client = reqwest::Client::new();
            let user_data = client
                .get("https://api.github.com/user")
                .header("Authorization", format!("Bearer {}", token.access_token().secret()))
                .header("User-Agent", "Axum OAuth Example")
                .send()
                .await;
                
            match user_data {
                Ok(response) => {
                    if let Ok(user_json) = response.json::<serde_json::Value>().await {
                        // В реальном приложении здесь создание сессии или JWT
                        format!("Успешная аутентификация: {}", user_json)
                    } else {
                        "Ошибка при получении данных пользователя".to_string()
                    }
                },
                Err(_) => "Ошибка при запросе данных пользователя".to_string(),
            }
        },
        Err(_) => "Ошибка при обмене кода на токен".to_string(),
    }
}

// Настройка маршрутов OAuth
fn oauth_routes() -> Router {
    let oauth_client = Arc::new(create_github_client());
    
    Router::new()
        .route("/auth/github", get(github_auth))
        .route("/auth/github/callback", get(github_callback))
        .with_state(oauth_client)
}

Базовая HTTP-аутентификация

Для простых случаев можно использовать базовую HTTP-аутентификацию:

rust
use axum::{
    async_trait,
    extract::{FromRequest, RequestParts, TypedHeader},
    headers::authorization::{Authorization, Basic},
    http::StatusCode,
    response::IntoResponse,
    routing::get,
    Router,
};

// Экстрактор для базовой аутентификации
#[async_trait]
impl<B> FromRequest<B> for AuthUser
where
    B: Send,
{
    type Rejection = StatusCode;
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Извлечение заголовка Basic Authentication
        let TypedHeader(Authorization(basic)) = 
            TypedHeader::<Authorization<Basic>>::from_request(req)
                .await
                .map_err(|_| StatusCode::UNAUTHORIZED)?;
                
        // Проверка учетных данных
        if basic.username() == "admin" && basic.password() == "password" {
            Ok(AuthUser {
                user_id: "1".to_string(),
                name: basic.username().to_string(),
                role: "admin".to_string(),
            })
        } else {
            Err(StatusCode::UNAUTHORIZED)
        }
    }
}

// Защищенный маршрут
async fn basic_auth_route(auth_user: AuthUser) -> impl IntoResponse {
    format!("Аутентификация выполнена: {}", auth_user.name)
}

// Настройка маршрутов с базовой аутентификацией
fn basic_auth_routes() -> Router {
    Router::new()
        .route("/basic-auth", get(basic_auth_route))
}

Интеграция с Middleware

Для глобальной защиты маршрутов можно использовать middleware:

rust
use axum::{
    middleware::{self, Next},
    response::Response,
    Router,
};
use tower::ServiceBuilder;

// Middleware для проверки аутентификации
async fn auth_middleware(
    auth_result: Result<AuthUser, StatusCode>,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    // Проверка результата аутентификации
    let auth_user = auth_result?;
    
    // Добавление данных пользователя в request extensions
    request.extensions_mut().insert(auth_user);
    
    // Передача управления следующему слою
    Ok(next.run(request).await)
}

// Настройка приложения с middleware
fn app_with_auth() -> Router {
    let protected_routes = Router::new()
        .route("/profile", get(profile))
        .route("/admin", get(admin_panel))
        .layer(middleware::from_fn(auth_middleware));
        
    Router::new()
        .route("/login", post(login))
        .nest("/api", protected_routes)
}

// Получение пользователя из extensions
async fn profile(Extension(auth_user): Extension<AuthUser>) -> impl IntoResponse {
    format!("Профиль пользователя: {}", auth_user.name)
}

// Проверка роли
async fn admin_panel(Extension(auth_user): Extension<AuthUser>) -> Result<String, StatusCode> {
    if auth_user.role == "admin" {
        Ok("Панель администратора".to_string())
    } else {
        Err(StatusCode::FORBIDDEN)
    }
}

Лучшие практики

  1. Безопасное хранение паролей

    rust
    use argon2::{
        password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
        Argon2,
    };
    
    fn hash_password(password: &str) -> Result<String, String> {
        let salt = SaltString::generate(&mut OsRng);
        let argon2 = Argon2::default();
        
        argon2
            .hash_password(password.as_bytes(), &salt)
            .map(|hash| hash.to_string())
            .map_err(|e| format!("Ошибка хеширования: {}", e))
    }
    
    fn verify_password(hash: &str, password: &str) -> Result<bool, String> {
        let parsed_hash = PasswordHash::new(hash)
            .map_err(|e| format!("Ошибка при разборе хеша: {}", e))?;
            
        Ok(Argon2::default()
            .verify_password(password.as_bytes(), &parsed_hash)
            .is_ok())
    }
  2. Защита от CSRF-атак

    rust
    // Генерация CSRF-токена
    fn generate_csrf_token() -> String {
        use rand::{thread_rng, Rng};
        use rand::distributions::Alphanumeric;
        
        thread_rng()
            .sample_iter(&Alphanumeric)
            .take(32)
            .map(char::from)
            .collect()
    }
    
    // Проверка CSRF-токена в middleware
    async fn csrf_middleware(mut request: Request, next: Next) -> Result<Response, StatusCode> {
        // Только для POST/PUT/DELETE запросов
        if matches!(request.method(), &Method::POST | &Method::PUT | &Method::DELETE) {
            let cookie_token = request
                .headers()
                .get("Cookie")
                .and_then(|c| {
                    c.to_str().ok()?.split(';')
                        .find_map(|part| {
                            let part = part.trim();
                            if part.starts_with("csrf=") {
                                Some(part[5..].to_string())
                            } else {
                                None
                            }
                        })
                })
                .ok_or(StatusCode::FORBIDDEN)?;
                
            let header_token = request
                .headers()
                .get("X-CSRF-Token")
                .and_then(|h| h.to_str().ok())
                .ok_or(StatusCode::FORBIDDEN)?;
                
            if cookie_token != header_token {
                return Err(StatusCode::FORBIDDEN);
            }
        }
        
        Ok(next.run(request).await)
    }
  3. Управление сессиями

    • Устанавливайте флаг HttpOnly для предотвращения доступа к cookie через JavaScript
    • Используйте флаг Secure для передачи cookie только через HTTPS
    • Устанавливайте SameSite=Lax или SameSite=Strict для защиты от CSRF
    • Регулярно обновляйте ID сессии для предотвращения атак фиксации сессии
  4. Защита от атак перебора

    rust
    use std::collections::HashMap;
    use std::sync::{Arc, Mutex};
    use std::time::{Duration, Instant};
    
    // Структура для отслеживания попыток входа
    struct LoginAttemptTracker {
        attempts: HashMap<String, Vec<Instant>>,
        max_attempts: usize,
        window_seconds: u64,
    }
    
    impl LoginAttemptTracker {
        fn new(max_attempts: usize, window_seconds: u64) -> Self {
            Self {
                attempts: HashMap::new(),
                max_attempts,
                window_seconds,
            }
        }
        
        fn is_rate_limited(&mut self, ip: &str) -> bool {
            let now = Instant::now();
            let window = Duration::from_secs(self.window_seconds);
            
            // Очистка устаревших попыток
            if let Some(attempts) = self.attempts.get_mut(ip) {
                attempts.retain(|time| now.duration_since(*time) < window);
                
                // Проверка количества попыток
                if attempts.len() >= self.max_attempts {
                    return true;
                }
                
                // Добавление новой попытки
                attempts.push(now);
            } else {
                self.attempts.insert(ip.to_string(), vec![now]);
            }
            
            false
        }
    }
    
    // Использование в обработчике логина
    async fn login_with_rate_limit(
        State(tracker): State<Arc<Mutex<LoginAttemptTracker>>>,
        ConnectInfo(addr): ConnectInfo<SocketAddr>,
        Json(payload): Json<LoginRequest>,
    ) -> Result<impl IntoResponse, StatusCode> {
        let ip = addr.ip().to_string();
        
        // Проверка ограничения скорости
        if tracker.lock().unwrap().is_rate_limited(&ip) {
            return Err(StatusCode::TOO_MANY_REQUESTS);
        }
        
        // Обычная логика входа
        if authenticate(&payload.username, &payload.password).await {
            Ok("Успешный вход")
        } else {
            Err(StatusCode::UNAUTHORIZED)
        }
    }
  5. Ограничение срока действия токенов

    • Устанавливайте короткий срок действия для access токенов (минуты или часы)
    • Используйте refresh токены для продления сессии
    • Храните список отозванных токенов для немедленного прекращения доступа

Аутентификация в Axum может быть реализована различными способами в зависимости от требований приложения. Выбор между JWT, сессиями или OAuth зависит от характера приложения, требований к безопасности и удобству пользователей.