Работая над одним своим проектом я задумался о необходимости авторизации для его публичного запуска. В самом проекте я всем этим заниматься не стал, а решил разработать отдельный сервис авторизации на 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

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
Большое количество unwrap, вызывающих panic при ошибке.
Нерациональное хранение токенов. Хранить access-токены нет особого смысла, так как они уже несут в себе нужную информацию. В данной версии хранятся активные токены, но хранить их нерационально, так как это добавляет дополнительную нагрузку и даёт меньшую производительность. А валидацию в свою очередь каждый другой сервис должен иметь право производить за счёт своих middleware. Также хранятся refresh-токены с ключом-токеном и значением-токеном, то есть вместо памяти занимается грубо говоря в два раза больше.
Выдача refresh-токенов в открытом виде - риск перехвата. Если перехваченный access-токен не так опасен, так как его срок службы короткий(в моём случае 15 минут), то refresh-токен долгоживущий и даёт возможность генерировать новые access-токены, поэтому отдавать их в открытом виде не самая лучшая идея.
Слабое логирование и обработка ошибок. Без этого поймать ошибку очень тяжело и приходится писать костыли по типу 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 по ссылке.
Ещё остались некоторые уязвимости, связанные с unwrap'ами. Возможно в дальнейшем всплывут ещё ошибки.
Сервис планируется развивать в дальнейшем(но наверное не в ближайшее время).
Планы на развитие
Убрать все unwrap'ы
Написать trait для IO-операций
Написать trait для provider'ов ключей
Написать trait для Cryptographer'ов
