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 --- ...8aa2c46169c12221e007d5519d3c7ecd423e7c3f68.json | 54 ---------- ...de6b1c9afff0e030585b44259a9fb7da9cfb10537d.json | 8 +- ...6bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json | 8 +- migrations/20240321225523_init.down.sql | 2 +- migrations/20240321225523_init.up.sql | 15 +-- 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 +- 14 files changed, 177 insertions(+), 173 deletions(-) delete mode 100644 .sqlx/query-705ed3855b2e46821ce6b28aa2c46169c12221e007d5519d3c7ecd423e7c3f68.json delete mode 100644 src/jwt.rs create mode 100644 src/routes/jwt.rs diff --git a/.sqlx/query-705ed3855b2e46821ce6b28aa2c46169c12221e007d5519d3c7ecd423e7c3f68.json b/.sqlx/query-705ed3855b2e46821ce6b28aa2c46169c12221e007d5519d3c7ecd423e7c3f68.json deleted file mode 100644 index 3d3d6c8..0000000 --- a/.sqlx/query-705ed3855b2e46821ce6b28aa2c46169c12221e007d5519d3c7ecd423e7c3f68.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO users (name,email,password_hash) VALUES ($1, $2, $3) RETURNING *", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "uuid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "password_hash", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true - ] - }, - "hash": "705ed3855b2e46821ce6b28aa2c46169c12221e007d5519d3c7ecd423e7c3f68" -} diff --git a/.sqlx/query-88ab40f815a04112c23c1dde6b1c9afff0e030585b44259a9fb7da9cfb10537d.json b/.sqlx/query-88ab40f815a04112c23c1dde6b1c9afff0e030585b44259a9fb7da9cfb10537d.json index 8a68aa6..2d82a8a 100644 --- a/.sqlx/query-88ab40f815a04112c23c1dde6b1c9afff0e030585b44259a9fb7da9cfb10537d.json +++ b/.sqlx/query-88ab40f815a04112c23c1dde6b1c9afff0e030585b44259a9fb7da9cfb10537d.json @@ -20,8 +20,8 @@ }, { "ordinal": 3, - "name": "password_hash", - "type_info": "Varchar" + "name": "session_epoch", + "type_info": "Timestamptz" }, { "ordinal": 4, @@ -44,8 +44,8 @@ false, false, false, - true, - true + false, + false ] }, "hash": "88ab40f815a04112c23c1dde6b1c9afff0e030585b44259a9fb7da9cfb10537d" diff --git a/.sqlx/query-f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json b/.sqlx/query-f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json index 909b674..9c94c0f 100644 --- a/.sqlx/query-f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json +++ b/.sqlx/query-f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json @@ -20,8 +20,8 @@ }, { "ordinal": 3, - "name": "password_hash", - "type_info": "Varchar" + "name": "session_epoch", + "type_info": "Timestamptz" }, { "ordinal": 4, @@ -44,8 +44,8 @@ false, false, false, - true, - true + false, + false ] }, "hash": "f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f" diff --git a/migrations/20240321225523_init.down.sql b/migrations/20240321225523_init.down.sql index ec52e0b..1726abe 100644 --- a/migrations/20240321225523_init.down.sql +++ b/migrations/20240321225523_init.down.sql @@ -1 +1 @@ -DROP TABLE IF EXISTS "users"; +DROP TABLE IF EXISTS sessions, users; diff --git a/migrations/20240321225523_init.up.sql b/migrations/20240321225523_init.up.sql index 7ae6aab..4ea79fe 100644 --- a/migrations/20240321225523_init.up.sql +++ b/migrations/20240321225523_init.up.sql @@ -5,10 +5,13 @@ CREATE TABLE users ( name VARCHAR(100) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(100) NOT NULL, - created_at TIMESTAMP - WITH - TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP - WITH - TIME ZONE DEFAULT NOW() + session_epoch TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + + +CREATE TABLE sessions ( + jti UUID NOT NULL PRIMARY KEY DEFAULT (uuid_generate_v4()), + uuid UUID NOT NULL REFERENCES users ON DELETE CASCADE ); 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