diff options
| author | mrw1593 <botahamec@outlook.com> | 2023-06-04 13:41:32 -0400 |
|---|---|---|
| committer | mrw1593 <botahamec@outlook.com> | 2023-06-04 13:41:32 -0400 |
| commit | 47fb9177c7d9d6d3b4e75aeb55a94ef236c807a6 (patch) | |
| tree | 13cf376fb4a33ef6e22aac7e5b498bb8d988107a | |
| parent | d8d5650cc4d232215dce109f8aa3f0161079bf42 (diff) | |
Setup JWT utility
| -rw-r--r-- | Cargo.lock | 63 | ||||
| -rw-r--r-- | Cargo.toml | 6 | ||||
| -rw-r--r-- | src/services/crypto.rs | 2 | ||||
| -rw-r--r-- | src/services/db.rs | 2 | ||||
| -rw-r--r-- | src/services/db/jwt.rs | 199 | ||||
| -rw-r--r-- | src/services/jwt.rs | 258 | ||||
| -rw-r--r-- | src/services/mod.rs | 1 | ||||
| -rw-r--r-- | src/services/secrets.rs | 8 |
8 files changed, 531 insertions, 8 deletions
@@ -187,7 +187,7 @@ dependencies = [ "serde_urlencoded", "smallvec", "socket2", - "time", + "time 0.3.20", "url", ] @@ -436,8 +436,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ "iana-time-zone", + "js-sys", "num-integer", "num-traits", + "serde", + "time 0.1.45", + "wasm-bindgen", "winapi", ] @@ -515,7 +519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ "percent-encoding", - "time", + "time 0.3.20", "version_check", ] @@ -629,12 +633,13 @@ checksum = "850878694b7933ca4c9569d30a34b55031b9b139ee1fc7b94a527c4ef960d690" [[package]] name = "digest" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -791,7 +796,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -922,6 +927,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] name = "http" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1056,6 +1070,21 @@ dependencies = [ ] [[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + +[[package]] name = "language-tags" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1163,7 +1192,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] @@ -1640,10 +1669,13 @@ version = "0.1.0" dependencies = [ "actix-web", "base64 0.21.0", + "chrono", "dotenv", "exun", "grass", "hex", + "hmac", + "jwt", "log", "parking_lot 0.12.1", "path-clean", @@ -1653,6 +1685,7 @@ dependencies = [ "rust-ini", "serde", "serde_urlencoded", + "sha2", "sqlx", "tera", "thiserror", @@ -1890,6 +1923,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "digest", @@ -2070,6 +2104,17 @@ dependencies = [ [[package]] name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "time" version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" @@ -2377,6 +2422,12 @@ dependencies = [ [[package]] name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" @@ -18,12 +18,16 @@ raise = "2" exun = "0.1" base64 = "0.21" rust-ini = "0.18" +jwt = "0.16" dotenv = "0.15" +hmac = "0.12" parking_lot = "0.12" grass = "0.12" +sha2 = "0.10" unic-langid = { version = "0.9", features = ["serde"] } rand = "0.8" serde_urlencoded = "0.7" -sqlx = { version = "0.6", features = [ "runtime-actix-rustls", "mysql", "uuid", "offline" ] } +sqlx = { version = "0.6", features = [ "runtime-actix-rustls", "mysql", "uuid", "chrono", "offline" ] } log = "0.4" +chrono = { version = "0.4", features = ["serde"] } hex = "0.4" 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<Self, RawUnexpected> { 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<bool, RawUnexpected> { + 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<bool, RawUnexpected> { + 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<bool, RawUnexpected> { + 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<bool, RawUnexpected> { + 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<Utc>, +) -> 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<Utc>, +) -> 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<Utc>, +) -> 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<bool, RawUnexpected> { + 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<bool, RawUnexpected> { + 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<bool, RawUnexpected> { + 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<bool, RawUnexpected> { + 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<Box<[String]>>, + #[serde(with = "ts_milliseconds")] + exp: DateTime<Utc>, + #[serde(with = "ts_milliseconds_option")] + nbf: Option<DateTime<Utc>>, + #[serde(with = "ts_milliseconds_option")] + iat: Option<DateTime<Utc>>, + jti: Uuid, + scope: Box<str>, + 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<Self, RawUnexpected> { + 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<Self, RawUnexpected> { + 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<Self, RawUnexpected> { + 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<Self, RawUnexpected> { + 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<Box<str>, 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<Claims, Expect<VerifyJwtError>> { + 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<Claims, Expect<VerifyJwtError>> { + 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<Claims, Expect<VerifyJwtError>> { + 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<Claims, Expect<VerifyJwtError>> { + 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<Box<[u8]>, RawUnexpected> { pub fn database_url() -> Result<String, RawUnexpected> { env::var("DATABASE_URL").unexpect() } + +pub fn signing_key() -> Result<Hmac<Sha256>, RawUnexpected> { + let key = env::var("PRIVATE_KEY")?; + let key = Hmac::<Sha256>::new_from_slice(key.as_bytes())?; + Ok(key) +} |
