diff --git a/Cargo.lock b/Cargo.lock index e2fdd61..f0ec34a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2216,6 +2216,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" name = "stationslauf" version = "0.1.0" dependencies = [ + "argon2", "async-trait", "axum", "axum-login", diff --git a/Cargo.toml b/Cargo.toml index c226a0d..13dbac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["sqlite"] } tracing-subscriber = "0.3" futures = "0.3" rand = "0.9" +argon2 = "0.5" [dev-dependencies] diff --git a/migration.sql b/migration.sql index f7a0cd4..f2c6317 100644 --- a/migration.sql +++ b/migration.sql @@ -51,7 +51,8 @@ CREATE TABLE IF NOT EXISTS rating ( CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL UNIQUE, - pw TEXT NOT NULL + pw TEXT NOT NULL, + require_new_password_code TEXT ); create table if not exists "tower_sessions" ( diff --git a/src/admin/mod.rs b/src/admin/mod.rs index a9c676a..953a7c5 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -2,6 +2,10 @@ use crate::{auth::Backend, models::rating::Rating, page, AppState}; use axum::{extract::State, routing::get, Router}; use axum_login::login_required; use maud::{html, Markup}; +use rand::{ + distr::{Distribution, Uniform}, + rng, +}; use route::Route; use sqlx::SqlitePool; use std::sync::Arc; @@ -10,6 +14,15 @@ use tower_sessions::Session; pub(crate) mod route; pub(crate) mod station; pub(crate) mod team; +pub(crate) mod user; + +fn generate_random_alphanumeric(length: usize) -> String { + let mut rng = rng(); + let chars: Vec = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); + let dist = Uniform::new(0, chars.len()).unwrap(); + + (0..length).map(|_| chars[dist.sample(&mut rng)]).collect() +} async fn highscore(State(db): State>, session: Session) -> Markup { let routes = Route::all(&db).await; @@ -131,6 +144,11 @@ async fn index(session: Session) -> Markup { "Highscore" } } + li { + a role="button" href="/admin/user" { + "Admins" + } + } } } }; @@ -144,5 +162,6 @@ pub(super) fn routes() -> Router { .nest("/station", station::routes()) .nest("/route", route::routes()) .nest("/team", team::routes()) + .nest("/user", user::routes()) .layer(login_required!(Backend, login_url = "/auth/login")) } diff --git a/src/admin/station/mod.rs b/src/admin/station/mod.rs index e94597e..acbbaca 100644 --- a/src/admin/station/mod.rs +++ b/src/admin/station/mod.rs @@ -1,4 +1,4 @@ -use super::team::Team; +use super::{generate_random_alphanumeric, team::Team}; use crate::{ admin::route::Route, models::rating::{Rating, TeamsAtStationLocation}, @@ -6,11 +6,6 @@ use crate::{ }; use axum::Router; use chrono::{DateTime, Local, NaiveDateTime, Utc}; -use rand::{ - distr::{Distribution, Uniform}, - rng, -}; - use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; @@ -83,16 +78,8 @@ impl Station { .unwrap(); } - fn generate_random_alphanumeric(length: usize) -> String { - let mut rng = rng(); - let chars: Vec = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); - let dist = Uniform::new(0, chars.len()).unwrap(); - - (0..length).map(|_| chars[dist.sample(&mut rng)]).collect() - } - pub(crate) async fn create(db: &SqlitePool, name: &str) -> Result<(), String> { - let code = Self::generate_random_alphanumeric(8); + let code = generate_random_alphanumeric(8); sqlx::query!("INSERT INTO station(name, pw) VALUES (?, ?)", name, code) .execute(db) .await diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs new file mode 100644 index 0000000..32b2853 --- /dev/null +++ b/src/admin/user/mod.rs @@ -0,0 +1,95 @@ +use super::generate_random_alphanumeric; +use crate::{auth::User, AppState}; +use argon2::password_hash::rand_core::OsRng; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use axum::Router; +use sqlx::SqlitePool; + +mod web; + +impl User { + pub(crate) async fn all(db: &SqlitePool) -> Vec { + sqlx::query_as::<_, Self>( + "SELECT id, name, pw, require_new_password_code FROM user ORDER BY name;", + ) + .fetch_all(db) + .await + .unwrap() + } + + pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option { + sqlx::query_as!( + Self, + "SELECT id, name, pw, require_new_password_code FROM user WHERE id = ?", + id + ) + .fetch_one(db) + .await + .ok() + } + + async fn create(db: &SqlitePool, name: &str) -> Result { + let code = generate_random_alphanumeric(8); + let result = sqlx::query!( + "INSERT INTO user(name, pw, require_new_password_code) VALUES (?, 'pw-to-be-defined', ?) RETURNING id", + name, + code + ) + .fetch_one(db) + .await + .map_err(|e| e.to_string())?; + Ok(result.id) + } + + async fn update_name(&self, db: &SqlitePool, name: &str) { + sqlx::query!("UPDATE user SET name = ? WHERE id = ?", name, self.id) + .execute(db) + .await + .unwrap(); + } + + async fn new_pw(&self, db: &SqlitePool) { + let code = generate_random_alphanumeric(8); + sqlx::query!( + "UPDATE user SET pw='pw-to-be-redefined', require_new_password_code=? WHERE id = ?", + code, + self.id + ) + .execute(db) + .await + .unwrap(); + } + + pub(crate) async fn update_pw(&self, db: &SqlitePool, pw: &str) -> Result<(), String> { + if self.require_new_password_code.is_some() { + let argon2 = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + let password_hash = argon2 + .hash_password(pw.as_bytes(), &salt) + .unwrap() + .to_string(); + sqlx::query!( + "UPDATE user SET pw = ?, require_new_password_code=NULL WHERE id = ?", + password_hash, + self.id + ) + .execute(db) + .await + .unwrap(); + return Ok(()); + } + Err("User hat schon ein Passwort...".into()) + } + + async fn delete(&self, db: &SqlitePool) -> Result<(), String> { + sqlx::query!("DELETE FROM user WHERE id = ?", self.id) + .execute(db) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +pub(super) fn routes() -> Router { + web::routes() +} diff --git a/src/admin/user/web.rs b/src/admin/user/web.rs new file mode 100644 index 0000000..d22f837 --- /dev/null +++ b/src/admin/user/web.rs @@ -0,0 +1,238 @@ +use crate::{auth::User, err, partials::page, succ, AppState}; +use axum::{ + extract::State, + response::{IntoResponse, Redirect}, + routing::{get, post}, + Form, Router, +}; +use maud::{html, Markup}; +use serde::Deserialize; +use sqlx::SqlitePool; +use std::sync::Arc; +use tower_sessions::Session; + +#[derive(Deserialize)] +struct CreateForm { + name: String, +} + +async fn create( + State(db): State>, + session: Session, + Form(form): Form, +) -> impl IntoResponse { + let id = match User::create(&db, &form.name).await { + Ok(id) => { + succ!(session, "User '{}' erfolgreich erstellt!", form.name); + id + } + Err(e) => { + err!( + session, + "User '{}' konnte _NICHT_ erstellt werden: {e:?}", + e + ); + + return Redirect::to("/admin/user"); + } + }; + + Redirect::to(&format!("/admin/user/{}", id)) +} + +async fn delete( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, +) -> impl IntoResponse { + let Some(user) = User::find_by_id(&db, id).await else { + err!( + session, + "User mit ID {id} konnte nicht gelöscht werden, da sie nicht existiert" + ); + + return Redirect::to("/admin/user"); + }; + + match user.delete(&db).await { + Ok(()) => succ!(session, "User '{}' erfolgreich gelöscht!", user.name), + Err(e) => err!( + session, + "User '{}' kann nicht gelöscht werden, da er/sie bereits verwendet wird. ({e})", + user.name + ), + } + + Redirect::to("/admin/user") +} + +async fn view( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, +) -> Result { + let Some(user) = User::find_by_id(&db, id).await else { + err!( + session, + "User mit ID {id} konnte nicht geöffnet werden, da sie nicht existiert" + ); + + return Err(Redirect::to("/admin/user")); + }; + + // maybe switch to maud-display impl of team + let content = html! { + h1 { + a href="/admin/user" { "↩️" } + "User " (user.name) + } + article { + details { + summary { "Name bearbeiten ✏️" } + form action=(format!("/admin/user/{}/name", user.id)) method="post" { + input type="text" name="name" value=(user.name) required; + input type="submit" value="Speichern"; + } + + } + } + table { + tbody { + tr { + th scope="row" { "Name" }; + td { + (user.name) + } + } + tr { + th scope="row" { "Passwort" }; + td { + @if let Some(code) = user.require_new_password_code { + a href=(format!("/user/{}/set-pw/{}", user.id, code)){ + "Login-Link" + } + } @else { + a href=(format!("/admin/user/{}/new-pw", user.id)) + onclick="return confirm('Bist du sicher, dass du einen neuen Passwort-Link generieren willst? Mit dem alten Passwort kann man sich dann nicht mehr einloggen.');" { + "Passwort vergessen: neuen Loginlink generieren" + } + } + } + } + } + } + }; + Ok(page(content, session, true).await) +} + +#[derive(Deserialize)] +struct UpdateNameForm { + name: String, +} +async fn update_name( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(user) = User::find_by_id(&db, id).await else { + err!( + session, + "User mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/admin/user"); + }; + + user.update_name(&db, &form.name).await; + + succ!( + session, + "User '{}' heißt ab sofort '{}'.", + user.name, + form.name + ); + + Redirect::to(&format!("/admin/user/{id}")) +} + +async fn new_pw( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, +) -> impl IntoResponse { + let Some(user) = User::find_by_id(&db, id).await else { + err!( + session, + "User mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/admin/user"); + }; + + user.new_pw(&db).await; + + succ!( + session, + "Neuer Loginlink für User '{}' wurde generiert.", + user.name + ); + + Redirect::to(&format!("/admin/user/{id}")) +} + +async fn index(State(db): State>, session: Session) -> Markup { + let users = User::all(&db).await; + + let content = html! { + h1 { + a href="/admin" { "↩️" } + "User" + } + article { + em { "Admins " } + "sind Menschen, die " + a href="/admin/station" { "Stationen" } + ", " + a href="/admin/route" { "Routen" } + " und " + a href="/admin/team" { "Teams" } + " bearbeiten können. Zusätzlich sehen sie das " + a href="/admin/highscore" { "Ergebnis" } + "." + } + article { + h2 { "Neuer User" } + form action="/admin/user" method="post" { + fieldset role="group" { + input type="text" name="name" placeholder="Name" required; + input type="submit" value="Neuer User"; + } + } + } + ul { + @for user in &users { + li { + a href=(format!("/admin/user/{}", user.id)){ + (user.name) + } + a href=(format!("/admin/user/{}/delete", user.id)) + onclick="return confirm('Bist du sicher, dass der User gelöscht werden soll? Das kann _NICHT_ mehr rückgängig gemacht werden.');" { + "🗑️" + } + } + } + } + }; + page(content, session, false).await +} + +pub(super) fn routes() -> Router { + Router::new() + .route("/", get(index)) + .route("/", post(create)) + .route("/{id}", get(view)) + .route("/{id}/delete", get(delete)) + .route("/{id}/new-pw", get(new_pw)) + .route("/{id}/name", post(update_name)) +} diff --git a/src/auth.rs b/src/auth.rs index 5f68a26..77a5a80 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -17,9 +17,10 @@ pub type UserId = <::User as AuthUser>::Id; #[derive(Clone, Serialize, Deserialize, FromRow, Debug)] pub struct User { - id: i64, - name: String, - pw: String, + pub(crate) id: i64, + pub(crate) name: String, + pub(crate) pw: String, + pub(crate) require_new_password_code: Option, } #[derive(Debug, Clone, Deserialize)] @@ -67,22 +68,27 @@ impl AuthnBackend for Backend { &self, creds: Self::Credentials, ) -> Result, Self::Error> { - let user: Option = - sqlx::query_as("SELECT id, name, pw FROM user WHERE name = ? ") - .bind(creds.name) - .fetch_optional(&self.db) - .await?; + let user: Option = sqlx::query_as( + "SELECT id, name, pw, require_new_password_code FROM user WHERE name = ? ", + ) + .bind(creds.name) + .fetch_optional(&self.db) + .await?; // We're using password-based authentication--this works by comparing our form // input with an argon2 password hash. - Ok(user.filter(|user| verify_password(creds.password, &user.pw).is_ok())) + Ok(user.filter(|user| { + verify_password(creds.password, &user.pw).is_ok() + && user.require_new_password_code.is_none() + })) } async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { - let user = sqlx::query_as("SELECT id, name, pw FROM user WHERE id = ?") - .bind(user_id) - .fetch_optional(&self.db) - .await?; + let user = + sqlx::query_as("SELECT id, name, pw, require_new_password_code FROM user WHERE id = ?") + .bind(user_id) + .fetch_optional(&self.db) + .await?; Ok(user) } diff --git a/src/lib.rs b/src/lib.rs index ebd1efd..8436c09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,20 +17,22 @@ macro_rules! testdb { i18n!("locales", fallback = "de-AT"); use admin::station::Station; -use auth::Backend; +use auth::{AuthSession, Backend, User}; use axum::{ body::Body, - extract::FromRef, + extract::{FromRef, State}, response::{IntoResponse, Redirect, Response}, - routing::get, - Router, + routing::{get, post}, + Form, Router, }; use axum_login::AuthManagerLayerBuilder; +use maud::{html, Markup}; use partials::page; +use serde::Deserialize; use sqlx::SqlitePool; use std::sync::Arc; use tokio::net::TcpListener; -use tower_sessions::{cookie::time::Duration, Expiry, SessionManagerLayer}; +use tower_sessions::{cookie::time::Duration, Expiry, Session, SessionManagerLayer}; use tower_sessions_sqlx_store_chrono::SqliteStore; pub(crate) mod admin; @@ -168,6 +170,100 @@ impl FromRef for Arc { state.db.clone() } } +async fn set_pw( + State(db): State>, + session: Session, + axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>, +) -> Result { + let Some(user) = User::find_by_id(&db, id).await else { + err!(session, "User mit ID {id} gibt's ned"); + return Err(Redirect::to("/")); + }; + + let Some(correct_code) = user.require_new_password_code else { + err!( + session, + "User {} hat bereits ein Passwort. Du kannst kein neues setzen.", + user.name + ); + return Err(Redirect::to("/")); + }; + + if correct_code != code { + err!( + session, + "Falscher Code zum Passwort setzen für User {}.", + user.name + ); + return Err(Redirect::to("/")); + } + + let content = html! { + h1 { + "Neues Passwort für " + (user.name) + " setzen" + } + form action=(format!("/user/{}/set-pw", user.id)) method="post" { + input type="hidden" name="code" value=(code); + label { + "Passwort" + input type="password" name="password"; + } + input type="submit" value="Einloggen"; + } + }; + + Ok(page(content, session, false).await) +} +#[derive(Deserialize)] +struct NewPwForm { + code: String, + password: String, +} +async fn set_concrete_pw( + mut auth_session: AuthSession, + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(user) = User::find_by_id(&db, id).await else { + err!(session, "User mit ID {id} gibt's ned"); + return Redirect::to("/").into_response(); + }; + + let Some(correct_code) = &user.require_new_password_code else { + err!( + session, + "User {} hat bereits ein Passwort. Du kannst kein neues setzen.", + user.name + ); + return Redirect::to("/").into_response(); + }; + + if correct_code != &form.code { + err!( + session, + "Falscher Code zum Passwort setzen für User {}.", + user.name + ); + return Redirect::to("/").into_response(); + } + + match user.update_pw(&db, &form.password).await { + Ok(()) => { + let user = User::find_by_id(&db, id).await.unwrap(); + auth_session.login(&user).await.unwrap(); + succ!(session, "Passwort erfolgreich gesetzt"); + Redirect::to("/admin").into_response() + } + Err(e) => { + err!(session, "{e}"); + Redirect::to("/admin").into_response() + } + } +} fn router(db: SqlitePool) -> Router { let session_store = SqliteStore::new(db.clone()); @@ -186,6 +282,8 @@ fn router(db: SqlitePool) -> Router { .nest("/s/{id}/{code}", station::routes()) // TODO: maybe switch to "/" .nest("/admin", admin::routes()) .nest("/auth", auth::routes()) + .route("/user/{id}/set-pw/{code}", get(set_pw)) + .route("/user/{id}/set-pw", post(set_concrete_pw)) .route("/pico.css", get(serve_pico_css)) .route("/style.css", get(serve_my_css)) .route("/leaflet.css", get(serve_leaflet_css))