summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorToby Vincent <tobyv@tobyvin.dev>2024-04-08 16:31:44 -0500
committerToby Vincent <tobyv@tobyvin.dev>2024-04-11 23:49:41 -0500
commitd9ed52fc239e3547eb99fe03bd296ab2808d2ebc (patch)
tree2fdc8a0e33bdf0902f608daa8e41d61df80ea9b2
parent9a6c04d52edb10431f9f5ca2dbc83c410cb5daee (diff)
wip: impl jwt handling
-rw-r--r--.sqlx/query-705ed3855b2e46821ce6b28aa2c46169c12221e007d5519d3c7ecd423e7c3f68.json54
-rw-r--r--.sqlx/query-88ab40f815a04112c23c1dde6b1c9afff0e030585b44259a9fb7da9cfb10537d.json8
-rw-r--r--.sqlx/query-f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json8
-rw-r--r--migrations/20240321225523_init.down.sql2
-rw-r--r--migrations/20240321225523_init.up.sql15
-rw-r--r--src/error.rs19
-rw-r--r--src/jwt.rs51
-rw-r--r--src/lib.rs1
-rw-r--r--src/model.rs43
-rw-r--r--src/routes.rs5
-rw-r--r--src/routes/jwt.rs114
-rw-r--r--src/routes/login.rs5
-rw-r--r--src/routes/register.rs15
-rw-r--r--src/routes/user.rs10
14 files changed, 177 insertions, 173 deletions
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<T, E = Error> = std::result::Result<T, E>;
@@ -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<String, jsonwebtoken::errors::Error> {
- jsonwebtoken::encode(
- &jsonwebtoken::Header::default(),
- self,
- &jsonwebtoken::EncodingKey::from_secret(secret),
- )
- }
-}
-
-pub async fn authenticate(
- State(state): State<Arc<AppState>>,
- TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
- mut req: Request,
-) -> Result<Request, AuthError> {
- let claims = jsonwebtoken::decode::<Claims>(
- 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<OffsetDateTime>,
- pub updated_at: Option<OffsetDateTime>,
-}
-
-#[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<RegisterSchema> 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<String, jsonwebtoken::errors::Error> {
+ jsonwebtoken::encode(
+ &jsonwebtoken::Header::default(),
+ self,
+ &jsonwebtoken::EncodingKey::from_secret(secret),
+ )
+ }
+}
+
+impl From<Uuid> 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<Arc<AppState>>,
+ TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
+ cookie_jar: CookieJar,
+ ) -> Result<impl IntoResponse, Error> {
+ let Claims { sub, .. } = jsonwebtoken::decode::<Claims>(
+ 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<Arc<AppState>>,
+ cookie_jar: CookieJar,
+ mut req: Request,
+) -> Result<Request, AuthError> {
+ let token = cookie_jar
+ .get("token")
+ .ok_or(AuthError::JwtNotFound)?
+ .to_string();
+
+ let claims = jsonwebtoken::decode::<Claims>(
+ &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<Arc<AppState>>,
- Extension(Claims { sub, iat, exp }): Extension<Claims>,
+ Extension(Claims { sub, .. }): Extension<Claims>,
) -> Result<impl IntoResponse, Error> {
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")