Skip to content

Commit

Permalink
Add client-side authentication (#5)
Browse files Browse the repository at this point in the history
* Add User implementation

* Add login, logout, and update register routes

* Update comment

* Do an empty password check

* Fix typo error

* Add login and logout to auth router

* Remove cookie-based auth in API

* Have svelte put token into cookie for client
  • Loading branch information
sneakycrow authored Oct 31, 2024
1 parent a861ebc commit 504bdef
Show file tree
Hide file tree
Showing 13 changed files with 394 additions and 68 deletions.
4 changes: 3 additions & 1 deletion packages/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 76 additions & 46 deletions packages/api/src/routes/auth.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -14,8 +20,15 @@ pub struct RegisterRequest {
password_confirmation: String,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterResponse {
#[derive(Deserialize)]
pub struct LoginRequest {
username: Option<String>,
email: Option<String>,
password: String,
}

#[derive(Serialize)]
pub struct AuthResponse {
token: String,
}

Expand All @@ -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<Arc<AppState>>,
Json(payload): Json<RegisterRequest>,
) -> Result<Json<RegisterResponse>, (StatusCode, Json<ErrorResponse>)> {
) -> 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<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
) -> 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<User> = 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)
}
}
2 changes: 1 addition & 1 deletion packages/db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
93 changes: 76 additions & 17 deletions packages/db/src/users.rs
Original file line number Diff line number Diff line change
@@ -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<String, sqlx::Error> {
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<Self, sqlx::Error> {
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<Self, sqlx::Error> {
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<Self, sqlx::Error> {
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<String, argon2::password_hash::Error> {
/// Hash a string using Argon2
pub fn hash_string(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "web",
"version": "0.0.1",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite dev",
"build": "vite build",
Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
authenticated: boolean;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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);
};
86 changes: 86 additions & 0 deletions packages/web/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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<AuthResponse> {
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<AuthResponse> {
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);
}
}
Loading

0 comments on commit 504bdef

Please sign in to comment.