summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormrw1593 <botahamec@outlook.com>2023-06-04 13:41:32 -0400
committermrw1593 <botahamec@outlook.com>2023-06-04 13:41:32 -0400
commit47fb9177c7d9d6d3b4e75aeb55a94ef236c807a6 (patch)
tree13cf376fb4a33ef6e22aac7e5b498bb8d988107a
parentd8d5650cc4d232215dce109f8aa3f0161079bf42 (diff)
Setup JWT utility
-rw-r--r--Cargo.lock63
-rw-r--r--Cargo.toml6
-rw-r--r--src/services/crypto.rs2
-rw-r--r--src/services/db.rs2
-rw-r--r--src/services/db/jwt.rs199
-rw-r--r--src/services/jwt.rs258
-rw-r--r--src/services/mod.rs1
-rw-r--r--src/services/secrets.rs8
8 files changed, 531 insertions, 8 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 5947f93..409ae6a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 045bea4..aa5f668 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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)
+}