- PVSM.RU - https://www.pvsm.ru -
Работая над одним своим проектом я задумался о необходимости авторизации для его публичного запуска. В самом проекте я всем этим заниматься не стал, а решил разработать отдельный сервис авторизации на Rust, который в дальнейшем можно будет "прикручивать" к разным проектам с небольшими доработками. Для удобства доработок мне в дальнейшем пришла идея создания абстракций для подходящих под них модулей.
Если говорить кратко, то сервис должен уметь генерировать access- и refresh-токены, хранить их и валидировать. Но если честно, то сказать кратко будет неверно, так как на практике всё получилось намного сложнее и запутаннее.
Всё было бы смешно, когда бы не было так грустно.
Создание первой версии началось полным хаосом и непониманием работы auth-механизмов и -логики. В данную тему я раньше вообще почти не погружался, поэтому разбираться во всём приходилось по ходу разработки, из-за чего та шла очень динамично. Каждый день находились новые уязвимости, которые приходилось фиксить.
Сразу скажу, что в первой версии есть довольно много серьёзных уязвимостей, о которых я скажу в конце.
В сервисе будут два вида токенов: access и refresh.
По специфике генерации были выделены две группы генераторов - Jwt и Opaque. Все генераторы будут иметь только одну единственную функцию - generate.
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.
Основная разница между JWS и JWE заключается в цели защиты данных: JWS(JSON Web Signature) подписывает данные для проверки их целостности, оставляя их читаемыми, а JWE(JSON Web Encryption) шифрует данные, обеспечивая их конфиденциальность. JWS используется для аутентификации, а JWE — для безопасной передачи секретной информации.
Поэтому будет использоваться именно JWS.
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 байта
}
}
Несмотря на то, что мы генерируем 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 в 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(Privacy Enhanced Mail) это стандарт хранения и передачи публичных ключей и сертификатов.
PEM-файл может содержать сертификат, пару ключей, или их комбинацию. Ключи могут содержать цепочку сертификатов от различных центров сертификации (certificate authorities).
У 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'а.
Предлагаю для начала посмотреть на конструктор 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.
Первым опять же следует рассмотреть конструктор. Он принимает на вход название папки для хранения ключей. Затем создаётся 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(())
}
Большое количество unwrap, вызывающих panic при ошибке.
Нерациональное хранение токенов. Хранить access-токены нет особого смысла, так как они уже несут в себе нужную информацию. В данной версии хранятся активные токены, но хранить их нерационально, так как это добавляет дополнительную нагрузку и даёт меньшую производительность. А валидацию в свою очередь каждый другой сервис должен иметь право производить за счёт своих middleware. Также хранятся refresh-токены с ключом-токеном и значением-токеном, то есть вместо памяти занимается грубо говоря в два раза больше.
Выдача refresh-токенов в открытом виде - риск перехвата. Если перехваченный access-токен не так опасен, так как его срок службы короткий(в моём случае 15 минут), то refresh-токен долгоживущий и даёт в��зможность генерировать новые access-токены, поэтому отдавать их в открытом виде не самая лучшая идея.
Слабое логирование и обработка ошибок. Без этого поймать ошибку очень тяжело и приходится писать костыли по типу println!().
Фикситься эти проблемы будут в следующих версиях.
К этому моменту мы уже имеем работающий, но сырой сервис и некоторый список проблем.
Первой для решения оказалась проблема избыточного хранения токенов, поэтому был переработан 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).
Также были сделаны некоторые поправки по синтаксису, что не влияет на логику работы.
В этой версии упор делался на апгрейд безопасности и error handling.
То есть было принято решение перестать возвращать refresh-токен в открытом виде, а вместо этого хранить его в Redis, а пользователю возвращать зашифрованный вариант. Для этого пришлось подразобраться в криптографии и алгоритмах шифрования. В итоге я разработал модуль AesGcmCryptographer.
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 - это уникальное случайное или псевдослучайное число, применяемое в криптографии, блокчейне и 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'ами. Возможно в дальнейшем всплывут ещё ошибки.
Сервис планируется развивать в дальнейшем(но наверное не в ближайшее время).
Убрать все unwrap'ы
Написать trait для IO-операций
Написать trait для provider'ов ключей
Написать 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
Нажмите здесь для печати.