Implement route to generate a new session

This commit is contained in:
Z. Charles Dziura 2024-10-07 16:48:50 -04:00
parent 6057c8295d
commit e6615454cd
16 changed files with 295 additions and 193 deletions

View file

@ -1,11 +1,9 @@
DROP INDEX IF EXISTS status_name_uniq_idx;
DROP INDEX IF EXISTS user_auth_token_hash_uniq_idx;
DROP INDEX IF EXISTS user_username_uniq_idx;
DROP INDEX IF EXISTS user_email_uniq_idx;
DROP INDEX IF EXISTS permission_name_uniq_idx;
DROP TABLE IF EXISTS public.user_permission;
DROP TABLE IF EXISTS public.permission;
DROP TABLE IF EXISTS public.user_auth_token;
DROP TABLE IF EXISTS public.user;
DROP TABLE IF EXISTS public.status CASCADE;

View file

@ -16,7 +16,7 @@ VALUES
('Active'),
('Unverified'),
('Removed'),
('Quaranteened');
('Quarantined');
CREATE TABLE IF NOT EXISTS
public.user (
@ -33,17 +33,6 @@ CREATE TABLE IF NOT EXISTS
CREATE UNIQUE INDEX IF NOT EXISTS user_username_uniq_idx ON public.user(username);
CREATE UNIQUE INDEX IF NOT EXISTS user_email_uniq_idx ON public.user(email);
CREATE TABLE IF NOT EXISTS
public.user_auth_token (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INT NOT NULL REFERENCES public.user(id),
token_hash VARCHAR(256) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS user_auth_token_hash_uniq_idx ON public.user_auth_token(token_hash);
CREATE TABLE IF NOT EXISTS
public.permission (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

View file

@ -1,12 +1,6 @@
use std::{
fmt::Debug,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use std::fmt::Debug;
use blake3::Hash;
use serde::Serialize;
use sqlx::prelude::FromRow;
use time::OffsetDateTime;
use sqlx::prelude::*;
use tracing::error;
use crate::models::AppError;
@ -98,75 +92,3 @@ pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> {
})
.map(|_| ())
}
#[derive(Clone, Debug)]
pub struct NewUserAuthTokenEntity {
pub user_id: i32,
pub token_hash: Hash,
pub expires_at: SystemTime,
}
#[derive(Debug, FromRow)]
pub struct TmpUserAuthTokenEntity {
pub id: i32,
pub user_id: i32,
pub token_hash: String,
pub expires_at: OffsetDateTime,
}
#[derive(Debug, Serialize)]
pub struct UserAuthTokenEntity {
pub id: i32,
pub user_id: i32,
pub token_hash: String,
pub expires_at: SystemTime,
}
impl From<TmpUserAuthTokenEntity> for UserAuthTokenEntity {
fn from(other: TmpUserAuthTokenEntity) -> Self {
let TmpUserAuthTokenEntity {
id,
user_id,
token_hash,
expires_at,
} = other;
Self {
id,
user_id,
token_hash,
expires_at: UNIX_EPOCH
.checked_add(Duration::new(
expires_at.clone().unix_timestamp().try_into().unwrap(),
expires_at.nanosecond(),
))
.unwrap(),
}
}
}
pub async fn insert_new_user_auth_token(
pool: &DbPool,
new_user_auth_token: NewUserAuthTokenEntity,
) -> Result<UserAuthTokenEntity, AppError> {
let NewUserAuthTokenEntity {
user_id,
token_hash,
expires_at,
} = new_user_auth_token.clone();
let token_hash = token_hash.to_string();
let expires_at = OffsetDateTime::from(expires_at);
sqlx::query_as::<_, TmpUserAuthTokenEntity>("INSERT INTO public.user_auth_token (user_id, token_hash, expires_at) VALUES ($1, $2, $3) RETURNING id, user_id, token_hash, expires_at;")
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.fetch_one(pool)
.await
.map_err(|err| {
error!(?err, record = ?new_user_auth_token, "Cannot insert new user auth token record");
AppError::from(err)
})
.map(From::from)
}

View file

@ -45,6 +45,10 @@ impl AppError {
Self::new(ErrorKind::InvalidToken)
}
pub fn missing_authorization_token() -> Self {
Self::new(ErrorKind::MissingAuthorizationToken)
}
pub fn missing_environment_variables(missing_vars: Vec<&'static str>) -> Self {
Self::new(ErrorKind::MissingEnvironmentVariables(missing_vars))
}
@ -129,6 +133,7 @@ impl Display for AppError {
ErrorKind::ExpiredToken => write!(f, "The provided token has expired"),
ErrorKind::InvalidPassword => write!(f, "Invalid password"),
ErrorKind::InvalidToken => write!(f, "The provided token is invalid"),
ErrorKind::MissingAuthorizationToken => write!(f, "Missing authorization token"),
ErrorKind::MissingEnvironmentVariables(missing_vars) => write!(
f,
"Missing required environment variables: {}",
@ -163,6 +168,7 @@ enum ErrorKind {
InvalidToken,
MissingEnvironmentVariables(Vec<&'static str>),
MissingSessionField(&'static str),
MissingAuthorizationToken,
NoDbRecordFound,
NoSessionFound,
Sqlx(SqlxError),
@ -180,7 +186,9 @@ impl IntoResponse for AppError {
StatusCode::BAD_REQUEST,
ApiResponse::new_with_error(self).into_json_response(),
),
&ErrorKind::ExpiredToken | &ErrorKind::NoSessionFound => (
&ErrorKind::ExpiredToken
| &ErrorKind::MissingAuthorizationToken
| &ErrorKind::NoSessionFound => (
StatusCode::UNAUTHORIZED,
ApiResponse::new_with_error(self).into_json_response(),
),

View file

@ -1,7 +1,20 @@
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::SystemTime;
use axum::{async_trait, extract::FromRequestParts};
use http::request::Parts;
use humantime::format_rfc3339;
use redis::ToRedisArgs;
use uuid::Uuid;
use crate::{
requests::AppState,
services::{
auth_token::{self, verify_token},
user_session,
},
};
use super::AppError;
#[derive(Debug)]
pub struct Session {
@ -28,12 +41,29 @@ impl Session {
}
}
impl Default for Session {
fn default() -> Self {
Self {
user_id: Default::default(),
created_at: UNIX_EPOCH,
expires_at: UNIX_EPOCH,
}
#[async_trait]
impl FromRequestParts<AppState> for Session {
type Rejection = AppError;
async fn from_request_parts<'a, 'b>(
parts: &'a mut Parts,
state: &'b AppState,
) -> Result<Self, Self::Rejection> {
let cache_pool = state.cache_pool();
let token_key = state.env().token_key();
let trusted_token_str = auth_token::extract_token_string_from_http_headers(&parts.headers)?;
let trusted_token = verify_token(token_key, trusted_token_str, None)?;
let token_id = trusted_token
.payload_claims()
.and_then(|claims| claims.get_claim("kid"))
.ok_or(AppError::invalid_token())
.map(|value| value.as_str().unwrap())
.and_then(|token_id| Uuid::try_from(token_id).map_err(|_| AppError::invalid_token()))?;
user_session::get_user_session(cache_pool, token_id)
.await
.and_then(|session| session.ok_or(AppError::no_session_found()))
}
}

View file

@ -11,17 +11,14 @@ use pasetors::{keys::SymmetricKey, version4::V4};
use tracing::debug;
use crate::{
db::{
get_username_and_password_by_username, insert_new_user_auth_token, DbPool,
NewUserAuthTokenEntity, UserIdAndHashedPasswordEntity,
},
db::{get_username_and_password_by_username, DbPool, UserIdAndHashedPasswordEntity},
models::{ApiResponse, AppError, Session},
requests::{
auth::login::models::{AuthLoginResponse, AuthLoginTokenData},
AppState,
},
services::{
auth_token::{generate_auth_token, generate_session_token},
auth_token::{generate_auth_token, generate_session_token, store_user_auth_token},
user_session, verify_password, CachePool,
},
};
@ -55,34 +52,8 @@ async fn auth_login_request(
verify_password(password, hashed_password)?;
let (session_token, session_token_id, session_token_expiration) =
generate_session_token(token_key, user_id);
let (auth_token, auth_token_id, auth_token_expiration) =
generate_auth_token(token_key, user_id);
insert_new_user_auth_token(
db_pool,
NewUserAuthTokenEntity {
user_id,
token_hash: blake3::hash(auth_token_id.as_bytes()),
expires_at: auth_token_expiration,
},
)
.await?;
let session = Session {
user_id,
created_at: SystemTime::now(),
expires_at: auth_token_expiration,
};
let expiration = session_token_expiration
.duration_since(SystemTime::now())
.unwrap();
user_session::store_user_session(cache_pool, session_token_id, session, Some(expiration))
.await?;
let ((session_token, session_token_expiration), (auth_token, auth_token_expiration)) =
generate_login_auth_and_session_tokens(cache_pool, token_key, user_id).await?;
let response = AuthLoginResponse {
user_id,
@ -102,3 +73,42 @@ async fn auth_login_request(
)
.into_response())
}
pub async fn generate_login_auth_and_session_tokens(
cache_pool: &CachePool,
token_key: &SymmetricKey<V4>,
user_id: i32,
) -> Result<((String, SystemTime), (String, SystemTime)), AppError> {
let (session_token, session_token_id, session_token_expiration) =
generate_session_token(token_key, user_id);
let (auth_token, _, auth_token_expiration) = generate_auth_token(token_key, user_id);
store_user_auth_token(
cache_pool,
user_id,
auth_token.as_str(),
auth_token_expiration
.duration_since(SystemTime::now())
.unwrap(),
)
.await?;
let session = Session {
user_id,
created_at: SystemTime::now(),
expires_at: auth_token_expiration,
};
let expiration = session_token_expiration
.duration_since(SystemTime::now())
.unwrap();
user_session::store_user_session(cache_pool, session_token_id, session, Some(expiration))
.await?;
Ok((
(session_token, session_token_expiration),
(auth_token, auth_token_expiration),
))
}

View file

@ -1,13 +1,28 @@
use axum::{routing::post, Router};
use axum::{
routing::{get, post},
Router,
};
use login::auth_login_post_handler;
use session::auth_session_get_handler;
use super::AppState;
mod login;
mod session;
pub use login::generate_login_auth_and_session_tokens;
pub fn requests(state: AppState) -> Router {
Router::new().route(
"/auth/login",
post(auth_login_post_handler).with_state(state),
Router::new().nest(
"/auth",
Router::new()
.route(
"/login",
post(auth_login_post_handler).with_state(state.clone()),
)
.route(
"/session",
get(auth_session_get_handler).with_state(state.clone()),
),
)
}

View file

@ -0,0 +1,76 @@
use std::time::SystemTime;
use axum::{
debug_handler,
extract::State,
response::{IntoResponse, Response},
};
use http::{HeaderMap, StatusCode};
use crate::{
models::{ApiResponse, AppError, Session},
requests::AppState,
services::{
auth_token::{self, generate_session_token, get_if_auth_token_exists, verify_token},
user_session,
},
};
use super::models::AuthSessionResponse;
#[debug_handler]
pub async fn auth_session_get_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let cache_pool = state.cache_pool();
let token_key = state.env().token_key();
let auth_token_str = auth_token::extract_token_string_from_http_headers(&headers)?;
let auth_token = verify_token(token_key, auth_token_str, None)?;
let user_id = auth_token
.payload_claims()
.and_then(|claims| claims.get_claim("sub"))
.and_then(|user_id| user_id.as_str())
.ok_or(AppError::invalid_token())
.and_then(|user_id| {
user_id
.parse::<i32>()
.map_err(|_| AppError::invalid_token())
})
.unwrap();
let auth_token_exists =
get_if_auth_token_exists(cache_pool, user_id, auth_token_str.to_string().as_str()).await?;
if !auth_token_exists {
return Err(AppError::no_session_found());
}
let (session_token, session_token_id, session_token_expiration) =
generate_session_token(token_key, user_id);
let expiration = session_token_expiration
.duration_since(SystemTime::now())
.unwrap();
let new_session = Session {
user_id,
created_at: SystemTime::now(),
expires_at: session_token_expiration,
};
user_session::store_user_session(cache_pool, session_token_id, new_session, Some(expiration))
.await?;
Ok((
StatusCode::CREATED,
ApiResponse::new(AuthSessionResponse {
token: session_token,
expiration: session_token_expiration,
})
.into_json_response(),
)
.into_response())
}

View file

@ -0,0 +1,4 @@
mod handler;
mod models;
pub use handler::*;

View file

@ -0,0 +1,3 @@
mod response;
pub use response::*;

View file

@ -0,0 +1,12 @@
use std::time::SystemTime;
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthSessionResponse {
pub token: String,
#[serde(serialize_with = "humantime_serde::serialize")]
pub expiration: SystemTime,
}

View file

@ -111,13 +111,13 @@ async fn register_new_user_request(
});
UserRegistrationResponse {
id: user_id,
user_id,
expires_at,
session_token: None,
}
} else {
UserRegistrationResponse {
id: user_id,
user_id,
expires_at,
session_token: Some(verification_token),
}

View file

@ -8,7 +8,7 @@ use serde_with::{serde_as, skip_serializing_none};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserRegistrationResponse {
pub id: i32,
pub user_id: i32,
#[serde(serialize_with = "humantime_serde::serialize")]
pub expires_at: SystemTime,

View file

@ -1,4 +1,4 @@
use std::{str::FromStr, time::SystemTime};
use std::str::FromStr;
use axum::{
debug_handler,
@ -12,13 +12,13 @@ use tracing::{debug, error};
use uuid::Uuid;
use crate::{
db::{insert_new_user_auth_token, verify_user, DbPool, NewUserAuthTokenEntity},
models::{ApiResponse, AppError, Session},
requests::{user::verify::UserVerifyGetResponseTokenAndExpiration, AppState},
services::{
auth_token::{generate_auth_token, generate_session_token, verify_token},
user_session, CachePool,
db::{verify_user, DbPool},
models::{ApiResponse, AppError},
requests::{
auth::generate_login_auth_and_session_tokens,
user::verify::UserVerifyGetResponseTokenAndExpiration, AppState,
},
services::{auth_token::verify_token, user_session, CachePool},
};
use super::{UserVerifyGetParams, UserVerifyGetResponse};
@ -81,46 +81,14 @@ async fn verify_new_user_request(
})?
.await?;
// TODO: Add new auth token to database, cache session access token
let (session_token, session_token_id, session_token_expiration) =
generate_session_token(token_key, user_id);
let (auth_token, auth_token_id, auth_token_expiration) =
generate_auth_token(token_key, user_id);
verify_user(db_pool, user_id)
.await
.inspect_err(|err| error!(?err))?;
let session = Session {
user_id,
created_at: SystemTime::now(),
expires_at: session_token_expiration,
};
let ((session_token, session_token_expiration), (auth_token, auth_token_expiration)) =
generate_login_auth_and_session_tokens(cache_pool, token_key, user_id).await?;
user_session::store_user_session(
cache_pool,
session_token_id,
session,
Some(
session_token_expiration
.duration_since(SystemTime::now())
.unwrap(),
),
)
.await?;
insert_new_user_auth_token(
db_pool,
NewUserAuthTokenEntity {
user_id,
token_hash: blake3::hash(auth_token_id.as_bytes()),
expires_at: auth_token_expiration,
},
)
.await?;
let response = verify_token(
token_key,
verification_token.as_str(),
Some(validation_rules),
)
.map(|_| UserVerifyGetResponse {
let response = UserVerifyGetResponse {
user_id,
session: UserVerifyGetResponseTokenAndExpiration {
token: session_token,
@ -130,11 +98,7 @@ async fn verify_new_user_request(
token: auth_token,
expires_at: auth_token_expiration,
},
})?;
verify_user(db_pool, user_id)
.await
.inspect_err(|err| error!(?err))?;
};
Ok((
StatusCode::OK,

View file

@ -1,5 +1,7 @@
use std::time::{Duration, SystemTime};
use http::{header::AUTHORIZATION, HeaderMap};
use humantime::format_rfc3339_seconds;
use pasetors::{
claims::{Claims, ClaimsValidationRules},
errors::{ClaimValidationError, Error as TokenError},
@ -15,10 +17,40 @@ use uuid::Uuid;
use crate::models::AppError;
use super::{cache, CachePool};
static AUTH_TOKEN_CACHE_KEY_PREFIX: &'static str = "debt_pirate:auth:";
static ONE_DAY: Duration = Duration::from_secs(86_400);
static ONE_HOUR: Duration = Duration::from_secs(3_600);
static FIFTEEN_MINUTES: Duration = Duration::from_secs(900);
pub async fn store_user_auth_token(
cache_pool: &CachePool,
user_id: i32,
auth_token: &str,
expiration: Duration,
) -> Result<(), AppError> {
let key = make_key(user_id, auth_token);
let expires_at = SystemTime::now() + expiration;
cache::store_scalar(
cache_pool,
key.as_str(),
format_rfc3339_seconds(expires_at).to_string(),
Some(expiration),
)
.await
}
pub async fn get_if_auth_token_exists(
cache_pool: &CachePool,
user_id: i32,
auth_token: &str,
) -> Result<bool, AppError> {
let key = make_key(user_id, auth_token);
cache::exists(cache_pool, key.as_str()).await
}
pub fn verify_token(
key: &SymmetricKey<V4>,
token: &str,
@ -68,6 +100,22 @@ pub fn generate_new_user_token(key: &SymmetricKey<V4>, user_id: i32) -> (String,
)
}
pub fn extract_token_string_from_http_headers<'a>(
headers: &'a HeaderMap,
) -> Result<&'a str, AppError> {
headers
.get(AUTHORIZATION)
.ok_or(AppError::missing_authorization_token())
.map(|value| {
value
.to_str()
.unwrap()
.trim_start_matches("Bearer")
.trim_start_matches("bearer")
.trim()
})
}
fn generate_token(
key: &SymmetricKey<V4>,
user_id: i32,
@ -131,6 +179,11 @@ fn map_token_error(err: TokenError) -> AppError {
}
}
fn make_key(user_id: i32, token: &str) -> String {
let token_hash = blake3::hash(token.as_bytes());
format!("{AUTH_TOKEN_CACHE_KEY_PREFIX}{user_id}:{token_hash}")
}
#[cfg(test)]
mod tests {
use base64::prelude::*;

View file

@ -67,6 +67,24 @@ pub async fn _get_object<K: FromRedisValue + Eq + Hash>(
Ok(hash_fields)
}
pub async fn store_scalar<'a, V: ToRedisArgs + Send + Sync + 'a>(
cache_pool: &CachePool,
key: &str,
scalar: V,
expiration: Option<Duration>,
) -> Result<(), AppError> {
let mut conn = get_connection_from_pool(cache_pool).await?;
let _: () = conn.set(key, scalar).await?;
if let Some(expiration) = expiration {
let _: () = conn
.expire(key, expiration.as_secs().try_into().unwrap())
.await?;
}
Ok(())
}
pub async fn exists(cache_pool: &CachePool, key: &str) -> Result<bool, AppError> {
let mut conn = get_connection_from_pool(cache_pool).await?;
conn.exists(key).await.map_err(Into::into)