login
This commit is contained in:
1
backend/Cargo.lock
generated
1
backend/Cargo.lock
generated
@@ -2521,6 +2521,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"derive_more",
|
||||
"dotenv",
|
||||
"hex",
|
||||
"mime_guess",
|
||||
"regex",
|
||||
"serde",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
|
||||
65
backend/src/lib/domain/warren/models/auth_session/mod.rs
Normal file
65
backend/src/lib/domain/warren/models/auth_session/mod.rs
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth_session;
|
||||
pub mod file;
|
||||
pub mod user;
|
||||
pub mod warren;
|
||||
|
||||
@@ -33,6 +33,10 @@ impl User {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn password_hash(&self) -> &str {
|
||||
&self.hash
|
||||
}
|
||||
|
||||
pub fn admin(&self) -> bool {
|
||||
self.admin
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
94
backend/src/lib/inbound/http/handlers/auth/login.rs
Normal file
94
backend/src/lib/inbound/http/handlers/auth/login.rs
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
20
frontend/composables/useAuthSession.ts
Normal file
20
frontend/composables/useAuthSession.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
42
frontend/lib/api/auth/login.ts
Normal file
42
frontend/lib/api/auth/login.ts
Normal 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
15
frontend/lib/api/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: '/api',
|
||||
cookiesSecure: 'false',
|
||||
cookiesSameSite: 'strict',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
5
frontend/types/auth.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type AuthSessionType = 'WarrenAuth';
|
||||
export interface AuthSession {
|
||||
type: AuthSessionType;
|
||||
id: string;
|
||||
}
|
||||
Reference in New Issue
Block a user