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"
sqlx = { version = "0.8", features = [
"default",
"chrono",
"time",
"postgres",
"runtime-tokio",
] }

View file

@ -1,9 +1,11 @@
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

@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS
public.status (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NULL
@ -20,7 +20,7 @@ VALUES
CREATE TABLE IF NOT EXISTS
public.user (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(255) NOT NULL,
password 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_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 INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
status_id INT NOT NULL REFERENCES status(id) DEFAULT 1,
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
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),
user_id INT NOT NULL REFERENCES public.user(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 time::OffsetDateTime;
use tracing::error;
use crate::models::AppError;
@ -60,19 +66,17 @@ pub async fn insert_new_user(
}
#[derive(Debug, FromRow)]
pub struct UserAndHashedPassword {
pub struct UserIdAndHashedPasswordEntity {
pub id: i32,
pub username: String,
pub name: String,
pub password: String,
}
pub async fn get_username_and_password_by_username(
pool: &DbPool,
username: String,
) -> Result<UserAndHashedPassword, AppError> {
sqlx::query_as::<_, UserAndHashedPassword>(
"SELECT id, username, name, password FROM public.user WHERE username = $1;",
) -> Result<UserIdAndHashedPasswordEntity, AppError> {
sqlx::query_as::<_, UserIdAndHashedPasswordEntity>(
"SELECT id, password FROM public.user WHERE username = $1;",
)
.bind(username)
.fetch_one(pool)
@ -94,3 +98,75 @@ 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

@ -49,7 +49,7 @@ impl AppError {
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))
}

View file

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

View file

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

View file

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

View file

@ -84,7 +84,6 @@ async fn register_new_user_request(
let new_user_session = Session {
user_id,
username,
created_at: SystemTime::now(),
expires_at,
};
@ -114,13 +113,13 @@ async fn register_new_user_request(
UserRegistrationResponse {
id: user_id,
expires_at,
verification_token: None,
session_token: None,
}
} else {
UserRegistrationResponse {
id: user_id,
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")]
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::{
debug_handler,
@ -12,10 +12,13 @@ use tracing::{debug, error};
use uuid::Uuid;
use crate::{
db::{verify_user, DbPool},
models::{ApiResponse, AppError},
requests::AppState,
services::{auth_token::verify_token, user_session, CachePool},
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,
},
};
use super::{UserVerifyGetParams, UserVerifyGetResponse};
@ -57,29 +60,77 @@ async fn verify_new_user_request(
)
.inspect_err(|err| error!(?err))?;
let token_id = verified_token
let verification_token_id = verified_token
.payload_claims()
.map(|claims| claims.get_claim("jti"))
.flatten()
.map(|jti| Uuid::from_str(jti.as_str().unwrap()).unwrap())
.unwrap();
user_session::exists(cache_pool, token_id)
user_session::exists(cache_pool, verification_token_id)
.await
.and_then(|exists| {
if exists {
Ok(())
Ok(user_session::get_user_session(
cache_pool,
verification_token_id,
))
} else {
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(
token_key,
verification_token.as_str(),
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)
.await

View file

@ -1,35 +1,20 @@
use humantime::format_rfc3339_seconds;
use pasetors::{keys::SymmetricKey, version4::V4};
use std::time::SystemTime;
use serde::Serialize;
use crate::services::auth_token::{generate_access_token, generate_auth_token};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserVerifyGetResponse {
access: UserVerifyGetResponseTokenAndExpiration,
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(),
},
}
}
pub user_id: i32,
pub session: UserVerifyGetResponseTokenAndExpiration,
pub auth: UserVerifyGetResponseTokenAndExpiration,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserVerifyGetResponseTokenAndExpiration {
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)
}
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)
}

View file

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