user crud; Fixes #41
All checks were successful
CI/CD Pipeline / test (push) Successful in 5m40s
CI/CD Pipeline / deploy (push) Successful in 3m41s

This commit is contained in:
Philipp Hofer 2025-04-18 14:48:48 +02:00
parent 4760e9276a
commit 6b96b047e0
9 changed files with 480 additions and 34 deletions

1
Cargo.lock generated
View File

@ -2216,6 +2216,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
name = "stationslauf" name = "stationslauf"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2",
"async-trait", "async-trait",
"axum", "axum",
"axum-login", "axum-login",

View File

@ -22,6 +22,7 @@ tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["sqlite"] }
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
futures = "0.3" futures = "0.3"
rand = "0.9" rand = "0.9"
argon2 = "0.5"
[dev-dependencies] [dev-dependencies]

View File

@ -51,7 +51,8 @@ CREATE TABLE IF NOT EXISTS rating (
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE, 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" ( create table if not exists "tower_sessions" (

View File

@ -2,6 +2,10 @@ use crate::{auth::Backend, models::rating::Rating, page, AppState};
use axum::{extract::State, routing::get, Router}; use axum::{extract::State, routing::get, Router};
use axum_login::login_required; use axum_login::login_required;
use maud::{html, Markup}; use maud::{html, Markup};
use rand::{
distr::{Distribution, Uniform},
rng,
};
use route::Route; use route::Route;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use std::sync::Arc;
@ -10,6 +14,15 @@ use tower_sessions::Session;
pub(crate) mod route; pub(crate) mod route;
pub(crate) mod station; pub(crate) mod station;
pub(crate) mod team; pub(crate) mod team;
pub(crate) mod user;
fn generate_random_alphanumeric(length: usize) -> String {
let mut rng = rng();
let chars: Vec<char> = "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<Arc<SqlitePool>>, session: Session) -> Markup { async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let routes = Route::all(&db).await; let routes = Route::all(&db).await;
@ -131,6 +144,11 @@ async fn index(session: Session) -> Markup {
"Highscore" "Highscore"
} }
} }
li {
a role="button" href="/admin/user" {
"Admins"
}
}
} }
} }
}; };
@ -144,5 +162,6 @@ pub(super) fn routes() -> Router<AppState> {
.nest("/station", station::routes()) .nest("/station", station::routes())
.nest("/route", route::routes()) .nest("/route", route::routes())
.nest("/team", team::routes()) .nest("/team", team::routes())
.nest("/user", user::routes())
.layer(login_required!(Backend, login_url = "/auth/login")) .layer(login_required!(Backend, login_url = "/auth/login"))
} }

View File

@ -1,4 +1,4 @@
use super::team::Team; use super::{generate_random_alphanumeric, team::Team};
use crate::{ use crate::{
admin::route::Route, admin::route::Route,
models::rating::{Rating, TeamsAtStationLocation}, models::rating::{Rating, TeamsAtStationLocation},
@ -6,11 +6,6 @@ use crate::{
}; };
use axum::Router; use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc}; use chrono::{DateTime, Local, NaiveDateTime, Utc};
use rand::{
distr::{Distribution, Uniform},
rng,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
@ -83,16 +78,8 @@ impl Station {
.unwrap(); .unwrap();
} }
fn generate_random_alphanumeric(length: usize) -> String {
let mut rng = rng();
let chars: Vec<char> = "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> { 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) sqlx::query!("INSERT INTO station(name, pw) VALUES (?, ?)", name, code)
.execute(db) .execute(db)
.await .await

95
src/admin/user/mod.rs Normal file
View File

@ -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<Self> {
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<Self> {
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<i64, String> {
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<AppState> {
web::routes()
}

238
src/admin/user/web.rs Normal file
View File

@ -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<Arc<SqlitePool>>,
session: Session,
Form(form): Form<CreateForm>,
) -> 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<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> 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<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
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<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNameForm>,
) -> 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<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> 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<Arc<SqlitePool>>, 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<AppState> {
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))
}

View File

@ -17,9 +17,10 @@ pub type UserId<Backend> = <<Backend as AuthnBackend>::User as AuthUser>::Id;
#[derive(Clone, Serialize, Deserialize, FromRow, Debug)] #[derive(Clone, Serialize, Deserialize, FromRow, Debug)]
pub struct User { pub struct User {
id: i64, pub(crate) id: i64,
name: String, pub(crate) name: String,
pw: String, pub(crate) pw: String,
pub(crate) require_new_password_code: Option<String>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -67,19 +68,24 @@ impl AuthnBackend for Backend {
&self, &self,
creds: Self::Credentials, creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> { ) -> Result<Option<Self::User>, Self::Error> {
let user: Option<Self::User> = let user: Option<Self::User> = sqlx::query_as(
sqlx::query_as("SELECT id, name, pw FROM user WHERE name = ? ") "SELECT id, name, pw, require_new_password_code FROM user WHERE name = ? ",
)
.bind(creds.name) .bind(creds.name)
.fetch_optional(&self.db) .fetch_optional(&self.db)
.await?; .await?;
// We're using password-based authentication--this works by comparing our form // We're using password-based authentication--this works by comparing our form
// input with an argon2 password hash. // 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<Self>) -> Result<Option<Self::User>, Self::Error> { async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user = sqlx::query_as("SELECT id, name, pw FROM user WHERE id = ?") let user =
sqlx::query_as("SELECT id, name, pw, require_new_password_code FROM user WHERE id = ?")
.bind(user_id) .bind(user_id)
.fetch_optional(&self.db) .fetch_optional(&self.db)
.await?; .await?;

View File

@ -17,20 +17,22 @@ macro_rules! testdb {
i18n!("locales", fallback = "de-AT"); i18n!("locales", fallback = "de-AT");
use admin::station::Station; use admin::station::Station;
use auth::Backend; use auth::{AuthSession, Backend, User};
use axum::{ use axum::{
body::Body, body::Body,
extract::FromRef, extract::{FromRef, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
routing::get, routing::{get, post},
Router, Form, Router,
}; };
use axum_login::AuthManagerLayerBuilder; use axum_login::AuthManagerLayerBuilder;
use maud::{html, Markup};
use partials::page; use partials::page;
use serde::Deserialize;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; 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; use tower_sessions_sqlx_store_chrono::SqliteStore;
pub(crate) mod admin; pub(crate) mod admin;
@ -168,6 +170,100 @@ impl FromRef<AppState> for Arc<SqlitePool> {
state.db.clone() state.db.clone()
} }
} }
async fn set_pw(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>,
) -> Result<Markup, impl IntoResponse> {
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<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<NewPwForm>,
) -> 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 { fn router(db: SqlitePool) -> Router {
let session_store = SqliteStore::new(db.clone()); 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("/s/{id}/{code}", station::routes()) // TODO: maybe switch to "/"
.nest("/admin", admin::routes()) .nest("/admin", admin::routes())
.nest("/auth", auth::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("/pico.css", get(serve_pico_css))
.route("/style.css", get(serve_my_css)) .route("/style.css", get(serve_my_css))
.route("/leaflet.css", get(serve_leaflet_css)) .route("/leaflet.css", get(serve_leaflet_css))