This commit is contained in:
2025-07-17 16:36:36 +02:00
parent a4cf1064d4
commit 8d707535fd
29 changed files with 751 additions and 26 deletions

1
backend/Cargo.lock generated
View File

@@ -2521,6 +2521,7 @@ dependencies = [
"chrono",
"derive_more",
"dotenv",
"hex",
"mime_guess",
"regex",
"serde",

View File

@@ -19,6 +19,7 @@ axum_typed_multipart = "0.16.3"
chrono = "0.4.41"
derive_more = { version = "2.0.1", features = ["display"] }
dotenv = "0.15.0"
hex = "0.4.3"
mime_guess = "2.0.5"
regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] }

View File

@@ -0,0 +1,6 @@
CREATE TABLE auth_sessions (
session_id VARCHAR NOT NULL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -39,7 +39,8 @@ async fn main() -> anyhow::Result<()> {
fs_service.clone(),
);
let auth_service = domain::warren::service::auth::Service::new(postgres, metrics, notifier);
let auth_service =
domain::warren::service::auth::Service::new(postgres, metrics, notifier, config.auth);
let server_config = HttpServerConfig::new(
&config.server_address,

View File

@@ -3,6 +3,8 @@ use std::{env, str::FromStr};
use anyhow::Context as _;
use tracing::level_filters::LevelFilter;
use crate::domain::warren::service::auth::AuthConfig;
const DATABASE_URL_KEY: &str = "DATABASE_URL";
const DATABASE_NAME_KEY: &str = "DATABASE_NAME";
@@ -29,24 +31,28 @@ pub struct Config {
pub database_name: String,
pub log_level: LevelFilter,
pub auth: AuthConfig,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let server_address = load_env(SERVER_ADDRESS_KEY)?;
let server_port = load_env(SERVER_PORT_KEY)?.parse()?;
let cors_allow_origin = load_env(CORS_ALLOW_ORIGIN_KEY)?;
let server_address = Self::load_env(SERVER_ADDRESS_KEY)?;
let server_port = Self::load_env(SERVER_PORT_KEY)?.parse()?;
let cors_allow_origin = Self::load_env(CORS_ALLOW_ORIGIN_KEY)?;
let serve_dir = load_env(SERVE_DIRECTORY_KEY)?;
let static_frontend_dir = load_env(STATIC_FRONTEND_DIRECTORY).ok();
let serve_dir = Self::load_env(SERVE_DIRECTORY_KEY)?;
let static_frontend_dir = Self::load_env(STATIC_FRONTEND_DIRECTORY).ok();
let database_url = load_env(DATABASE_URL_KEY)?;
let database_name = load_env(DATABASE_NAME_KEY)?;
let database_url = Self::load_env(DATABASE_URL_KEY)?;
let database_name = Self::load_env(DATABASE_NAME_KEY)?;
let log_level =
LevelFilter::from_str(&load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string()))
LevelFilter::from_str(&Self::load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string()))
.context("Failed to convert the value of LOG_LEVEL to a LevelFilter")?;
let auth_config = AuthConfig::from_env()?;
Ok(Self {
server_address,
server_port,
@@ -59,10 +65,12 @@ impl Config {
database_name,
log_level,
auth: auth_config,
})
}
}
fn load_env(key: &str) -> anyhow::Result<String> {
pub fn load_env(key: &str) -> anyhow::Result<String> {
env::var(key).context(format!("Failed to get environment variable: {key}"))
}
}

View File

@@ -0,0 +1,65 @@
use chrono::NaiveDateTime;
use derive_more::Display;
use thiserror::Error;
use uuid::Uuid;
pub mod requests;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::FromRow)]
pub struct AuthSession {
session_id: AuthSessionId,
user_id: Uuid,
expires_at: NaiveDateTime,
created_at: NaiveDateTime,
}
impl AuthSession {
pub fn new(
session_id: AuthSessionId,
user_id: Uuid,
expires_at: NaiveDateTime,
created_at: NaiveDateTime,
) -> Self {
Self {
session_id,
user_id,
expires_at,
created_at,
}
}
pub fn session_id(&self) -> &AuthSessionId {
&self.session_id
}
pub fn user_id(&self) -> &Uuid {
&self.user_id
}
}
/// A valid auth session id
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)]
#[sqlx(transparent)]
pub struct AuthSessionId(String);
impl AuthSessionId {
pub fn new(raw: &str) -> Result<Self, AuthSessionIdError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AuthSessionIdError::Empty);
}
Ok(Self(trimmed.to_string()))
}
pub fn into_string(self) -> String {
self.0
}
}
#[derive(Debug, Clone, Error)]
pub enum AuthSessionIdError {
#[error("An auth session id must not be empty")]
Empty,
}

View File

@@ -0,0 +1,45 @@
use thiserror::Error;
use crate::domain::warren::models::user::User;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CreateAuthSessionRequest {
user: User,
expiration: SessionExpirationTime,
}
impl CreateAuthSessionRequest {
pub fn new(user: User, expiration_time: SessionExpirationTime) -> Self {
Self {
user,
expiration: expiration_time,
}
}
pub fn user(&self) -> &User {
&self.user
}
pub fn expiration(&self) -> &SessionExpirationTime {
&self.expiration
}
}
#[derive(Debug, Error)]
pub enum CreateAuthSessionError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SessionExpirationTime(u64);
impl SessionExpirationTime {
pub fn new(value: u64) -> Self {
Self(value)
}
pub fn millis(&self) -> u64 {
self.0
}
}

View File

@@ -1,3 +1,4 @@
pub mod auth_session;
pub mod file;
pub mod user;
pub mod warren;

View File

@@ -33,6 +33,10 @@ impl User {
&self.email
}
pub fn password_hash(&self) -> &str {
&self.hash
}
pub fn admin(&self) -> bool {
self.admin
}

View File

@@ -1,5 +1,7 @@
use thiserror::Error;
use crate::domain::warren::models::auth_session::requests::CreateAuthSessionError;
use super::{UserEmail, UserName};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -87,3 +89,72 @@ impl RegisterUserRequest {
&self.password
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct VerifyUserPasswordRequest {
email: UserEmail,
password: UserPassword,
}
impl VerifyUserPasswordRequest {
pub fn new(email: UserEmail, password: UserPassword) -> Self {
Self { email, password }
}
pub fn email(&self) -> &UserEmail {
&self.email
}
pub fn password(&self) -> &UserPassword {
&self.password
}
}
impl From<LoginUserRequest> for VerifyUserPasswordRequest {
fn from(value: LoginUserRequest) -> Self {
Self {
email: value.email,
password: value.password,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LoginUserRequest {
email: UserEmail,
password: UserPassword,
}
impl LoginUserRequest {
pub fn new(email: UserEmail, password: UserPassword) -> Self {
Self { email, password }
}
pub fn email(&self) -> &UserEmail {
&self.email
}
pub fn password(&self) -> &UserPassword {
&self.password
}
}
#[derive(Debug, Error)]
pub enum VerifyUserPasswordError {
#[error("There is no user with this email: {0}")]
NotFound(UserEmail),
#[error("The password is incorrect")]
IncorrectPassword,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum LoginUserError {
#[error(transparent)]
VerifyUser(#[from] VerifyUserPasswordError),
#[error(transparent)]
CreateAuthToken(#[from] CreateAuthSessionError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -52,4 +52,10 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
pub trait AuthMetrics: Clone + Send + Sync + 'static {
fn record_user_registration_success(&self) -> impl Future<Output = ()> + Send;
fn record_user_registration_failure(&self) -> impl Future<Output = ()> + Send;
fn record_user_login_success(&self) -> impl Future<Output = ()> + Send;
fn record_user_login_failure(&self) -> impl Future<Output = ()> + Send;
fn record_auth_session_creation_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_session_creation_failure(&self) -> impl Future<Output = ()> + Send;
}

View File

@@ -7,12 +7,16 @@ pub use notifier::*;
pub use repository::*;
use super::models::{
auth_session::{
AuthSession,
requests::{CreateAuthSessionError, CreateAuthSessionRequest},
},
file::{
CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest,
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
},
user::{RegisterUserError, RegisterUserRequest, User},
user::{LoginUserError, LoginUserRequest, RegisterUserError, RegisterUserRequest, User},
warren::{
CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError,
DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest,
@@ -98,4 +102,12 @@ pub trait AuthService: Clone + Send + Sync + 'static {
&self,
request: RegisterUserRequest,
) -> impl Future<Output = Result<User, RegisterUserError>> + Send;
fn login_user(
&self,
request: LoginUserRequest,
) -> impl Future<Output = Result<AuthSession, LoginUserError>> + Send;
fn create_auth_session(
&self,
request: CreateAuthSessionRequest,
) -> impl Future<Output = Result<AuthSession, CreateAuthSessionError>> + Send;
}

View File

@@ -1,3 +1,5 @@
use uuid::Uuid;
use crate::domain::warren::models::{
file::{AbsoluteFilePath, File, FilePath},
user::User,
@@ -72,4 +74,6 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
pub trait AuthNotifier: Clone + Send + Sync + 'static {
fn user_registered(&self, user: &User) -> impl Future<Output = ()> + Send;
fn user_logged_in(&self, user: &User) -> impl Future<Output = ()> + Send;
fn auth_session_created(&self, user_id: &Uuid) -> impl Future<Output = ()> + Send;
}

View File

@@ -1,10 +1,17 @@
use crate::domain::warren::models::{
auth_session::{
AuthSession,
requests::{CreateAuthSessionError, CreateAuthSessionRequest},
},
file::{
CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest,
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
},
user::{RegisterUserError, RegisterUserRequest, User},
user::{
RegisterUserError, RegisterUserRequest, User, VerifyUserPasswordError,
VerifyUserPasswordRequest,
},
warren::{FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren},
};
@@ -55,4 +62,12 @@ pub trait AuthRepository: Clone + Send + Sync + 'static {
&self,
request: RegisterUserRequest,
) -> impl Future<Output = Result<User, RegisterUserError>> + Send;
fn verify_user_password(
&self,
request: VerifyUserPasswordRequest,
) -> impl Future<Output = Result<User, VerifyUserPasswordError>> + Send;
fn create_auth_session(
&self,
request: CreateAuthSessionRequest,
) -> impl Future<Output = Result<AuthSession, CreateAuthSessionError>> + Send;
}

View File

@@ -1,8 +1,49 @@
use crate::domain::warren::{
models::user::{RegisterUserError, RegisterUserRequest, User},
use crate::{
config::Config,
domain::warren::{
models::{
auth_session::{
AuthSession,
requests::{
CreateAuthSessionError, CreateAuthSessionRequest, SessionExpirationTime,
},
},
user::{
LoginUserError, LoginUserRequest, RegisterUserError, RegisterUserRequest, User,
},
},
ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService},
},
};
const AUTH_SESSION_EXPIRATION_KEY: &str = "AUTH_SESSION_EXPIRATION";
/// The authentication service configuration
///
/// * `session_lifetime`: The amount of milliseconds a client session is valid
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthConfig {
session_lifetime: SessionExpirationTime,
}
impl AuthConfig {
pub fn from_env() -> anyhow::Result<Self> {
let session_lifetime = {
match Config::load_env(AUTH_SESSION_EXPIRATION_KEY).ok() {
Some(raw) => SessionExpirationTime::new(raw.parse()?),
// 86.400.000 milliseconds = 1 day
None => SessionExpirationTime::new(86_400_000),
}
};
Ok(Self { session_lifetime })
}
pub fn session_lifetime(&self) -> SessionExpirationTime {
self.session_lifetime
}
}
#[derive(Debug, Clone)]
pub struct Service<R, M, N>
where
@@ -13,6 +54,7 @@ where
repository: R,
metrics: M,
notifier: N,
config: AuthConfig,
}
impl<R, M, N> Service<R, M, N>
@@ -21,11 +63,12 @@ where
M: AuthMetrics,
N: AuthNotifier,
{
pub fn new(repository: R, metrics: M, notifier: N) -> Self {
pub fn new(repository: R, metrics: M, notifier: N, config: AuthConfig) -> Self {
Self {
repository,
metrics,
notifier,
config,
}
}
}
@@ -48,4 +91,44 @@ where
result
}
async fn login_user(&self, request: LoginUserRequest) -> Result<AuthSession, LoginUserError> {
let user = self
.repository
.verify_user_password(request.into())
.await
.map_err(|e| LoginUserError::VerifyUser(e))?;
let result = self
.create_auth_session(CreateAuthSessionRequest::new(
user.clone(),
self.config.session_lifetime(),
))
.await;
if result.as_ref().is_ok() {
self.metrics.record_user_login_success().await;
self.notifier.user_logged_in(&user).await;
} else {
self.metrics.record_user_login_failure().await;
}
result.map_err(Into::into)
}
async fn create_auth_session(
&self,
request: CreateAuthSessionRequest,
) -> Result<AuthSession, CreateAuthSessionError> {
let result = self.repository.create_auth_session(request).await;
if let Ok(session) = result.as_ref() {
self.metrics.record_auth_session_creation_success().await;
self.notifier.auth_session_created(session.user_id()).await;
} else {
self.metrics.record_auth_session_creation_failure().await;
}
result
}
}

View File

@@ -1,7 +1,7 @@
use crate::{
domain::warren::models::{
file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError},
user::RegisterUserError,
user::{LoginUserError, RegisterUserError, VerifyUserPasswordError},
warren::{
CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError,
FetchWarrenError, ListWarrenFilesError, ListWarrensError, RenameWarrenEntryError,
@@ -134,3 +134,21 @@ impl From<RegisterUserError> for ApiError {
}
}
}
impl From<LoginUserError> for ApiError {
fn from(value: LoginUserError) -> Self {
match value {
LoginUserError::VerifyUser(e) => match e {
VerifyUserPasswordError::NotFound(_) => {
Self::NotFound("Could not find a user with that email".to_string())
}
VerifyUserPasswordError::IncorrectPassword => {
Self::BadRequest("The email and password do not match".to_string())
}
VerifyUserPasswordError::Unknown(e) => Self::InternalServerError(e.to_string()),
},
LoginUserError::CreateAuthToken(e) => Self::InternalServerError(e.to_string()),
LoginUserError::Unknown(e) => Self::InternalServerError(e.to_string()),
}
}
}

View File

@@ -0,0 +1,94 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
domain::warren::{
models::{
auth_session::AuthSession,
user::{LoginUserRequest, UserEmail, UserEmailError, UserPassword, UserPasswordError},
},
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
responses::{ApiError, ApiSuccess},
},
};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginUserHttpRequestBody {
email: String,
password: String,
}
#[derive(Debug, Clone, Error)]
pub enum ParseLoginUserHttpRequestError {
#[error(transparent)]
Email(#[from] UserEmailError),
#[error(transparent)]
Password(#[from] UserPasswordError),
}
impl From<ParseLoginUserHttpRequestError> for ApiError {
fn from(value: ParseLoginUserHttpRequestError) -> Self {
match value {
ParseLoginUserHttpRequestError::Email(err) => Self::BadRequest(
match err {
UserEmailError::Empty => "The provided email is empty",
UserEmailError::Invalid => "The provided email is invalid",
}
.to_string(),
),
ParseLoginUserHttpRequestError::Password(err) => Self::BadRequest(
match err {
UserPasswordError::Empty => "The provided password is empty",
// Best not give a potential bad actor any hints since this is the login and
// not the registration
UserPasswordError::LeadingWhitespace
| UserPasswordError::TrailingWhitespace
| UserPasswordError::TooShort
| UserPasswordError::TooLong => "",
}
.to_string(),
),
}
}
}
impl LoginUserHttpRequestBody {
fn try_into_domain(self) -> Result<LoginUserRequest, ParseLoginUserHttpRequestError> {
let email = UserEmail::new(&self.email)?;
let password = UserPassword::new(&self.password)?;
Ok(LoginUserRequest::new(email, password))
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LoginResponseBody {
token: String,
}
impl From<AuthSession> for LoginResponseBody {
fn from(value: AuthSession) -> Self {
Self {
token: value.session_id().to_string(),
}
}
}
pub async fn login<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<LoginUserHttpRequestBody>,
) -> Result<ApiSuccess<LoginResponseBody>, ApiError> {
let domain_request = request.try_into_domain()?;
state
.auth_service
.login_user(domain_request)
.await
.map(|token| ApiSuccess::new(StatusCode::OK, token.into()))
.map_err(ApiError::from)
}

View File

@@ -1,4 +1,6 @@
mod login;
mod register;
use login::login;
use register::register;
use axum::{Router, routing::post};
@@ -9,5 +11,7 @@ use crate::{
};
pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
Router::new().route("/register", post(register))
Router::new()
.route("/register", post(register))
.route("/login", post(login))
}

View File

@@ -115,10 +115,10 @@ impl FileSystemMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] File deletion failed");
}
async fn record_entry_rename_success(&self) -> () {
async fn record_entry_rename_success(&self) {
tracing::debug!("[Metrics] Entry rename succeeded");
}
async fn record_entry_rename_failure(&self) -> () {
async fn record_entry_rename_failure(&self) {
tracing::debug!("[Metrics] Entry rename failed");
}
}
@@ -130,4 +130,18 @@ impl AuthMetrics for MetricsDebugLogger {
async fn record_user_registration_failure(&self) {
tracing::debug!("[Metrics] User registration failed");
}
async fn record_user_login_success(&self) {
tracing::debug!("[Metrics] User login succeeded");
}
async fn record_user_login_failure(&self) {
tracing::debug!("[Metrics] User login failed");
}
async fn record_auth_session_creation_success(&self) {
tracing::debug!("[Metrics] Auth session creation succeeded");
}
async fn record_auth_session_creation_failure(&self) {
tracing::debug!("[Metrics] Auth session creation failed");
}
}

View File

@@ -1,3 +1,5 @@
use uuid::Uuid;
use crate::domain::warren::{
models::{
file::{File, FilePath},
@@ -118,4 +120,12 @@ impl AuthNotifier for NotifierDebugLogger {
async fn user_registered(&self, user: &User) {
tracing::debug!("[Notifier] Registered user {}", user.name());
}
async fn user_logged_in(&self, user: &User) {
tracing::debug!("[Notifier] Logged in user {}", user.name());
}
async fn auth_session_created(&self, user_id: &Uuid) {
tracing::debug!("[Notifier] Created auth session for user {}", user_id);
}
}

View File

@@ -2,9 +2,13 @@ use std::str::FromStr;
use anyhow::{Context, anyhow};
use argon2::{
Argon2,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
Argon2, PasswordHash, PasswordVerifier,
password_hash::{
PasswordHasher, SaltString,
rand_core::{OsRng, RngCore as _},
},
};
use chrono::Utc;
use sqlx::{
ConnectOptions as _, Connection as _, PgConnection, PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
@@ -13,7 +17,14 @@ use uuid::Uuid;
use crate::domain::warren::{
models::{
user::{RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword},
auth_session::{
AuthSession,
requests::{CreateAuthSessionError, CreateAuthSessionRequest, SessionExpirationTime},
},
user::{
RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword,
VerifyUserPasswordError, VerifyUserPasswordRequest,
},
warren::{
FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren,
},
@@ -151,6 +162,89 @@ impl Postgres {
Ok(user)
}
async fn get_user_from_email(
&self,
connection: &mut PgConnection,
email: &UserEmail,
) -> Result<User, sqlx::Error> {
let user: User = sqlx::query_as(
"
SELECT
*
FROM
users
WHERE
email = $1
",
)
.bind(email)
.fetch_one(connection)
.await?;
Ok(user)
}
fn check_user_password_against_hash(
&self,
password: &UserPassword,
hash: &str,
) -> Result<(), VerifyUserPasswordError> {
let argon = Argon2::default();
let hash =
PasswordHash::new(hash).map_err(|e| VerifyUserPasswordError::Unknown(anyhow!(e)))?;
argon
.verify_password(password.as_str().as_bytes(), &hash)
.map_err(|e| match e {
argon2::password_hash::Error::Password => {
VerifyUserPasswordError::IncorrectPassword
}
_ => VerifyUserPasswordError::Unknown(anyhow!(e)),
})
}
async fn create_session(
&self,
connection: &mut PgConnection,
user: &User,
expiration: &SessionExpirationTime,
) -> anyhow::Result<AuthSession> {
let mut rng = OsRng;
let mut bytes = vec![0_u8; 32];
rng.fill_bytes(&mut bytes);
let session_id = hex::encode(bytes);
let expiration_time = Utc::now().timestamp_millis() + i64::try_from(expiration.millis())?;
let mut tx = connection.begin().await?;
let session: AuthSession = sqlx::query_as(
"
INSERT INTO auth_sessions (
session_id,
user_id,
expires_at
) VALUES (
$1,
$2,
TO_TIMESTAMP($3::double precision / 1000)
)
RETURNING
*
",
)
.bind(session_id)
.bind(user.id())
.bind(expiration_time)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(session)
}
}
impl WarrenRepository for Postgres {
@@ -217,6 +311,50 @@ impl AuthRepository for Postgres {
Ok(user)
}
async fn verify_user_password(
&self,
request: VerifyUserPasswordRequest,
) -> Result<User, VerifyUserPasswordError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
let user = self
.get_user_from_email(&mut connection, request.email())
.await
.map_err(|e| {
if is_not_found_error(&e) {
VerifyUserPasswordError::NotFound(request.email().clone())
} else {
VerifyUserPasswordError::Unknown(anyhow!(e))
}
})?;
self.check_user_password_against_hash(request.password(), user.password_hash())?;
Ok(user)
}
async fn create_auth_session(
&self,
request: CreateAuthSessionRequest,
) -> Result<AuthSession, CreateAuthSessionError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
let session = self
.create_session(&mut connection, request.user(), request.expiration())
.await
.context("Failed to create session")?;
Ok(session)
}
}
fn is_not_found_error(err: &sqlx::Error) -> bool {

View File

@@ -1,3 +1,5 @@
# this file is ignored when the app is built since we're using SSG
NUXT_PUBLIC_API_BASE="http://127.0.0.1:8080/api"
NUXT_COOKIES_SECURE="false"
NUXT_COOKIES_SAME_SITE="strict"

View File

@@ -0,0 +1,20 @@
import type { AuthSession } from '~/types/auth';
const SAME_SITE_SETTINGS = ['strict', 'lax', 'none'] as const;
export function useAuthSession() {
const config = useRuntimeConfig().public;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sameSite = SAME_SITE_SETTINGS.includes(config.cookiesSameSite as any)
? (config.cookiesSameSite as (typeof SAME_SITE_SETTINGS)[number])
: 'strict';
return useCookie('auth_session', {
default: () => null as AuthSession | null,
secure: config.cookiesSecure.toLowerCase() === 'true',
sameSite,
path: '/',
httpOnly: false,
});
}

View File

@@ -0,0 +1,42 @@
import { toast } from 'vue-sonner';
import type { ApiResponse } from '~/types/api';
export async function loginUser(
email: string,
password: string
): Promise<{ success: boolean }> {
const { data, error } = await useFetch<ApiResponse<{ token: string }>>(
getApiUrl('auth/login'),
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
email: email,
password: password,
}),
responseType: 'json',
}
);
if (data.value == null) {
toast.error('Login', {
description: error.value?.data ?? 'Failed to login',
});
return {
success: false,
};
}
useAuthSession().value = { type: 'WarrenAuth', id: data.value.data.token };
toast.success('Login', {
description: `Successfully logged in`,
});
return {
success: true,
};
}

15
frontend/lib/api/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export function getApiHeaders(
includeAuth: boolean = true
): Record<string, string> {
const headers: Record<string, string> = {};
if (includeAuth) {
const authSession = useAuthSession().value;
if (authSession != null) {
headers['authorization'] = `${authSession.type} ${authSession.id}`;
}
}
return headers;
}

View File

@@ -2,12 +2,14 @@ import { toast } from 'vue-sonner';
import type { DirectoryEntry } from '~/types';
import type { ApiResponse } from '~/types/api';
import type { Warren } from '~/types/warrens';
import { getApiHeaders } from '.';
export async function getWarrens(): Promise<Record<string, Warren>> {
const { data, error } = await useFetch<ApiResponse<{ warrens: Warren[] }>>(
getApiUrl('warrens'),
{
method: 'GET',
headers: getApiHeaders(),
}
);

View File

@@ -58,6 +58,8 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBase: '/api',
cookiesSecure: 'false',
cookiesSameSite: 'strict',
},
},
});

View File

@@ -7,6 +7,7 @@ import {
CardContent,
CardFooter,
} from '@/components/ui/card';
import { loginUser } from '~/lib/api/auth/login';
definePageMeta({
layout: 'auth',
@@ -14,6 +15,35 @@ definePageMeta({
// TODO: Get this from the backend
const OPEN_ID = false;
const loggingIn = ref(false);
const email = ref('');
const password = ref('');
const inputValid = computed(
() => email.value.trim().length > 0 && password.value.trim().length > 0
);
async function submit() {
if (!inputValid.value) {
return;
}
loggingIn.value = true;
const { success } = await loginUser(email.value, password.value);
if (success) {
await navigateTo({ path: '/' });
}
loggingIn.value = false;
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
submit();
}
}
</script>
<template>
@@ -29,24 +59,30 @@ const OPEN_ID = false;
<Label for="email">Email</Label>
<Input
id="email"
v-model="email"
type="email"
placeholder="your@email.com"
autocomplete="off"
required
@keydown="onKeyDown"
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
id="password"
v-model="password"
type="password"
autocomplete="off"
required
@keydown="onKeyDown"
/>
</div>
</CardContent>
<CardFooter class="flex-col gap-2">
<Button class="w-full">Sign in</Button>
<Button class="w-full" :disabled="!inputValid" @click="submit"
>Sign in</Button
>
<Button class="w-full" variant="outline" :disabled="!OPEN_ID"
>OpenID Connect</Button
>

5
frontend/types/auth.ts Normal file
View File

@@ -0,0 +1,5 @@
export type AuthSessionType = 'WarrenAuth';
export interface AuthSession {
type: AuthSessionType;
id: string;
}