diff --git a/packages/api/src/main.rs b/packages/api/src/main.rs index e40ee77..65372f5 100644 --- a/packages/api/src/main.rs +++ b/packages/api/src/main.rs @@ -57,7 +57,9 @@ async fn main() { .route("/", get(index)) .nest( "/auth", - Router::new().route("/register", post(routes::auth::register)), + Router::new() + .route("/register", post(routes::auth::register)) + .route("/login", post(routes::auth::login)), ) .nest( "/user", diff --git a/packages/api/src/routes/auth.rs b/packages/api/src/routes/auth.rs index 20c2651..d714d14 100644 --- a/packages/api/src/routes/auth.rs +++ b/packages/api/src/routes/auth.rs @@ -1,7 +1,13 @@ use std::sync::Arc; -use axum::{extract::State, http::StatusCode, Json}; -use db::users::{hash_password, insert_user}; +use axum::{ + body::Body, + extract::State, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use db::users::User; use serde::{Deserialize, Serialize}; use crate::{jwt::encode_jwt, AppState}; @@ -14,8 +20,15 @@ pub struct RegisterRequest { password_confirmation: String, } -#[derive(Debug, Deserialize, Serialize)] -pub struct RegisterResponse { +#[derive(Deserialize)] +pub struct LoginRequest { + username: Option, + email: Option, + password: String, +} + +#[derive(Serialize)] +pub struct AuthResponse { token: String, } @@ -24,60 +37,77 @@ pub struct ErrorResponse { message: String, } -// Handle user registration with password hashing and validation +/// Handle user registration with password hashing and validation +/// Returns a cookie with a JWT set on successful response pub async fn register( State(state): State>, Json(payload): Json, -) -> Result, (StatusCode, Json)> { +) -> impl IntoResponse { // Validate inputs if payload.password != payload.password_confirmation { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - message: "Passwords do not match".to_string(), - }), - )); + return Err(StatusCode::BAD_REQUEST); } if payload.username.is_empty() || payload.email.is_empty() || payload.password.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - message: "All fields are required".to_string(), - }), - )); + return Err(StatusCode::BAD_REQUEST); } - // Hash the password - let password_hash = hash_password(&payload.password).expect("Could not hash password"); - + // Create a new user + let mut user = User::new(payload.email, payload.username, payload.password); + // Make sure to hash the password + user.hash_password() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Insert the new user into the database - match insert_user(&state.db, &payload.email, &payload.username, &password_hash).await { - Ok(user_id) => { - let token = encode_jwt(&user_id).map_err(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - message: "Could not encode JWT token".to_string(), - }), - ) - })?; - Ok(Json(RegisterResponse { token })) + match user.insert(&state.db).await { + Ok(user) => { + let token = + encode_jwt(&user.id.to_string()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(AuthResponse { token })) } - Err(e) => { - let error_message = match e { - sqlx::Error::Database(ref db_error) if db_error.is_unique_violation() => { - "Username or email already exists".to_string() - } - _ => "Failed to create user".to_string(), - }; + Err(_e) => Err(StatusCode::BAD_REQUEST), + } +} - Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - message: error_message, - }), - )) - } +/// Handle user authentication +/// Returns a cookie with a JWT set on successful response +pub async fn login( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + // Make sure we have either a username or email to work with + if payload.email.is_none() && payload.username.is_none() { + return Err(StatusCode::BAD_REQUEST); + } + // Make sure the password isn't empty + if payload.password.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + // Grab the user based on either the username or email + let mut user: Option = None; + // If the username is provided, it supercedes the email + if let Some(username) = payload.username { + user = Some( + User::from_username(username, &state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + ); + } + // Otherwise, we use the email + if let Some(email) = payload.email { + user = Some( + User::from_email(email, &state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + ) + } + // Finally, check the passwords + if let Some(user) = user { + user.check_password(payload.password) + .map_err(|_| StatusCode::BAD_REQUEST)?; + let token = + encode_jwt(&user.id.to_string()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(AuthResponse { token })) + } else { + Err(StatusCode::INTERNAL_SERVER_ERROR) } } diff --git a/packages/db/Cargo.toml b/packages/db/Cargo.toml index 8d39073..c755c51 100644 --- a/packages/db/Cargo.toml +++ b/packages/db/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -argon2 = "0.5" +argon2 = { version = "0.5", features = ["password-hash"] } sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/packages/db/src/users.rs b/packages/db/src/users.rs index 9adf9c0..9fe4c9f 100644 --- a/packages/db/src/users.rs +++ b/packages/db/src/users.rs @@ -1,30 +1,89 @@ use argon2::{ password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, - Argon2, + Argon2, PasswordVerifier, }; use sqlx::{types::Uuid, PgPool}; -// Insert a new user into the database -pub async fn insert_user( - pool: &PgPool, - email: &str, - username: &str, - password: &str, -) -> Result { - let user_id = Uuid::new_v4(); - sqlx::query("INSERT INTO users (id, email, username, password_hash) VALUES ($1, $2, $3, $4)") - .bind(user_id) - .bind(email) - .bind(username) - .bind(password) +#[derive(sqlx::FromRow)] +/// A database representation of a user +pub struct User { + pub id: Uuid, + pub email: String, + pub username: String, + pub password_hash: String, +} + +pub enum UserError { + FailedToHashPassword, + BadPassword, +} + +impl User { + /// Creates a new user from the given parameters + // NOTE: This does not hash the password by default + pub fn new(email: String, username: String, password_hash: String) -> Self { + let id = Uuid::new_v4(); + User { + id, + email, + username, + password_hash, + } + } + /// Gets a user from the databased based on Username + pub async fn from_username(username: String, pool: &PgPool) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1") + .bind(username) + .fetch_one(pool) + .await + } + /// Gets a user from the database based on ID + pub async fn from_id(id: Uuid, pool: &PgPool) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + } + /// Gets a user from the databased based on Email + pub async fn from_email(email: String, pool: &PgPool) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + .bind(email) + .fetch_one(pool) + .await + } + /// Checks that the raw password matches the expected-to-be-hashed password + pub fn check_password(&self, raw_password: String) -> Result<(), UserError> { + let dashed_password = argon2::password_hash::PasswordHash::new(&self.password_hash) + .map_err(|_| UserError::FailedToHashPassword)?; + argon2::Argon2::default() + .verify_password(raw_password.as_bytes(), &dashed_password) + .map_err(|_| UserError::BadPassword) + } + /// Hashes the password for the user + pub fn hash_password(&mut self) -> Result<&mut Self, UserError> { + let hashed_password = + hash_string(&self.password_hash).map_err(|_| UserError::FailedToHashPassword)?; + self.password_hash = hashed_password; + Ok(self) + } + /// Inserts the user into the database + pub async fn insert(&self, pool: &PgPool) -> Result<&Self, sqlx::Error> { + sqlx::query( + "INSERT INTO users (id, email, username, password_hash) VALUES ($1, $2, $3, $4)", + ) + .bind(&self.id) + .bind(&self.email) + .bind(&self.username) + .bind(&self.password_hash) .execute(pool) .await?; - Ok(user_id.to_string()) + Ok(self) + } } -// Hash a password using Argon2 -pub fn hash_password(password: &str) -> Result { +/// Hash a string using Argon2 +pub fn hash_string(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); let password_hash = argon2 diff --git a/packages/web/package.json b/packages/web/package.json index 61587ea..0f06ad9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,6 +2,7 @@ "name": "web", "version": "0.0.1", "type": "module", + "license": "MIT", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/packages/web/src/app.d.ts b/packages/web/src/app.d.ts index da08e6d..3f2166e 100644 --- a/packages/web/src/app.d.ts +++ b/packages/web/src/app.d.ts @@ -3,7 +3,9 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + authenticated: boolean; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/packages/web/src/hooks.server.ts b/packages/web/src/hooks.server.ts new file mode 100644 index 0000000..7b52b93 --- /dev/null +++ b/packages/web/src/hooks.server.ts @@ -0,0 +1,10 @@ +import type { Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + // Get the JWT from cookies + const jwt = event.cookies.get('jwt'); + // Set the user as authenticated in the locals object if JWT exists + event.locals.authenticated = !!jwt; + + return await resolve(event); +}; diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts new file mode 100644 index 0000000..3ddc879 --- /dev/null +++ b/packages/web/src/lib/auth.ts @@ -0,0 +1,86 @@ +import { isAuthenticated } from './stores/auth'; +import { goto } from '$app/navigation'; +import { env } from '$env/dynamic/private'; + +interface AuthResponse { + ok: boolean; + error?: string; +} + +export async function login(username: string, password: string): Promise { + try { + const response = await fetch(`${env.API_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username, password }) + }); + + if (response.ok) { + isAuthenticated.set(true); + return { ok: true }; + } + + return { + ok: false, + error: 'Invalid credentials' + }; + } catch (error) { + console.error('Could not login', error); + return { + ok: false, + error: 'An error occurred during login' + }; + } +} + +export async function register( + username: string, + email: string, + password: string, + passwordConfirmation: string +): Promise { + try { + const response = await fetch(`${env.API_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username, + email, + password, + password_confirmation: passwordConfirmation + }) + }); + + if (response.ok) { + isAuthenticated.set(true); + return { ok: true }; + } + + return { + ok: false, + error: 'Registration failed' + }; + } catch (error) { + console.error('Could not register', error); + return { + ok: false, + error: 'An error occurred during registration' + }; + } +} + +export async function logout() { + try { + await fetch(`${env.API_URL}/auth/logout`, { + method: 'POST' + }); + isAuthenticated.set(false); + goto('/login'); + } catch (error) { + console.error('Logout failed:', error); + } +} diff --git a/packages/web/src/lib/stores/auth.ts b/packages/web/src/lib/stores/auth.ts new file mode 100644 index 0000000..509934f --- /dev/null +++ b/packages/web/src/lib/stores/auth.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const isAuthenticated = writable(false); diff --git a/packages/web/src/routes/+layout.server.ts b/packages/web/src/routes/+layout.server.ts new file mode 100644 index 0000000..0be9caf --- /dev/null +++ b/packages/web/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { + authenticated: locals.authenticated + }; +}; diff --git a/packages/web/src/routes/+layout.svelte b/packages/web/src/routes/+layout.svelte index 08bf6e4..72ebddb 100644 --- a/packages/web/src/routes/+layout.svelte +++ b/packages/web/src/routes/+layout.svelte @@ -1,12 +1,19 @@
+ {#if isAuthenticated} +
{JSON.stringify(data, null, 2)}
+ {/if}
diff --git a/packages/web/src/routes/login/+page.server.ts b/packages/web/src/routes/login/+page.server.ts new file mode 100644 index 0000000..6254239 --- /dev/null +++ b/packages/web/src/routes/login/+page.server.ts @@ -0,0 +1,47 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import { env } from '$env/dynamic/private'; + +export const actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + const username = data.get('username'); + const password = data.get('password'); + + // Basic validation + if (!username || !password) { + return fail(400, { + error: 'Username and password are required', + username: username?.toString() + }); + } + + const response = await fetch(`${env.API_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: username.toString(), + password: password.toString() + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + return fail(response.status, { + error: errorData.message || 'Invalid credentials', + username: username.toString() + }); + } + const { token } = await response.json(); + + cookies.set('jwt', token, { + path: '/', + expires: new Date(Date.now() + 1000 * 60 * 60 * 24), // 24 hours + sameSite: true + }); + + throw redirect(303, '/'); + } +} satisfies Actions; diff --git a/packages/web/src/routes/login/+page.svelte b/packages/web/src/routes/login/+page.svelte index 15bbee0..23f038d 100644 --- a/packages/web/src/routes/login/+page.svelte +++ b/packages/web/src/routes/login/+page.svelte @@ -1 +1,73 @@ -login page + + +
{ + isLoading = true; + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + class="mx-auto mt-8 max-w-sm rounded-lg bg-white p-6 shadow-md dark:bg-secondary-950 dark:shadow-xl" +> + {#if form?.error} + + {/if} + + + + + + +