From 47fb9177c7d9d6d3b4e75aeb55a94ef236c807a6 Mon Sep 17 00:00:00 2001 From: mrw1593 Date: Sun, 4 Jun 2023 13:41:32 -0400 Subject: Setup JWT utility --- src/services/crypto.rs | 2 +- src/services/db.rs | 2 + src/services/db/jwt.rs | 199 +++++++++++++++++++++++++++++++++++++ src/services/jwt.rs | 258 ++++++++++++++++++++++++++++++++++++++++++++++++ src/services/mod.rs | 1 + src/services/secrets.rs | 8 ++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 src/services/db/jwt.rs create mode 100644 src/services/jwt.rs (limited to 'src') diff --git a/src/services/crypto.rs b/src/services/crypto.rs index 9c36c57..5fce403 100644 --- a/src/services/crypto.rs +++ b/src/services/crypto.rs @@ -49,7 +49,7 @@ impl PasswordHash { pub fn new(password: &str) -> Result { let password = password.as_bytes(); - let salt: [u8; 16] = rand::random(); + let salt: [u8; 32] = rand::random(); let salt = Box::from(salt); let pepper = pepper()?; let hash = hash_raw(password, &salt, &config(&pepper))?.into_boxed_slice(); diff --git a/src/services/db.rs b/src/services/db.rs index 9789e51..f811d79 100644 --- a/src/services/db.rs +++ b/src/services/db.rs @@ -2,8 +2,10 @@ use exun::{RawUnexpected, ResultErrorExt}; use sqlx::MySqlPool; mod client; +mod jwt; mod user; +pub use self::jwt::*; pub use client::*; pub use user::*; diff --git a/src/services/db/jwt.rs b/src/services/db/jwt.rs new file mode 100644 index 0000000..a3edef2 --- /dev/null +++ b/src/services/db/jwt.rs @@ -0,0 +1,199 @@ +use chrono::{DateTime, Utc}; +use exun::{RawUnexpected, ResultErrorExt}; +use sqlx::{query, query_scalar, Executor, MySql}; +use uuid::Uuid; + +use crate::services::jwt::RevokedRefreshTokenReason; + +pub async fn auth_code_exists<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, +) -> Result { + query_scalar!( + "SELECT EXISTS(SELECT jti FROM auth_codes WHERE jti = ?) as `e: bool`", + jti + ) + .fetch_one(executor) + .await + .unexpect() +} + +pub async fn access_token_exists<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, +) -> Result { + query_scalar!( + "SELECT EXISTS(SELECT jti FROM access_tokens WHERE jti = ?) as `e: bool`", + jti + ) + .fetch_one(executor) + .await + .unexpect() +} + +pub async fn refresh_token_exists<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, +) -> Result { + query_scalar!( + "SELECT EXISTS(SELECT jti FROM refresh_tokens WHERE jti = ?) as `e: bool`", + jti + ) + .fetch_one(executor) + .await + .unexpect() +} + +pub async fn refresh_token_revoked<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, +) -> Result { + let result = query_scalar!( + r"SELECT EXISTS( + SELECT revoked_reason FROM refresh_tokens WHERE jti = ? and revoked_reason IS NOT NULL + ) as `e: bool`", + jti + ) + .fetch_one(executor) + .await? + .unwrap_or(true); + + Ok(result) +} + +pub async fn create_auth_code<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, + exp: DateTime, +) -> Result<(), sqlx::Error> { + query!( + r"INSERT INTO auth_codes (jti, exp) + VALUES ( ?, ?)", + jti, + exp + ) + .execute(executor) + .await?; + + Ok(()) +} + +pub async fn create_access_token<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, + auth_code: Uuid, + exp: DateTime, +) -> Result<(), sqlx::Error> { + query!( + r"INSERT INTO access_tokens (jti, auth_code, exp) + VALUES ( ?, ?, ?)", + jti, + auth_code, + exp + ) + .execute(executor) + .await?; + + Ok(()) +} + +pub async fn create_refresh_token<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, + auth_code: Uuid, + exp: DateTime, +) -> Result<(), sqlx::Error> { + query!( + r"INSERT INTO access_tokens (jti, auth_code, exp) + VALUES ( ?, ?, ?)", + jti, + auth_code, + exp + ) + .execute(executor) + .await?; + + Ok(()) +} + +pub async fn delete_auth_code<'c>( + executor: impl Executor<'c, Database = MySql>, + auth_code: Uuid, +) -> Result { + let result = query!("DELETE FROM auth_codes WHERE jti = ?", auth_code) + .execute(executor) + .await?; + + Ok(result.rows_affected() != 0) +} + +pub async fn delete_expired_auth_codes<'c>( + executor: impl Executor<'c, Database = MySql>, +) -> Result<(), RawUnexpected> { + query!("DELETE FROM auth_codes WHERE exp < ?", Utc::now()) + .execute(executor) + .await?; + + Ok(()) +} + +pub async fn delete_access_tokens_with_auth_code<'c>( + executor: impl Executor<'c, Database = MySql>, + auth_code: Uuid, +) -> Result { + let result = query!("DELETE FROM access_tokens WHERE auth_code = ?", auth_code) + .execute(executor) + .await?; + + Ok(result.rows_affected() != 0) +} + +pub async fn delete_expired_access_tokens<'c>( + executor: impl Executor<'c, Database = MySql>, +) -> Result<(), RawUnexpected> { + query!("DELETE FROM access_tokens WHERE exp < ?", Utc::now()) + .execute(executor) + .await?; + + Ok(()) +} + +pub async fn revoke_refresh_token<'c>( + executor: impl Executor<'c, Database = MySql>, + jti: Uuid, +) -> Result { + let result = query!( + "UPDATE refresh_tokens SET revoked_reason = ? WHERE jti = ?", + RevokedRefreshTokenReason::NewRefreshToken, + jti + ) + .execute(executor) + .await?; + + Ok(result.rows_affected() != 0) +} + +pub async fn revoke_refresh_tokens_with_auth_code<'c>( + executor: impl Executor<'c, Database = MySql>, + auth_code: Uuid, +) -> Result { + let result = query!( + "UPDATE refresh_tokens SET revoked_reason = ? WHERE auth_code = ?", + RevokedRefreshTokenReason::ReusedAuthorizationCode, + auth_code + ) + .execute(executor) + .await?; + + Ok(result.rows_affected() != 0) +} + +pub async fn delete_expired_refresh_tokens<'c>( + executor: impl Executor<'c, Database = MySql>, +) -> Result<(), RawUnexpected> { + query!("DELETE FROM refresh_tokens WHERE exp < ?", Utc::now()) + .execute(executor) + .await?; + + Ok(()) +} diff --git a/src/services/jwt.rs b/src/services/jwt.rs new file mode 100644 index 0000000..7841afb --- /dev/null +++ b/src/services/jwt.rs @@ -0,0 +1,258 @@ +use chrono::{serde::ts_milliseconds, serde::ts_milliseconds_option, DateTime, Duration, Utc}; +use exun::{Expect, RawUnexpected, ResultErrorExt}; +use jwt::{SignWithKey, VerifyWithKey}; +use raise::yeet; +use serde::{Deserialize, Serialize}; +use sqlx::{Executor, MySql, MySqlPool}; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +use super::{db, id::new_id, secrets}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TokenType { + Authorization, + Access, + Refresh, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + iss: Url, + aud: Option>, + #[serde(with = "ts_milliseconds")] + exp: DateTime, + #[serde(with = "ts_milliseconds_option")] + nbf: Option>, + #[serde(with = "ts_milliseconds_option")] + iat: Option>, + jti: Uuid, + scope: Box, + client_id: Uuid, + auth_code_id: Uuid, + token_type: TokenType, +} + +#[derive(Debug, Clone, Copy, sqlx::Type)] +#[sqlx(rename_all = "kebab-case")] +pub enum RevokedRefreshTokenReason { + ReusedAuthorizationCode, + NewRefreshToken, +} + +impl Claims { + pub async fn auth_code<'c>( + db: MySqlPool, + self_id: Url, + client_id: Uuid, + scopes: &str, + ) -> Result { + let five_minutes = Duration::minutes(5); + + let id = new_id(&db, db::auth_code_exists).await?; + let time = Utc::now(); + let exp = time + five_minutes; + + db::create_auth_code(&db, id, exp).await?; + + Ok(Self { + iss: self_id, + aud: None, + exp, + nbf: None, + iat: Some(time), + jti: id, + scope: scopes.into(), + client_id, + auth_code_id: id, + token_type: TokenType::Authorization, + }) + } + + pub async fn access_token<'c>( + db: MySqlPool, + auth_code_id: Uuid, + self_id: Url, + client_id: Uuid, + duration: Duration, + scopes: &str, + ) -> Result { + let id = new_id(&db, db::access_token_exists).await?; + let time = Utc::now(); + let exp = time + duration; + + db::create_access_token(&db, id, auth_code_id, exp) + .await + .unexpect()?; + + Ok(Self { + iss: self_id, + aud: None, + exp, + nbf: None, + iat: Some(time), + jti: id, + scope: scopes.into(), + client_id, + auth_code_id, + token_type: TokenType::Access, + }) + } + + pub async fn refresh_token(db: MySqlPool, other_token: Claims) -> Result { + let one_day = Duration::days(1); + + let id = new_id(&db, db::refresh_token_exists).await?; + let time = Utc::now(); + let exp = other_token.exp + one_day; + + db::create_refresh_token(&db, id, other_token.auth_code_id, exp).await?; + + let mut claims = other_token; + claims.exp = exp; + claims.iat = Some(time); + claims.jti = id; + claims.token_type = TokenType::Refresh; + + Ok(claims) + } + + pub async fn refreshed_access_token( + db: MySqlPool, + refresh_token: Claims, + exp_time: Duration, + ) -> Result { + let id = new_id(&db, db::access_token_exists).await?; + let time = Utc::now(); + let exp = time + exp_time; + + db::create_access_token(&db, id, refresh_token.auth_code_id, exp).await?; + + let mut claims = refresh_token; + claims.exp = exp; + claims.iat = Some(time); + claims.jti = id; + claims.token_type = TokenType::Access; + + Ok(claims) + } + + pub fn id(&self) -> Uuid { + self.jti + } + + pub fn scopes(&self) -> &str { + &self.scope + } + + pub fn to_jwt(&self) -> Result, RawUnexpected> { + let key = secrets::signing_key()?; + let jwt = self.sign_with_key(&key)?.into_boxed_str(); + Ok(jwt) + } +} + +#[derive(Debug, Error)] +pub enum VerifyJwtError { + #[error("{0}")] + ParseJwtError(#[from] jwt::Error), + #[error("The issuer for this token is incorrect")] + IncorrectIssuer, + #[error("This bearer token was intended for a different client")] + WrongClient, + #[error("The given audience parameter does not contain this issuer")] + BadAudience, + #[error("The token is expired")] + ExpiredToken, + #[error("The token cannot be used yet")] + NotYet, + #[error("The bearer token has been revoked")] + JwtRevoked, +} + +fn verify_jwt( + token: &str, + self_id: Url, + client_id: Uuid, +) -> Result> { + let key = secrets::signing_key()?; + let claims: Claims = token + .verify_with_key(&key) + .map_err(|e| VerifyJwtError::from(e))?; + + if claims.iss != self_id { + yeet!(VerifyJwtError::IncorrectIssuer.into()) + } + + if claims.client_id != client_id { + yeet!(VerifyJwtError::WrongClient.into()) + } + + if let Some(aud) = claims.aud.clone() { + if !aud.contains(&self_id.to_string()) { + yeet!(VerifyJwtError::BadAudience.into()) + } + } + + let now = Utc::now(); + + if now > claims.exp { + yeet!(VerifyJwtError::ExpiredToken.into()) + } + + if let Some(nbf) = claims.nbf { + if now < nbf { + yeet!(VerifyJwtError::NotYet.into()) + } + } + + Ok(claims) +} + +pub async fn verify_auth_code<'c>( + db: MySqlPool, + token: &str, + self_id: Url, + client_id: Uuid, +) -> Result> { + let claims = verify_jwt(token, self_id, client_id)?; + + if db::delete_auth_code(&db, claims.jti).await? { + db::delete_access_tokens_with_auth_code(&db, claims.jti).await?; + db::revoke_refresh_tokens_with_auth_code(&db, claims.jti).await?; + yeet!(VerifyJwtError::JwtRevoked.into()); + } + + Ok(claims) +} + +pub async fn verify_access_token<'c>( + db: impl Executor<'c, Database = MySql>, + token: &str, + self_id: Url, + client_id: Uuid, +) -> Result> { + let claims = verify_jwt(token, self_id, client_id)?; + + if !db::access_token_exists(db, claims.jti).await? { + yeet!(VerifyJwtError::JwtRevoked.into()) + } + + Ok(claims) +} + +pub async fn verify_refresh_token<'c>( + db: impl Executor<'c, Database = MySql>, + token: &str, + self_id: Url, + client_id: Uuid, +) -> Result> { + let claims = verify_jwt(token, self_id, client_id)?; + + if db::refresh_token_revoked(db, claims.jti).await? { + yeet!(VerifyJwtError::JwtRevoked.into()) + } + + Ok(claims) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index deab694..5339594 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,4 +2,5 @@ pub mod authorization; pub mod crypto; pub mod db; pub mod id; +pub mod jwt; pub mod secrets; diff --git a/src/services/secrets.rs b/src/services/secrets.rs index 9f8af54..241b2c5 100644 --- a/src/services/secrets.rs +++ b/src/services/secrets.rs @@ -1,6 +1,8 @@ use std::env; use exun::*; +use hmac::{Hmac, Mac}; +use sha2::Sha256; /// This is a secret salt, needed for creating passwords. It's used as an extra /// layer of security, on top of the salt that's already used. @@ -14,3 +16,9 @@ pub fn pepper() -> Result, RawUnexpected> { pub fn database_url() -> Result { env::var("DATABASE_URL").unexpect() } + +pub fn signing_key() -> Result, RawUnexpected> { + let key = env::var("PRIVATE_KEY")?; + let key = Hmac::::new_from_slice(key.as_bytes())?; + Ok(key) +} -- cgit v1.2.3