Create session objects during login, also store auth token for users

This commit is contained in:
Z. Charles Dziura 2024-10-06 14:08:26 -04:00
parent e24cf5c0b8
commit 6057c8295d
14 changed files with 230 additions and 95 deletions

View file

@ -35,7 +35,7 @@ serde_json = "1.0"
serde_with = "3.9" serde_with = "3.9"
sqlx = { version = "0.8", features = [ sqlx = { version = "0.8", features = [
"default", "default",
"chrono", "time",
"postgres", "postgres",
"runtime-tokio", "runtime-tokio",
] } ] }

View file

@ -1,9 +1,11 @@
DROP INDEX IF EXISTS status_name_uniq_idx; 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_username_uniq_idx;
DROP INDEX IF EXISTS user_email_uniq_idx; DROP INDEX IF EXISTS user_email_uniq_idx;
DROP INDEX IF EXISTS permission_name_uniq_idx; DROP INDEX IF EXISTS permission_name_uniq_idx;
DROP TABLE IF EXISTS public.user_permission; DROP TABLE IF EXISTS public.user_permission;
DROP TABLE IF EXISTS public.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.user;
DROP TABLE IF EXISTS public.status CASCADE; DROP TABLE IF EXISTS public.status CASCADE;

View file

@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS CREATE TABLE IF NOT EXISTS
public.status ( public.status (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NULL updated_at TIMESTAMP WITH TIME ZONE NULL
@ -20,7 +20,7 @@ VALUES
CREATE TABLE IF NOT EXISTS CREATE TABLE IF NOT EXISTS
public.user ( public.user (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(255) NOT NULL, username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
@ -33,9 +33,20 @@ 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_username_uniq_idx ON public.user(username);
CREATE UNIQUE INDEX IF NOT EXISTS user_email_uniq_idx ON public.user(email); 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 CREATE TABLE IF NOT EXISTS
public.permission ( public.permission (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
status_id INT NOT NULL REFERENCES status(id) DEFAULT 1, status_id INT NOT NULL REFERENCES status(id) DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
@ -46,7 +57,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS permission_name_uniq_idx ON public.permission(
CREATE TABLE IF NOT EXISTS CREATE TABLE IF NOT EXISTS
public.user_permission ( public.user_permission (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
permission_id INT NOT NULL REFERENCES permission(id), permission_id INT NOT NULL REFERENCES permission(id),
user_id INT NOT NULL REFERENCES public.user(id), user_id INT NOT NULL REFERENCES public.user(id),
status_id INT NOT NULL REFERENCES status(id) status_id INT NOT NULL REFERENCES status(id)

View file

@ -1,6 +1,12 @@
use std::fmt::Debug; use std::{
fmt::Debug,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use blake3::Hash;
use serde::Serialize;
use sqlx::prelude::FromRow; use sqlx::prelude::FromRow;
use time::OffsetDateTime;
use tracing::error; use tracing::error;
use crate::models::AppError; use crate::models::AppError;
@ -60,19 +66,17 @@ pub async fn insert_new_user(
} }
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
pub struct UserAndHashedPassword { pub struct UserIdAndHashedPasswordEntity {
pub id: i32, pub id: i32,
pub username: String,
pub name: String,
pub password: String, pub password: String,
} }
pub async fn get_username_and_password_by_username( pub async fn get_username_and_password_by_username(
pool: &DbPool, pool: &DbPool,
username: String, username: String,
) -> Result<UserAndHashedPassword, AppError> { ) -> Result<UserIdAndHashedPasswordEntity, AppError> {
sqlx::query_as::<_, UserAndHashedPassword>( sqlx::query_as::<_, UserIdAndHashedPasswordEntity>(
"SELECT id, username, name, password FROM public.user WHERE username = $1;", "SELECT id, password FROM public.user WHERE username = $1;",
) )
.bind(username) .bind(username)
.fetch_one(pool) .fetch_one(pool)
@ -94,3 +98,75 @@ pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> {
}) })
.map(|_| ()) .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

@ -49,7 +49,7 @@ impl AppError {
Self::new(ErrorKind::MissingEnvironmentVariables(missing_vars)) Self::new(ErrorKind::MissingEnvironmentVariables(missing_vars))
} }
pub fn _missing_session_field(field: &'static str) -> Self { pub fn missing_session_field(field: &'static str) -> Self {
Self::new(ErrorKind::MissingSessionField(field)) Self::new(ErrorKind::MissingSessionField(field))
} }

View file

@ -6,7 +6,6 @@ use redis::ToRedisArgs;
#[derive(Debug)] #[derive(Debug)]
pub struct Session { pub struct Session {
pub user_id: i32, pub user_id: i32,
pub username: String,
pub created_at: SystemTime, pub created_at: SystemTime,
pub expires_at: SystemTime, pub expires_at: SystemTime,
} }
@ -17,14 +16,12 @@ impl Session {
) -> Vec<(&'static str, impl ToRedisArgs + Send + Sync + 'a)> { ) -> Vec<(&'static str, impl ToRedisArgs + Send + Sync + 'a)> {
let Self { let Self {
user_id, user_id,
username,
created_at, created_at,
expires_at, expires_at,
} = self; } = self;
vec![ vec![
("userId", user_id.to_string()), ("userId", user_id.to_string()),
("username", username),
("createdAt", format_rfc3339(created_at).to_string()), ("createdAt", format_rfc3339(created_at).to_string()),
("expiresAt", format_rfc3339(expires_at).to_string()), ("expiresAt", format_rfc3339(expires_at).to_string()),
] ]
@ -35,7 +32,6 @@ impl Default for Session {
fn default() -> Self { fn default() -> Self {
Self { Self {
user_id: Default::default(), user_id: Default::default(),
username: Default::default(),
created_at: UNIX_EPOCH, created_at: UNIX_EPOCH,
expires_at: UNIX_EPOCH, expires_at: UNIX_EPOCH,
} }

View file

@ -1,3 +1,5 @@
use std::time::SystemTime;
use axum::{ use axum::{
debug_handler, debug_handler,
extract::State, extract::State,
@ -9,15 +11,18 @@ use pasetors::{keys::SymmetricKey, version4::V4};
use tracing::debug; use tracing::debug;
use crate::{ use crate::{
db::{get_username_and_password_by_username, DbPool, UserAndHashedPassword}, db::{
models::{ApiResponse, AppError}, get_username_and_password_by_username, insert_new_user_auth_token, DbPool,
NewUserAuthTokenEntity, UserIdAndHashedPasswordEntity,
},
models::{ApiResponse, AppError, Session},
requests::{ requests::{
auth::login::models::{AuthLoginResponse, AuthLoginTokenData}, auth::login::models::{AuthLoginResponse, AuthLoginTokenData},
AppState, AppState,
}, },
services::{ services::{
auth_token::{generate_access_token, generate_auth_token}, auth_token::{generate_auth_token, generate_session_token},
verify_password, user_session, verify_password, CachePool,
}, },
}; };
@ -28,41 +33,62 @@ pub async fn auth_login_post_handler(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<AuthLoginRequest>, Json(body): Json<AuthLoginRequest>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let pool = state.db_pool(); let db_pool = state.db_pool();
let cache_pool = state.cache_pool();
let token_key = state.env().token_key(); let token_key = state.env().token_key();
auth_login_request(pool, token_key, body).await auth_login_request(db_pool, cache_pool, token_key, body).await
} }
async fn auth_login_request( async fn auth_login_request(
pool: &DbPool, db_pool: &DbPool,
cache_pool: &CachePool,
token_key: &SymmetricKey<V4>, token_key: &SymmetricKey<V4>,
body: AuthLoginRequest, body: AuthLoginRequest,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
debug!(?body); debug!(?body);
let AuthLoginRequest { username, password } = body; let AuthLoginRequest { username, password } = body;
let UserAndHashedPassword { let UserIdAndHashedPasswordEntity {
id: user_id, id: user_id,
username,
name,
password: hashed_password, password: hashed_password,
} = get_username_and_password_by_username(pool, username).await?; } = get_username_and_password_by_username(db_pool, username).await?;
verify_password(password, hashed_password)?; verify_password(password, hashed_password)?;
let (access_token, _access_token_id, access_token_expiration) = let (session_token, session_token_id, session_token_expiration) =
generate_access_token(token_key, user_id); generate_session_token(token_key, user_id);
let (auth_token, _auth_token_id, auth_token_expiration) = let (auth_token, auth_token_id, auth_token_expiration) =
generate_auth_token(token_key, user_id); 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 response = AuthLoginResponse { let response = AuthLoginResponse {
user_id, user_id,
username, session: AuthLoginTokenData {
name, token: session_token,
access: AuthLoginTokenData { expiration: session_token_expiration,
token: access_token,
expiration: access_token_expiration,
}, },
auth: AuthLoginTokenData { auth: AuthLoginTokenData {
token: auth_token, token: auth_token,

View file

@ -6,9 +6,7 @@ use serde::Serialize;
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AuthLoginResponse { pub struct AuthLoginResponse {
pub user_id: i32, pub user_id: i32,
pub username: String, pub session: AuthLoginTokenData,
pub name: String,
pub access: AuthLoginTokenData,
pub auth: AuthLoginTokenData, pub auth: AuthLoginTokenData,
} }

View file

@ -84,7 +84,6 @@ async fn register_new_user_request(
let new_user_session = Session { let new_user_session = Session {
user_id, user_id,
username,
created_at: SystemTime::now(), created_at: SystemTime::now(),
expires_at, expires_at,
}; };
@ -114,13 +113,13 @@ async fn register_new_user_request(
UserRegistrationResponse { UserRegistrationResponse {
id: user_id, id: user_id,
expires_at, expires_at,
verification_token: None, session_token: None,
} }
} else { } else {
UserRegistrationResponse { UserRegistrationResponse {
id: user_id, id: user_id,
expires_at, expires_at,
verification_token: Some(verification_token), session_token: Some(verification_token),
} }
}; };

View file

@ -13,5 +13,5 @@ pub struct UserRegistrationResponse {
#[serde(serialize_with = "humantime_serde::serialize")] #[serde(serialize_with = "humantime_serde::serialize")]
pub expires_at: SystemTime, pub expires_at: SystemTime,
pub verification_token: Option<String>, pub session_token: Option<String>,
} }

View file

@ -1,4 +1,4 @@
use std::str::FromStr; use std::{str::FromStr, time::SystemTime};
use axum::{ use axum::{
debug_handler, debug_handler,
@ -12,10 +12,13 @@ use tracing::{debug, error};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
db::{verify_user, DbPool}, db::{insert_new_user_auth_token, verify_user, DbPool, NewUserAuthTokenEntity},
models::{ApiResponse, AppError}, models::{ApiResponse, AppError, Session},
requests::AppState, requests::{user::verify::UserVerifyGetResponseTokenAndExpiration, AppState},
services::{auth_token::verify_token, user_session, CachePool}, services::{
auth_token::{generate_auth_token, generate_session_token, verify_token},
user_session, CachePool,
},
}; };
use super::{UserVerifyGetParams, UserVerifyGetResponse}; use super::{UserVerifyGetParams, UserVerifyGetResponse};
@ -57,29 +60,77 @@ async fn verify_new_user_request(
) )
.inspect_err(|err| error!(?err))?; .inspect_err(|err| error!(?err))?;
let token_id = verified_token let verification_token_id = verified_token
.payload_claims() .payload_claims()
.map(|claims| claims.get_claim("jti")) .map(|claims| claims.get_claim("jti"))
.flatten() .flatten()
.map(|jti| Uuid::from_str(jti.as_str().unwrap()).unwrap()) .map(|jti| Uuid::from_str(jti.as_str().unwrap()).unwrap())
.unwrap(); .unwrap();
user_session::exists(cache_pool, token_id) user_session::exists(cache_pool, verification_token_id)
.await .await
.and_then(|exists| { .and_then(|exists| {
if exists { if exists {
Ok(()) Ok(user_session::get_user_session(
cache_pool,
verification_token_id,
))
} else { } else {
Err(AppError::no_session_found()) Err(AppError::no_session_found())
} }
})?; })?
.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);
let session = Session {
user_id,
created_at: SystemTime::now(),
expires_at: session_token_expiration,
};
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( let response = verify_token(
token_key, token_key,
verification_token.as_str(), verification_token.as_str(),
Some(validation_rules), Some(validation_rules),
) )
.map(|_| UserVerifyGetResponse::new(token_key, user_id))?; .map(|_| UserVerifyGetResponse {
user_id,
session: UserVerifyGetResponseTokenAndExpiration {
token: session_token,
expires_at: session_token_expiration,
},
auth: UserVerifyGetResponseTokenAndExpiration {
token: auth_token,
expires_at: auth_token_expiration,
},
})?;
verify_user(db_pool, user_id) verify_user(db_pool, user_id)
.await .await

View file

@ -1,35 +1,20 @@
use humantime::format_rfc3339_seconds; use std::time::SystemTime;
use pasetors::{keys::SymmetricKey, version4::V4};
use serde::Serialize; use serde::Serialize;
use crate::services::auth_token::{generate_access_token, generate_auth_token};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserVerifyGetResponse { pub struct UserVerifyGetResponse {
access: UserVerifyGetResponseTokenAndExpiration, pub user_id: i32,
auth: UserVerifyGetResponseTokenAndExpiration, pub session: UserVerifyGetResponseTokenAndExpiration,
} pub auth: UserVerifyGetResponseTokenAndExpiration,
impl UserVerifyGetResponse {
pub fn new(key: &SymmetricKey<V4>, user_id: i32) -> Self {
let (access_token, _, access_token_expiration) = generate_access_token(key, user_id);
let (auth_token, _, auth_token_expiration) = generate_auth_token(key, user_id);
Self {
access: UserVerifyGetResponseTokenAndExpiration {
token: access_token,
expiration: format_rfc3339_seconds(access_token_expiration).to_string(),
},
auth: UserVerifyGetResponseTokenAndExpiration {
token: auth_token,
expiration: format_rfc3339_seconds(auth_token_expiration).to_string(),
},
}
}
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserVerifyGetResponseTokenAndExpiration { pub struct UserVerifyGetResponseTokenAndExpiration {
pub token: String, pub token: String,
pub expiration: String,
#[serde(serialize_with = "humantime_serde::serialize")]
pub expires_at: SystemTime,
} }

View file

@ -51,7 +51,7 @@ pub fn verify_token(
Ok(token) Ok(token)
} }
pub fn generate_access_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, Uuid, SystemTime) { pub fn generate_session_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, Uuid, SystemTime) {
generate_token(key, user_id, ONE_HOUR, None) generate_token(key, user_id, ONE_HOUR, None)
} }

View file

@ -38,7 +38,7 @@ pub async fn store_user_session(
Ok(()) Ok(())
} }
pub async fn _get_user_session( pub async fn get_user_session(
cache_pool: &CachePool, cache_pool: &CachePool,
token_id: Uuid, token_id: Uuid,
) -> Result<Option<Session>, AppError> { ) -> Result<Option<Session>, AppError> {
@ -51,41 +51,32 @@ pub async fn _get_user_session(
if let Some(object) = object { if let Some(object) = object {
let user_id: i32 = object let user_id: i32 = object
.get("userId") .get("userId")
.ok_or(AppError::_missing_session_field("userId")) .ok_or(AppError::missing_session_field("userId"))
.and_then(|value| { .and_then(|value| {
FromRedisValue::from_redis_value(value) FromRedisValue::from_redis_value(value)
.map_err(|_| AppError::_missing_session_field("userId")) .map_err(|_| AppError::missing_session_field("userId"))
})?;
let username: String = object
.get("username")
.ok_or(AppError::_missing_session_field("username"))
.and_then(|value| {
FromRedisValue::from_redis_value(value)
.map_err(|_| AppError::_missing_session_field("username"))
})?; })?;
let created_at: SystemTime = object let created_at: SystemTime = object
.get("createdAt") .get("createdAt")
.ok_or(AppError::_missing_session_field("createdAt")) .ok_or(AppError::missing_session_field("createdAt"))
.and_then(|value| { .and_then(|value| {
FromRedisValue::from_redis_value(value) FromRedisValue::from_redis_value(value)
.map_err(|_| AppError::_missing_session_field("createdAt")) .map_err(|_| AppError::missing_session_field("createdAt"))
}) })
.map(|serialized: String| parse_rfc3339(serialized.as_str()).unwrap())?; .map(|serialized: String| parse_rfc3339(serialized.as_str()).unwrap())?;
let expires_at: SystemTime = object let expires_at: SystemTime = object
.get("createdAt") .get("createdAt")
.ok_or(AppError::_missing_session_field("expiresAt")) .ok_or(AppError::missing_session_field("expiresAt"))
.and_then(|value| { .and_then(|value| {
FromRedisValue::from_redis_value(value) FromRedisValue::from_redis_value(value)
.map_err(|_| AppError::_missing_session_field("expiresAt")) .map_err(|_| AppError::missing_session_field("expiresAt"))
}) })
.map(|serialized: String| parse_rfc3339(serialized.as_str()).unwrap())?; .map(|serialized: String| parse_rfc3339(serialized.as_str()).unwrap())?;
Ok(Some(Session { Ok(Some(Session {
user_id, user_id,
username,
created_at, created_at,
expires_at, expires_at,
})) }))