feat: implement basic session authentication

This commit is contained in:
Jef Roosens 2025-01-11 21:37:37 +01:00
parent df741c931b
commit 902de85131
Signed by: Jef Roosens
GPG key ID: 21FD3D77D56BAF49
10 changed files with 204 additions and 97 deletions

View file

@ -66,7 +66,7 @@ pub fn run_migrations(pool: &DbPool, migrations: &[&str]) -> rusqlite::Result<()
while next_version < migrations.len() {
let tx = conn.transaction()?;
tx.execute(migrations[next_version], ())?;
tx.execute_batch(migrations[next_version])?;
let cur_time = chrono::Local::now().timestamp();
tx.execute(

View file

@ -1,11 +1,21 @@
use argon2::password_hash::rand_core::{OsRng, RngCore};
use rusqlite::Row;
use super::{DbError, DbPool, User};
pub struct Session {
id: u64,
user_id: i32,
pub id: u64,
pub user_id: i32,
}
impl Session {
pub fn from_row(row: &Row<'_>) -> Result<Self, rusqlite::Error> {
Ok(Self {
id: row.get("id")?,
user_id: row.get("user_id")?,
})
}
pub fn user_from_id(pool: &DbPool, id: u64) -> Result<Option<super::User>, DbError> {
let conn = pool.get()?;
@ -16,4 +26,14 @@ impl Session {
Err(err) => Err(DbError::Db(err)),
}
}
pub fn new_for_user(pool: &DbPool, user_id: i32) -> Result<Self, DbError> {
let id: u64 = OsRng.next_u64();
let conn = pool.get()?;
let mut stmt =
conn.prepare("insert into sessions (id, user_id) values ($1, $2) returning *")?;
Ok(stmt.query_row((&id, &user_id), Self::from_row)?)
}
}

View file

@ -1,3 +1,7 @@
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use rusqlite::Row;
use serde::{Deserialize, Serialize};
@ -5,17 +9,17 @@ use super::{DbError, DbPool};
#[derive(Serialize, Deserialize, Clone)]
pub struct User {
id: i32,
username: String,
password_hash: String,
admin: bool,
pub id: i32,
pub username: String,
pub password_hash: String,
pub admin: bool,
}
#[derive(Serialize, Deserialize)]
pub struct NewUser {
username: String,
password: String,
admin: bool,
pub username: String,
pub password: String,
pub admin: bool,
}
impl User {
@ -27,19 +31,48 @@ impl User {
admin: row.get("admin")?,
})
}
pub fn by_username(pool: &DbPool, username: impl AsRef<str>) -> Result<Option<Self>, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare("select * from users where username = $1")?;
match stmt.query_row((username.as_ref(),), User::from_row) {
Ok(user) => Ok(Some(user)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(DbError::Db(err)),
}
}
pub fn verify_password(&self, password: impl AsRef<str>) -> bool {
let password_hash = PasswordHash::new(&self.password_hash).unwrap();
Argon2::default()
.verify_password(password.as_ref().as_bytes(), &password_hash)
.is_ok()
}
}
fn hash_password(password: impl AsRef<str>) -> String {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_ref().as_bytes(), &salt)
.unwrap()
.to_string()
}
impl NewUser {
pub fn insert(self, pool: &DbPool) -> Result<User, DbError> {
let conn = pool.get()?;
let password_hash = hash_password(&self.password);
let mut stmt = conn.prepare(
"insert into users (username, password_hash, admin) values ($1, $2, $3) returning *",
)?;
Ok(stmt.query_row(
(&self.username, &self.password, &self.admin),
User::from_row,
)?)
Ok(stmt.query_row((&self.username, password_hash, &self.admin), User::from_row)?)
}
}

View file

@ -1,14 +1,21 @@
use axum::{
extract::{Request, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
response::{Html, IntoResponse, Response},
routing::{get, post},
Form, Router,
};
use axum_extra::extract::CookieJar;
use axum_extra::extract::{
cookie::{Cookie, SameSite},
CookieJar,
};
use serde::Deserialize;
use tera::Context;
use crate::db::Session;
use crate::db::{Session, User};
use super::error::AppError;
use super::{error::AppError, render_view};
pub async fn auth_middleware(
State(ctx): State<crate::Context>,
@ -34,3 +41,55 @@ pub async fn auth_middleware(
StatusCode::UNAUTHORIZED.into_response()
}
}
pub fn app() -> Router<crate::Context> {
Router::new().route("/login", get(get_login).post(post_login))
}
#[derive(Deserialize)]
struct Login {
username: String,
password: String,
}
async fn get_login(
State(ctx): State<crate::Context>,
headers: HeaderMap,
) -> Result<Html<String>, AppError> {
let context = Context::new();
Ok(Html(render_view(
&ctx.tera,
"views/login.html",
&context,
&headers,
)?))
}
async fn post_login(
State(ctx): State<crate::Context>,
jar: CookieJar,
Form(login): Form<Login>,
) -> Result<(CookieJar, Html<String>), AppError> {
if let Some(user) = User::by_username(&ctx.pool, &login.username)? {
if user.verify_password(&login.password) {
let session = Session::new_for_user(&ctx.pool, user.id)?;
let session_id = session.id.to_string();
Ok((
jar.add(
Cookie::build(("session_id", session_id))
.secure(true)
.http_only(true)
.same_site(SameSite::Lax)
.build(),
),
Html(String::new()),
))
} else {
Err(AppError::Unauthorized)
}
} else {
Err(AppError::Unauthorized)
}
}

View file

@ -8,6 +8,7 @@ use crate::db;
pub enum AppError {
Db(db::DbError),
Tera(tera::Error),
Unauthorized,
NotFound,
}
@ -16,6 +17,7 @@ impl fmt::Display for AppError {
match self {
Self::Db(_) => write!(f, "database error"),
Self::Tera(_) => write!(f, "error rendering template"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::NotFound => write!(f, "not found"),
}
}
@ -26,7 +28,7 @@ impl std::error::Error for AppError {
match self {
Self::Db(err) => Some(err),
Self::Tera(err) => Some(err),
Self::NotFound => None,
Self::NotFound | Self::Unauthorized => None,
}
}
}
@ -65,6 +67,7 @@ impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
match self {
Self::NotFound => StatusCode::NOT_FOUND.into_response(),
Self::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
_ => {
tracing::error!("{}", self.stack());

View file

@ -59,6 +59,7 @@ pub fn app(ctx: crate::Context, static_dir: &str) -> axum::Router {
ctx.clone(),
auth::auth_middleware,
))
.merge(auth::app())
.route("/", get(get_index))
.nest_service("/static", ServeDir::new(static_dir))
.with_state(ctx.clone());

View file

@ -13,7 +13,7 @@ use super::error::AppError;
pub fn app() -> axum::Router<crate::Context> {
Router::new()
.route("/:id", get(get_plant_page))
.route("/{id}", get(get_plant_page))
.route("/", post(post_plant))
}