From d9ed52fc239e3547eb99fe03bd296ab2808d2ebc Mon Sep 17 00:00:00 2001 From: Toby Vincent Date: Mon, 8 Apr 2024 16:31:44 -0500 Subject: wip: impl jwt handling --- src/error.rs | 19 +++------ src/jwt.rs | 51 ---------------------- src/lib.rs | 1 - src/model.rs | 43 +++++++------------ src/routes.rs | 5 ++- src/routes/jwt.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ src/routes/login.rs | 5 ++- src/routes/register.rs | 15 ++++--- src/routes/user.rs | 10 +++-- 9 files changed, 159 insertions(+), 104 deletions(-) delete mode 100644 src/jwt.rs create mode 100644 src/routes/jwt.rs (limited to 'src') diff --git a/src/error.rs b/src/error.rs index 6414a13..16e341f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,4 @@ -use axum::{http::StatusCode, Json}; -use serde_json::json; +use axum::http::StatusCode; pub type Result = std::result::Result; @@ -26,6 +25,9 @@ pub enum Error { #[error("Json error: {0}")] Json(#[from] serde_json::Error), + #[error("Time error: {0}")] + Time(#[from] time::error::ComponentRange), + #[error("JSON web token error: {0}")] Jwt(#[from] jsonwebtoken::errors::Error), @@ -78,14 +80,7 @@ impl axum::response::IntoResponse for Error { _ => StatusCode::INTERNAL_SERVER_ERROR, }; - ( - status, - Json(json!({ - "status": status.to_string(), - "detail": self.to_string(), - })), - ) - .into_response() + (status, self.to_string()).into_response() } } @@ -103,8 +98,8 @@ pub enum AuthError { #[error("Invalid authorization token")] JwtValidation(#[from] jsonwebtoken::errors::Error), - #[error("Jwk not found")] - JwkNotFound, + #[error("Invalid refresh token")] + RefreshTokenNotFound, } impl axum::response::IntoResponse for AuthError { diff --git a/src/jwt.rs b/src/jwt.rs deleted file mode 100644 index 6382a01..0000000 --- a/src/jwt.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Request, State}; -use axum_extra::{ - headers::{authorization::Bearer, Authorization}, - TypedHeader, -}; -use jsonwebtoken::{DecodingKey, Validation}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{error::AuthError, state::AppState}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct Claims { - pub sub: Uuid, - pub iat: i64, - pub exp: i64, -} - -impl Claims { - pub fn new(sub: Uuid, max_age: time::Duration) -> Self { - let iat = time::OffsetDateTime::now_utc().unix_timestamp(); - let exp = iat + max_age.whole_seconds(); - Self { sub, iat, exp } - } - - pub fn encode(&self, secret: &[u8]) -> Result { - jsonwebtoken::encode( - &jsonwebtoken::Header::default(), - self, - &jsonwebtoken::EncodingKey::from_secret(secret), - ) - } -} - -pub async fn authenticate( - State(state): State>, - TypedHeader(Authorization(bearer)): TypedHeader>, - mut req: Request, -) -> Result { - let claims = jsonwebtoken::decode::( - bearer.token(), - &DecodingKey::from_secret(state.jwt_secret.as_ref()), - &Validation::default(), - )? - .claims; - - req.extensions_mut().insert(claims); - Ok(req) -} diff --git a/src/lib.rs b/src/lib.rs index 85a4577..e7502f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub use error::{Error, Result}; pub use routes::init_router; pub mod error; -pub mod jwt; pub mod model; pub mod routes; pub mod state; diff --git a/src/model.rs b/src/model.rs index 655456e..045ab98 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,13 +1,9 @@ -use std::str::FromStr; - use serde::{Deserialize, Serialize}; use sqlx::FromRow; use time::OffsetDateTime; use uuid::Uuid; -use crate::Error; - -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)] #[serde(rename_all = "camelCase")] pub struct UserSchema { pub uuid: Uuid, @@ -15,21 +11,23 @@ pub struct UserSchema { pub email: String, #[serde(default, skip_serializing)] pub password_hash: String, - pub created_at: Option, - pub updated_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RegisterSchema { - pub name: String, - pub email: String, - pub password: String, + pub session_epoch: OffsetDateTime, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, } -impl RegisterSchema { - pub fn validate(&self) -> Result<(), Error> { - email_address::EmailAddress::from_str(&self.email)?; - Ok(()) +impl Default for UserSchema { + fn default() -> Self { + let now = time::OffsetDateTime::now_utc(); + Self { + uuid: Default::default(), + name: Default::default(), + email: Default::default(), + password_hash: Default::default(), + session_epoch: now, + created_at: now, + updated_at: now, + } } } @@ -38,12 +36,3 @@ pub struct LoginSchema { pub email: String, pub password: String, } - -impl From for LoginSchema { - fn from(value: RegisterSchema) -> Self { - let RegisterSchema { - email, password, .. - } = value; - Self { email, password } - } -} diff --git a/src/routes.rs b/src/routes.rs index 897b3cb..73a6dc4 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -8,9 +8,12 @@ use axum::{ use axum_extra::routing::RouterExt; use tower_http::cors::CorsLayer; -use crate::{jwt::authenticate, state::AppState}; +use crate::state::AppState; + +use self::jwt::authenticate; mod healthcheck; +mod jwt; mod login; mod register; mod user; diff --git a/src/routes/jwt.rs b/src/routes/jwt.rs new file mode 100644 index 0000000..6a229a3 --- /dev/null +++ b/src/routes/jwt.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + response::IntoResponse, +}; +use axum_extra::{ + extract::{cookie::Cookie, CookieJar}, + headers::{authorization::Bearer, Authorization}, + routing::TypedPath, + TypedHeader, +}; +use jsonwebtoken::{DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{error::AuthError, state::AppState, Error}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Claims { + pub sub: Uuid, + pub iat: i64, + pub exp: i64, + pub jti: Uuid, +} + +impl Claims { + const MAX_AGE: i64 = 3600; + + pub fn new(sub: Uuid) -> Self { + let iat = OffsetDateTime::now_utc().unix_timestamp(); + let exp = iat + Self::MAX_AGE; + let jti = uuid::Uuid::new_v4(); + Self { sub, iat, exp, jti } + } + + pub fn encode(&self, secret: &[u8]) -> Result { + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + self, + &jsonwebtoken::EncodingKey::from_secret(secret), + ) + } +} + +impl From for Claims { + fn from(value: Uuid) -> Self { + Self::new(value) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct Session { + jti: Uuid, + uuid: Uuid, +} + +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/auth/refresh")] +pub struct Refresh; + +impl Refresh { + #[tracing::instrument] + pub async fn post( + self, + State(state): State>, + TypedHeader(Authorization(bearer)): TypedHeader>, + cookie_jar: CookieJar, + ) -> Result { + let Claims { sub, .. } = jsonwebtoken::decode::( + bearer.token(), + &DecodingKey::from_secret(state.jwt_secret.as_ref()), + &Validation::default(), + )? + .claims; + + let claims = Claims::from(sub); + + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &claims, + &jsonwebtoken::EncodingKey::from_secret(state.jwt_secret.as_ref()), + )?; + + let cookie = Cookie::build(("token", token)) + .expires(OffsetDateTime::from_unix_timestamp(claims.exp)?) + .secure(true) + .http_only(true); + + Ok(cookie_jar.add(cookie)) + } +} + +pub async fn authenticate( + State(state): State>, + cookie_jar: CookieJar, + mut req: Request, +) -> Result { + let token = cookie_jar + .get("token") + .ok_or(AuthError::JwtNotFound)? + .to_string(); + + let claims = jsonwebtoken::decode::( + &token, + &DecodingKey::from_secret(state.jwt_secret.as_ref()), + &Validation::default(), + )? + .claims; + + req.extensions_mut().insert(claims); + Ok(req) +} diff --git a/src/routes/login.rs b/src/routes/login.rs index 67f8422..8843bd5 100644 --- a/src/routes/login.rs +++ b/src/routes/login.rs @@ -7,12 +7,13 @@ use serde::Deserialize; use crate::{ error::AuthError, - jwt::Claims, model::{LoginSchema, UserSchema}, state::AppState, Error, }; +use super::jwt::Claims; + #[derive(Debug, Deserialize, TypedPath)] #[typed_path("/api/login")] pub struct Login; @@ -40,7 +41,7 @@ impl Login { Argon2::default() .verify_password(password.as_bytes(), &PasswordHash::new(&password_hash)?)?; - let token = Claims::new(uuid, state.jwt_max_age).encode(state.jwt_secret.as_ref())?; + let token = Claims::from(uuid).encode(state.jwt_secret.as_ref())?; Authorization::bearer(&token) .map(TypedHeader) diff --git a/src/routes/register.rs b/src/routes/register.rs index d2a570c..2181808 100644 --- a/src/routes/register.rs +++ b/src/routes/register.rs @@ -6,13 +6,16 @@ use argon2::{ }; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use axum_extra::routing::TypedPath; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -use crate::{ - model::{RegisterSchema, UserSchema}, - state::AppState, - Error, -}; +use crate::{model::UserSchema, state::AppState, Error}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegisterSchema { + pub name: String, + pub email: String, + pub password: String, +} #[derive(Debug, Deserialize, TypedPath)] #[typed_path("/api/register")] diff --git a/src/routes/user.rs b/src/routes/user.rs index e6e5c3d..3663ec6 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -4,7 +4,9 @@ use axum::{extract::State, response::IntoResponse, Extension, Json}; use axum_extra::routing::TypedPath; use serde::Deserialize; -use crate::{jwt::Claims, model::UserSchema, state::AppState, Error}; +use crate::{model::UserSchema, state::AppState, Error}; + +use super::jwt::Claims; #[derive(Debug, Deserialize, TypedPath)] #[typed_path("/api/user/:uuid")] @@ -33,7 +35,7 @@ impl User { pub async fn get( self, State(state): State>, - Extension(Claims { sub, iat, exp }): Extension, + Extension(Claims { sub, .. }): Extension, ) -> Result { sqlx::query_as!(UserSchema, "SELECT * FROM users WHERE uuid = $1", sub) .fetch_optional(&state.pool) @@ -136,7 +138,7 @@ mod tests { }); let router = init_router(state.clone()); - let token = Claims::new(UUID, JWT_MAX_AGE).encode(JWT_SECRET.as_ref())?; + let token = Claims::from(UUID).encode(JWT_SECRET.as_ref())?; let request = Request::builder() .uri("/api/user") @@ -168,7 +170,7 @@ mod tests { }); let router = init_router(state.clone()); - let token = Claims::new(UUID, JWT_MAX_AGE).encode("BAD_SECRET".as_ref())?; + let token = Claims::from(UUID).encode("BAD_SECRET".as_ref())?; let request = Request::builder() .uri("/api/user") -- cgit v1.2.3-70-g09d2