summaryrefslogtreecommitdiff
path: root/src
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 /src
parentd8d5650cc4d232215dce109f8aa3f0161079bf42 (diff)
Setup JWT utility
Diffstat (limited to 'src')
-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
6 files changed, 469 insertions, 1 deletions
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)
+}