- PVSM.RU - https://www.pvsm.ru -

Как я писал сервис авторизации на Rust…

Работая над одним своим проектом я задумался о необходимости авторизации для его публичного запуска. В самом проекте я всем этим заниматься не стал, а решил разработать отдельный сервис авторизации на Rust, который в дальнейшем можно будет "прикручивать" к разным проектам с небольшими доработками. Для удобства доработок мне в дальнейшем пришла идея создания абстракций для подходящих под них модулей.

Краткий экскурс

Если говорить кратко, то сервис должен уметь генерировать access- и refresh-токены, хранить их и валидировать. Но если честно, то сказать кратко будет неверно, так как на практике всё получилось намного сложнее и запутаннее.

Всё было бы смешно, когда бы не было так грустно.

Что на практике

v0.1.0 - Стартовый базис со множеством недоработок

Создание первой версии началось полным хаосом и непониманием работы auth-механизмов и -логики. В данную тему я раньше вообще почти не погружался, поэтому разбираться во всём приходилось по ходу разработки, из-за чего та шла очень динамично. Каждый день находились новые уязвимости, которые приходилось фиксить.

Сразу скажу, что в первой версии есть довольно много серьёзных уязвимостей, о которых я скажу в конце.

Токены и их генерация

В сервисе будут два вида токенов: access и refresh.

По специфике генерации были выделены две группы генераторов - Jwt и Opaque. Все генераторы будут иметь только одну единственную функцию - generate.

Что такое Opaque-токен

Opaque (непрозрачный) токен — это уникальная случайная строка, используемая для авторизации, которая не несет информации для клиента, а служит ссылкой на данные, хранящиеся на сервере.

Для генерации JWT-токенов был создан trait IJwtTokenProvider c типами Claims и Error.

pub trait IJwtTokenProvider {
    type Claims: Send + Sync;
    type Error;

    fn generate(&self, claims: &Self::Claims, pem: &str) -> Result<String, Self::Error>;
}

Был реализован JwksTokenProvider для генерации JWS токенов, создающий токен с помощью алгоритма RS256, приватного ключа и Claims.

О разнице JWE и JWS

Основная разница между JWS и JWE заключается в цели защиты данных: JWS(JSON Web Signature) подписывает данные для проверки их целостности, оставляя их читаемыми, а JWE(JSON Web Encryption) шифрует данные, обеспечивая их конфиденциальность. JWS используется для аутентификации, а JWE — для безопасной передачи секретной информации.

Поэтому будет использоваться именно JWS.

Структура JWT
Структура JWT-токена

Структура JWT-токена
impl IJwtTokenProvider for JwksTokenProvider {
    type Claims = Claims;
    type Error = Box<dyn std::error::Error>;

    fn generate(
        &self,
        claims: &Claims,
        private_pem: &str,
    ) -> Result<String, Box<dyn std::error::Error>> {
        let header = Header::new(Algorithm::RS256);

        let claims = JwksClaims::from_domain_claims(&claims)?;

        let key = EncodingKey::from_rsa_pem(private_pem.as_bytes())?;

        let token = encode(&header, &claims, &key)?;

        Ok(token)
    }
}
О криптографических операциях

Очень интересная особенность, что на моей памяти большинство криптографических операций производится именно с байтами, а не, например, со строками. С одной стороны это логично, т.к. они работают так, чтобы обеспечить универсальность, целостность данных и независимость от кодировок, а с другой - иногда это неудобно из-за лишних конвертаций.

Для генерации Opaque-токенов был создан trait IOpaqueTokenProvider.

pub trait IOpaqueTokenProvider {
    fn generate(&self) -> String;
}

Был реализован GetrandomOpaqueTokenProvider для генерации opaque-токенов с помощью getrandom, заполняющий в функции generate 32 байта случайными символами и возвращающий строку base64.

impl IOpaqueTokenProvider for GetrandomOpaqueTokenProvider {
    fn generate(&self) -> String {
        let mut bytes = [0u8; 32]; // байты

        let _ = getrandom::fill(&mut bytes); // заполнение байтов символами

        URL_SAFE_NO_PAD.encode(bytes) // кодирование в base64. 32 -> 43 байта
    }
}
Особенность base64

Несмотря на то, что мы генерируем 32 рандомных байта, при encode в base64 строка принимает размер 43 байта из-за роста в 33% из-за специфики base64. В дальнейшем это может создать непредвиденные баги.

Кто-то мог заметить, что в логике JwksTokenProvider присутствуют две модели: Claims и JwksClaims. Claims - доменная модель для claims, а JwksClaims - модель claims, специфичная для поставщика.

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
    pub sub: Uuid,
    pub jti: Uuid,
    pub iat: DateTime<Utc>,
    pub exp: DateTime<Utc>
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwksClaims {
    pub sub: uuid::Uuid,
    pub jti: uuid::Uuid,
    pub iat: usize,
    pub exp: usize
}

Их отличие в типах DateTime и usize. DateTime в domain-модели даёт больше гибкости.

Больше о стандартизации Claims

Стандартизованные claims в JWT (RFC 7519) — это зарезервированные поля, рекомендуемые для обеспечения совместимости между системами. Ключевые поля включают:

sub

идентификатор пользователя

jti

идентификатор токена

nbf

не ранее

aud

аудитория

iss

издатель

iat

время инициализации токена

exp

срок годности токена

Верификация токенов

Для валидации JWT-токенов был создан trait IJwtTokenValidator с типами Claims и Error, имеющий одну функцию - verify, принимающую на вход token, ключ и возвращающую Claims.


pub trait IJwtTokenValidator {
    type Claims: Send + Sync;
    type Error;

    fn verify(&self, token: &str, pem: &str) -> Result<Self::Claims, Self::Error>;
}

Был реализован JwksTokenValidator. Здесь функция verify принимает публичный ключ.

Немного о публичных и приватных ключах

При разработке rust-auth-service я думал, что там, где нужно использовать публичный ключ, можно использовать и приватный, но это оказалось огромным заблуждением. Для каждой задачи - строго свой ключ.

impl IJwtTokenValidator for JwksTokenValidator {
    type Claims = Claims;
    type Error = Box<dyn std::error::Error>;

    fn verify(&self, token: &str, public_pem: &str) -> Result<Claims, Box<dyn std::error::Error>> {
        let key = DecodingKey::from_rsa_pem(public_pem.as_bytes()).map_err(|error| match error {
            _ => { 
                error!("{}", error);
                error
            }
        })?; // получение ключа декодирования из rsa ключа

        let claims = decode::<JwksClaims>(&token, &key, &Validation::new(Algorithm::RS256)).map_err(|error| match error {
            _ => { 
                error!("{}", error);
                error
            }
        })?.claims; // декодирование токена

        let claims = Claims { sub: storage_claims.sub, jti: _claims.jti, iat: usize_to_datetime(storage_claims.iat), exp: usize_to_datetime(storage_claims.exp) 
}; // конвертация в domain модель

        Ok(claims)
    }
}
Что такое PEM

PEM(Privacy Enhanced Mail) это стандарт хранения и передачи публичных ключей и сертификатов.

PEM-файл может содержать сертификат, пару ключей, или их комбинацию. Ключи могут содержать цепочку сертификатов от различных центров сертификации (certificate authorities).

Варианты шифрования JWT

У JWT есть много различных вариантов шифрования посредством разных алгоритмов, но алгоритмы можно разделить на два основных вида: симметричные и асиметричные.

Симметричное шифрование — это метод, использующий один и тот же секретный ключ как для шифрования, так и для дешифровки данных. К такому шифрованию можно отнести алгоритмы семейства HSA.

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

Хранение токенов

Но на генерации всё не останавливается. Всё необходимо где-то оркестрировать. Поэтому было решено создать два сервиса-оркестратора: TokenManager и KeyManager. TokenManager будет отвечать за управление токенами, а KeyManager - за управление ключами для access-токенов.

Первым я начал разработку составляющих TokenManager'а.

Для его работы из оставшегося необходимо было реализовать только хранение токенов в redis. Для удобной работы с хранилищем был написан модуль RedisIO, который был создан для того, чтобы каждый раз не указывать generic-типы.

impl<Storage> RedisIO<Storage>
where Storage: redis::AsyncCommands + Send + Sync
{
    pub fn new(storage: Storage) -> Self {
        Self { redis_storage: storage }
    }

    pub async fn setex(&mut self, key: &str, data: &str, exp: u64) -> Result<(), redis::RedisError> {
        self.redis_storage
            .set_ex::<&str, String, ()>(
                &key,
                data.to_string(),
                exp
            )
            .await?;

        Ok(())
    }

    pub async fn get(&mut self, key: &str) -> Result<String, redis::RedisError> {
        let data = self
            .redis_storage
            .get::<&str, String>(&key)
            .await?;

        Ok(data)
    }

    pub async fn delete(&mut self, key: &str) -> Result<(), redis::RedisError> {
        self.redis_storage
            .del::<&str, ()>(&key)
            .await?;

        Ok(())
    }
}

После разработки составных частей можно было уже перейти к написанию TokenManager'а.

TokenManager

Предлагаю для начала посмотреть на конструктор new. Он принимает как входные аргументы параметры access_provider, access_validator, refresh_provider, storage.

pub fn new(
        access_provider: AccessProvider,
        access_validator: AccessValidator,
        refresh_provider: RefreshProvider,
        storage: Storage,
) -> Self {
    let redis_io = RedisIO::new(storage);

    Self {
        access_provider,
        access_validator,
        refresh_provider,
        redis_io
    }
}

Причём эти параметры не имеют фиксированные типы, а реализованы через generic'и.

impl<AccessProvider, AccessValidator, RefreshProvider, Storage>
    TokenManager<AccessProvider, AccessValidator, RefreshProvider, Storage>
where
    AccessProvider: IJwtTokenProvider<Claims = Claims, Error = Box<dyn std::error::Error>>,
    AccessValidator: IJwtTokenValidator<Claims = Claims, Error = Box<dyn std::error::Error>>,
    RefreshProvider: IOpaqueTokenProvider,
    Storage: redis::AsyncCommands + Send + Sync,

Первой рассмотрим функцию generate_pair. Она берёт на вход Claims и публичный pem-ключ и возвращает access- и refresh-токены.

pub async fn generate_pair(
        &mut self,
        claims: &Claims,
        pem: &str,
) -> Result<(String, String), TokenManagerError> {
    let access_token = self.access_provider.generate(&claims, &pem)?;
    let refresh_token = self.refresh_provider.generate();

    let access_to_exp_sec = (claims.exp - &claims.iat).num_seconds() as u64;
    let refresh_to_exp_sec = 60 * 60 * 24 * 7;

    {
        self.redis_io.setex(&format!("tokens:access:jti:{}", &claims.jti), &access_token, access_to_exp_sec).await?;
        self.redis_io.setex(&format!("tokens:refresh:token:{}", &refresh_token), &refresh_token, refresh_to_exp_sec).await?;
    }

    Ok((access_token, refresh_token))
}

Следующей по очереди идёт verify_access, проверяющая существование access-токена. Она принимает на вход access-токен и публичный pem и при успехе возвращает claims.

pub async fn verify_access(
        &mut self,
        access: &str,
        pem: &str,
) -> Result<Claims, TokenManagerError> {
    let claims = self.access_validator.verify(access, pem)?;
        
    let access = self.redis_io.get(&format!("tokens:access:jti:{}", &claims.jti)).await?;

    if access.is_empty() {
        return Err(TokenManagerError::NotFound(format!(
            "Access token with jti {} not found",
            &claims.jti
        )));
    }

    Ok(claims)
}

Далее следует такая же функция для refresh-токена, но claims она уже не возвращает, потому что это opaque-строка, а не JWT.

pub async fn verify_refresh(&mut self, refresh: &str) -> Result<(), TokenManagerError> {
    let refresh_token = self.redis_io.get(&format!("tokens:refresh:token:{}", &refresh)).await?;

    if refresh_token.is_empty() {
        return Err(TokenManagerError::NotFound(format!(
            "Refresh token {} not found",
            &refresh
        )));
    }

    Ok(())
}

Далее можно взять сразу две простых функции для revoke(т.е. отката) токенов.

pub async fn revoke_access(
        &mut self,
        access: &str,
        pem: &str,
    ) -> Result<(), TokenManagerError> {
    let claims = self.access_validator.verify(access, pem)?;

    {
        self.redis_io.delete(&format!("tokens:access:jti:{}", &claims.jti)).await?;
    }

    Ok(())
}

pub async fn revoke_refresh(&mut self, refresh: &str) -> Result<(), TokenManagerError> {
    {
        self.redis_io.delete(&format!("tokens:refresh:token:{}", &refresh)).await?;
    }

    Ok(())
}

Ну и вишенка на торте: функция refresh. Она принимает refresh, access, private_pem и public_pem как входные параметры и возвращающая два новых токена(access и refresh) при успехе.

pub async fn refresh(
        &mut self,
        refresh: &str,
        access: &str,
        private_pem: &str,
        public_pem: &str,
) -> Result<(String, String), TokenManagerError> {
    let refresh_token = self.redis_io.get(&format!("tokens:refresh:token:{}", &refresh)).await?;

    if refresh_token.is_empty() {
        return Err(TokenManagerError::NotFound(format!(
            "Refresh token {} not found",
            &refresh
        )));
    }

    {
        self.redis_io.delete(&format!("tokens:refresh:token:{}", &refresh)).await?;
    }

    let claims = self.access_validator.verify(&access, &public_pem)?;

    {
        self.redis_io.delete(&format!("tokens:access:jti:{}", &claims.jti)).await?
    }

    let access_token = self.access_provider.generate(&claims, &private_pem)?;
    let refresh_token = self.refresh_provider.generate();

    let access_to_exp_sec = (claims.exp - &claims.iat).num_seconds() as u64;
    let refresh_to_exp_sec = 60 * 60 * 24 * 7;

    {
        self.redis_io.setex(&format!("tokens:access:jti:{}", &claims.jti), &access_token, access_to_exp_sec).await?;
        self.redis_io.setex(&format!("tokens:refresh:token:{}", &refresh_token), &refresh_token, refresh_to_exp_sec).await?;
    }

    Ok((access_token, refresh_token))
}

Но фишка в том, что TokenManager не будет работать без KeyManager, так как ключи необходимы для access-токенов, поэтому на данном этапе нужно перейти к разработке его частей.

Хранение ключей

Для начала нужно было определиться где хранить ключи - они будут храниться локально в файлах private.pem и public.pem. Для удобной работы с файлами я написал модуль FileIO.

Модуль FileIO принимает путь к файлу в виде строки как входной элемент в конструкторе, откуда мы получаем сам instance. Затем через него можно делать все операции, связанные с отдельным файлом. Очень удобный подход для дальнейшей разработки.

pub fn new(path: &str) -> Self {
    Self {
        file_path: PathBuf::from(path),
    }
}
impl FileIO {
    pub fn write(&self, data: &str) -> Result<(), std::io::Error> {
        let _ = write(&self.file_path, &data).map_err(|error| match error {
            err => {
                error!("IO error caused: {}", &err);
                err
            }
        })?;

        Ok(())
    }

    pub fn read(&self) -> Result<Vec<u8>, std::io::Error> {
        let data = read(&self.file_path).map_err(|error| match error {
            err => {
                error!("IO error caused: {}", &err);
                err
            }
        })?;

        Ok(data)
    }

    pub fn remove(&self) -> Result<(), std::io::Error> {
        let _ = remove_file(&self.file_path).map_err(|error| match error {
            err => {
                error!("IO error caused: {}", &err);
                err
            }
        })?;

        Ok(())
    }
}

Генерация ключей

Был разработан RsaPemProvider, реализующий функции genarate_pair(генерация пары private-public), generate_private, generate_from(генерация public из private).

impl RsaPemProvider {
    pub fn generate_pair(&self) -> (String, String) {
        let mut rng = OsRng;

        let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
        let private_pem = private_key
            .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
            .unwrap()
            .to_string();

        let public_key = RsaPublicKey::from(&private_key);
        let public_pem = public_key
            .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
            .unwrap()
            .to_string();

        (private_pem, public_pem)
    }

    pub fn generate_from(&self, private_pem: &str) -> String {
        let private_key = RsaPrivateKey::from_pkcs1_pem(private_pem).unwrap();

        let public_key = RsaPublicKey::from(&private_key);
        let public_pem = public_key
            .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
            .unwrap()
            .to_string();

        public_pem
    }

    pub fn generate_private(&self) -> String {
        let mut rng = OsRng;

        let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
        let private_pem = private_key
            .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
            .unwrap()
            .to_string();

        private_pem
    }
}

Потом уже был реализован сам KeyManager.

KeyManager

Первым опять же следует рассмотреть конструктор. Он принимает на вход название папки для хранения ключей. Затем создаётся PathBuf для непосредственной работы с папкой. Создаются instance'ы FileIO для public и private pem file. Возвращается модель с key_provider'ом, PathBuf к папке и IO'шниками для ключей.

pub fn new(keys_dir_path: &str) -> Result<Self, KeyManagerError> {
    let keys_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(keys_dir_path);

    if !keys_dir.exists() {
        std::fs::create_dir(&keys_dir).map_err(|error| match error {
            err => {
                error!("Couldn't create keys directory: {}", &err);
                err
            }
        });
    }

    let private_pem_file_io = FileIO::new(
        keys_dir
            .join("private.pem")
            .to_str()
            .ok_or(KeyManagerError::Unexpected("Invalid path".to_string()))?,
    );
    let public_pem_file_io = FileIO::new(
        keys_dir
            .join("public.pem")
            .to_str()
            .ok_or(KeyManagerError::Unexpected("Invalid path".to_string()))?,
    );

    Ok(Self {
        key_provider: RsaPemProvider,
        keys_dir: keys_dir,
        private_pem_file_io: private_pem_file_io,
        public_pem_file_io: public_pem_file_io
    })
}

Первая функция называется provide. Она поставляет ключи.

pub fn provide(&self) -> Result<(), KeyManagerError> {
    let (private_pem, public_pem) = self.key_provider.generate_pair();

    self.private_pem_file_io.write(&private_pem)?;
    self.public_pem_file_io.write(&public_pem)?;

    Ok(())
}

Следующей идёт функция rollback, очищающая все ключи.

pub fn rollback(&self) -> Result<(), KeyManagerError> {
    self.private_pem_file_io.remove()?;
    self.public_pem_file_io.remove()?;

    Ok(())
}

Следующая - функция update, обновляющая ключи.

pub fn update(&self) -> Result<(), KeyManagerError> {
    let (private_pem, public_pem) = self.key_provider.generate_pair();

    self.private_pem_file_io.remove()?;
    self.public_pem_file_io.remove()?;

    self.private_pem_file_io.write(&private_pem)?;
    self.public_pem_file_io.write(&public_pem)?;

    Ok(())
}

Следующими идут функции получения ключей.

pub fn get_public(&self) -> Result<String, KeyManagerError> {
    let public_pem = String::from_utf8(self.public_pem_file_io.read()?)
    .map_err(|error| match error {
        err => {
            error!("From UTF-8 error caused: {}", &err);
            err
        }
    })?;

    Ok(public_pem)
}

pub fn get_private(&self) -> Result<String, KeyManagerError> {
    let private_pem = String::from_utf8(self.private_pem_file_io.read()?)
    .map_err(|error| match error {
        err => {
            error!("From UTF-8 error caused: {}", &err);
            err
        }
    })?;

    Ok(private_pem)
}

На этом основная логика закончилась, остались только api handlers, которые в большинстве своём просто являются обёртками над сервисами, поэтому интересного в них разбирать нечего.

Вместо этого лучше посмотреть код файла main.rs, инициализирующего веб-сервер на Axum.

Веб-сервер

#[derive(Clone)]
pub struct AppState {
    pub key_manager: KeyManager,
    pub token_manager: TokenManager<
        JwksTokenProvider,
        JwksTokenValidator,
        GetrandomOpaqueTokenProvider,
        MultiplexedConnection,
    >,
}

impl AppState {
    pub fn new(conn: MultiplexedConnection) -> Result<Self, Box<dyn std::error::Error>> {
        let key_folder = "keys";

        let access_provider = JwksTokenProvider;
        let access_validator = JwksTokenValidator;
        let refresh_provider = GetrandomOpaqueTokenProvider;

        let key_manager = KeyManager::new(key_folder)?;
        let token_manager =
            TokenManager::new(access_provider, access_validator, refresh_provider, conn);

        Ok(Self {
            key_manager: key_manager,
            token_manager: token_manager,
        })
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt::init();

    let redis_client = redis::Client::open(std::env::var("REDIS_URL")?)?;
    let connection = redis_client.get_multiplexed_async_connection().await?;

    let state = AppState::new(connection)?;

    state.key_manager.provide()?;

    let app = Router::new()
        .route("/key/public", get(get_public_key))
        .route("/generate", post(generate_tokens))
        .route("/verify", post(verify_access_token))
        .route("/refresh", post(refresh_token))
        .route("/revoke_access", post(revoke_access_token))
        .route("/revoke_refresh", post(revoke_refresh_token))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:5000").await?;
    axum::serve(listener, app).await?;

    Ok(())
}

Уязвимости версии v0.1.0

  1. Большое количество unwrap, вызывающих panic при ошибке.

  2. Нерациональное хранение токенов. Хранить access-токены нет особого смысла, так как они уже несут в себе нужную информацию. В данной версии хранятся активные токены, но хранить их нерационально, так как это добавляет дополнительную нагрузку и даёт меньшую производительность. А валидацию в свою очередь каждый другой сервис должен иметь право производить за счёт своих middleware. Также хранятся refresh-токены с ключом-токеном и значением-токеном, то есть вместо памяти занимается грубо говоря в два раза больше.

  3. Выдача refresh-токенов в открытом виде - риск перехвата. Если перехваченный access-токен не так опасен, так как его срок службы короткий(в моём случае 15 минут), то refresh-токен долгоживущий и даёт в��зможность генерировать новые access-токены, поэтому отдавать их в открытом виде не самая лучшая идея.

  4. Слабое логирование и обработка ошибок. Без этого поймать ошибку очень тяжело и приходится писать костыли по типу println!().

Фикситься эти проблемы будут в следующих версиях.

v0.1.1 - Base-версия с релизом

К этому моменту мы уже имеем работающий, но сырой сервис и некоторый список проблем.

Решение проблем хранения токенов

Первой для решения оказалась проблема избыточного хранения токенов, поэтому был переработан TokenManager.

Вот изменённый код TokenManager.

impl<AccessProvider, AccessValidator, RefreshProvider, Storage>
    TokenManager<AccessProvider, AccessValidator, RefreshProvider, Storage>
where
    AccessProvider: IJwtTokenProvider<Claims = Claims, Error = Box<dyn std::error::Error>>,
    AccessValidator: IJwtTokenValidator<Claims = Claims, Error = Box<dyn std::error::Error>>,
    RefreshProvider: IOpaqueTokenProvider,
    Storage: redis::AsyncCommands + Send + Sync,
{
    pub fn new(
        access_provider: AccessProvider,
        access_validator: AccessValidator,
        refresh_provider: RefreshProvider,
        storage: Storage,
    ) -> Self {
        let redis_io = RedisIO::new(storage);

        Self {
            access_provider,
            access_validator,
            refresh_provider,
            redis_io
        }
    }

    pub async fn generate_pair(
        &mut self,
        claims: &Claims,
        pem: &str,
    ) -> Result<(String, String), TokenManagerError> {
        let access_token = self.access_provider.generate(&claims, &pem)?;
        let refresh_token = self.refresh_provider.generate();

        let refresh_to_exp_sec = 60 * 60 * 24 * 7;

        {
            self.redis_io.setex(&format!("tokens:refresh:token:{}", &refresh_token), "exists", refresh_to_exp_sec).await?;


        }

        Ok((access_token, refresh_token))
    }

    pub async fn verify_access(
        &mut self,
        access: &str,
        pem: &str,
    ) -> Result<Claims, TokenManagerError> {
        let claims = self.access_validator.verify(access, pem)?;

        Ok(claims)
    }

    pub async fn verify_refresh(&mut self, refresh: &str) -> Result<(), TokenManagerError> {
        let refresh_token = self.redis_io.get(&format!("tokens:refresh:token:{}", &refresh)).await?;

        if refresh_token.is_empty() {
            return Err(TokenManagerError::NotFound(format!(
                "Refresh token {} not found",
                &refresh
            )));
        }

        Ok(())
    }

    pub async fn revoke_refresh(&mut self, refresh: &str) -> Result<(), TokenManagerError> {
        {
            self.redis_io.delete(&format!("tokens:refresh:token:{}", &refresh)).await?;
        }

        Ok(())
    }

    pub async fn refresh(
        &mut self,
        refresh: &str,
        access: &str,
        private_pem: &str,
        public_pem: &str,
    ) -> Result<(String, String), TokenManagerError> {
        let refresh_token = self.redis_io.get(&format!("tokens:refresh:token:{}", &refresh)).await?;

        if refresh_token.is_empty() {
            return Err(TokenManagerError::NotFound(format!(
                "Refresh token {} not found",
                &refresh
            )));
        }

        {
            self.redis_io.delete(&format!("tokens:refresh:token:{}", &refresh)).await?;
        }

        let claims = self.access_validator.verify(&access, &public_pem)?;

        let access_token = self.access_provider.generate(&claims, &private_pem)?;
        let refresh_token = self.refresh_provider.generate();


        let refresh_to_exp_sec = 60 * 60 * 24 * 7;

        {
            self.redis_io.setex(&format!("tokens:refresh:token:{}", &refresh_token), "exists", refresh_to_exp_sec).await?;
        }

        Ok((access_token, refresh_token))
    }
}

Можно увидеть, что исчезли все фрагменты кода, связанные с сохранением и получением access-токенов. Была удалена за ненадобностью функция revoke_access. Также в хранилище refresh-токены теперь хранятся в формате key(refresh-token) - value(exists).

Также были сделаны некоторые поправки по синтаксису, что не влияет на логику работы.

v0.1.2 - Release-2 - крупные изменения с оставленными недоработками

В этой версии упор делался на апгрейд безопасности и error handling.

Усовершенствование безопасности refresh-токенов

То есть было принято решение перестать возвращать refresh-токен в открытом виде, а вместо этого хранить его в Redis, а пользователю возвращать зашифрованный вариант. Для этого пришлось подразобраться в криптографии и алгоритмах шифрования. В итоге я разработал модуль AesGcmCryptographer.

Что такое AES

AES(Advanced Encryption Standard) — это симметричный блочный алгоритм шифрования данных, принятый в качестве мирового стандарта.

#[derive(Debug, Clone, Copy)]
pub struct AesGcmCryptographer {
    pub key: GenericArray<u8, U32>,
}

impl AesGcmCryptographer {
    pub fn new(key: &GenericArray<u8, U32>) -> Self {
        Self { key: *key }
    }

    pub fn encrypt(&self, data: &str) -> Result<(String, String), aes_gcm::Error> {
        let cipher = Aes256Gcm::new(&self.key);

        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);

        let encrypted = cipher.encrypt(&nonce, data.as_bytes().as_ref())?;

        Ok((general_purpose::STANDARD.encode(encrypted), general_purpose::STANDARD.encode(nonce.to_vec())))
    }

    pub fn decrypt(&self, encrypted: &str, nonce: &str) -> Result<String, aes_gcm::Error> {
        let encrypted = general_purpose::STANDARD.decode(encrypted).unwrap();
        let nonce = general_purpose::STANDARD.decode(nonce).unwrap();

        let cipher = Aes256Gcm::new(&self.key);

        let nonce = Nonce::from_slice(&nonce);

        let decrypted = cipher.decrypt(&nonce, encrypted.as_ref())?;

        Ok(String::from_utf8(decrypted).unwrap())
    }
}

Это модуль, шифрующий и дешифрующий данные. В структуру конечно можно было добавить cipher, но насколько я помню он не реализует нужный trait Copy, поэтому как выход из проблемы я решил просто хранить секретный ключ. Также нужно обратить внимание, что тут используется не только секретный ключ, но и nonce.

Что такое Nonce

Nonce - это уникальное случайное или псевдослучайное число, применяемое в криптографии, блокчейне и API только один раз для конкретной операции. Его главная цель - предотвратить атаки повторного воспроизведения(replay attacks) и обеспечить уникальность каждого сообщения или транзакции.

Также важно сказать, что очень важно использовать именно general_purpose::STANDARD из библиотеки base64, потому что String::from_utf8 здесь не проканает, так как это не utf-8, а base64-строки. String::from_utf8 используется только в методе decrypt для возврата "чистой" строки.

Теперь везде, где принимался и возвращался refresh, будут участвовать encrypted_refresh и nonce.

Очень важное изменение - изменение конструктора TokenManager. Теперь он тоже взаимодействует с папкой keys. Возможно, эту логику в дальнейшем стоит перенести в KeysManager. Теперь тут создаётся IO'шник для файла encryption_key.

pub fn new(
        access_provider: AccessProvider,
        access_validator: AccessValidator,
        refresh_provider: RefreshProvider,
        keys_dir_path: &str,
        storage: Storage,
) -> Result<Self, TokenManagerError> {
    let redis_io = RedisIO::new(storage);

    let keys_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(keys_dir_path);

    if !keys_dir.exists() {
        let _ = std::fs::create_dir(&keys_dir).map_err(|error| match error {
            err => {
                error!("Couldn't create keys directory: {}", &err);
                err
            }
        });
    }

    let encryption_key_file_io = FileIO::new(
        keys_dir
            .join("encryption_key.txt")
            .to_str()
            .ok_or(TokenManagerError::Unexpected("Invalid path".to_string()))?,
    );

    let key = Aes256Gcm::generate_key(OsRng);

    let base64_key = general_purpose::STANDARD.encode(key.as_slice());

    let _ = encryption_key_file_io.write(&base64_key);

    let cryptographer = AesGcmCryptographer::new(&key);

    Ok(Self {
            access_provider,
            access_validator,
            refresh_provider,
            redis_io,
            cryptographer,
            keys_dir,
    })
}

Мелкие доработки

Также в данной версии были доработаны обработка ошибок: добавлены новые enum'ы ошибок и решено большое количество уязвимостей с unwrap. Кроме этого было добавлено всестороннее логирование ошибок для более быстрой их ловли.

Итоги на данный момент

На данный момент сервис полностью рабочий.

Найти полный код можно на GitHub по ссылке [1].

Ещё остались некоторые уязвимости, связанные с unwrap'ами. Возможно в дальнейшем всплывут ещё ошибки.

Сервис планируется развивать в дальнейшем(но наверное не в ближайшее время).

Планы на развитие

  1. Убрать все unwrap'ы

  2. Написать trait для IO-операций

  3. Написать trait для provider'ов ключей

  4. Написать trait для Cryptographer'ов

Автор: VitBurk

Источник [2]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/razrabotka/447403

Ссылки в тексте:

[1] Найти полный код можно на GitHub по ссылке: https://github.com/Vitalij-Burk/rust-auth-service

[2] Источник: https://habr.com/ru/articles/1013338/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1013338