Add initial setup and admin page
Some checks failed
Cargo Build & Test / Rust project - latest (stable) (push) Failing after 23s
Cargo Build & Test / Rust project - latest (beta) (push) Failing after 35s
Cargo Build & Test / Rust project - latest (nightly) (push) Failing after 33s

This commit is contained in:
2025-10-04 16:04:19 -05:00
parent bf5d3dc222
commit f859f8f34b
15 changed files with 926 additions and 44 deletions

173
src/admin.rs Normal file
View File

@@ -0,0 +1,173 @@
use std::convert::Infallible;
use argon2::Argon2;
use either::Either;
use password_hash::{PasswordHasher, SaltString, rand_core::OsRng};
use rocket::{
FromForm, Request, Route, TypedError, async_trait,
form::Form,
get,
outcome::{Outcome, try_outcome},
post,
request::FromRequest,
response::Redirect,
routes,
serde::{Deserialize, Serialize},
trace::info,
uri,
};
use rocket_db_pools::Connection;
use rocket_dyn_templates::{Template, context};
use crate::{
Db, SqlError,
auth::{self, AdminUser, BasicAuth},
};
#[get("/", rank = 1)]
fn dash(auth: Option<BasicAuth>) -> Template {
info!("user: {:?}", auth.as_ref().map(|a| a.username()));
Template::render(
"dash",
context! {
username: auth.as_ref().map(|a| a.username()),
admin: auth.as_ref().map(|a| a.is_admin()).unwrap_or(false),
mode: "dark",
},
)
}
#[get("/login")]
fn login(auth: Option<BasicAuth>) -> Either<Template, Redirect> {
if auth.is_some() {
Either::Right(Redirect::temporary(uri!(dash)))
} else {
Either::Left(Template::render(
"login",
context! {
mode: "dark",
},
))
}
}
#[get("/account")]
fn account(auth: BasicAuth) -> Template {
Template::render(
"account",
context! {
username: auth.username(),
admin: auth.is_admin(),
mode: "dark",
},
)
}
#[get("/admin")]
async fn admin(auth: AdminUser, mut db: Connection<Db>) -> Result<Template, SqlError> {
let users = sqlx::query!("SELECT * from users")
.fetch_all(&mut **db)
.await?;
Ok(Template::render(
"admin",
context! {
username: auth.username(),
admin: auth.is_admin(),
users: users.into_iter().filter(|r| r.name != auth.username()).map(|r| r.name).collect::<Vec<_>>(),
mode: "dark",
},
))
}
struct InitialSetup;
#[derive(Debug, TypedError)]
pub enum InitialSetupError {
#[error(status = 500)]
InternalError,
#[error(status = 500)]
DbError(#[error(source)] SqlError),
}
impl From<SqlError> for InitialSetupError {
fn from(value: SqlError) -> Self {
Self::DbError(value)
}
}
impl From<sqlx::Error> for InitialSetupError {
fn from(value: sqlx::Error) -> Self {
Self::DbError(value.into())
}
}
#[derive(Debug, TypedError)]
struct Empty;
impl From<Infallible> for Empty {
fn from(value: Infallible) -> Self {
match value {}
}
}
#[async_trait]
impl<'r> FromRequest<'r> for InitialSetup {
type Forward = Empty;
type Error = InitialSetupError;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error, Self::Forward> {
// TODO: cache result in managed state?
let mut db = try_outcome!(
req.guard::<Connection<Db>>()
.await
.map_error(|_| InitialSetupError::InternalError)
);
let user_count = try_outcome!(
sqlx::query!("SELECT count(name) as count from users")
.fetch_one(&mut **db)
.await
.into()
);
if user_count.count == 0 {
Outcome::Success(Self)
} else {
Outcome::Forward(Empty)
}
}
}
#[get("/")]
fn initial_setup(_initial: InitialSetup) -> Template {
Template::render("initial_setup", context! {})
}
#[derive(Debug, Serialize, Deserialize, FromForm)]
#[serde(crate = "rocket::serde")]
pub struct InitialSetupOptions<'a> {
admin_username: &'a str,
admin_password: &'a str,
}
#[post("/initial_setup", data = "<opts>")]
async fn initial_complete(
_initial: InitialSetup,
opts: Form<InitialSetupOptions<'_>>,
mut db: Connection<Db>,
) -> Result<Redirect, SqlError> {
let salt = SaltString::generate(&mut OsRng);
let pw_hash = Argon2::default()
.hash_password(opts.admin_password.as_bytes(), salt.as_salt())
.expect("Failed to hash password");
sqlx::query("INSERT INTO users (name, role, password) VALUES (?1, \"admin\", ?2)")
.bind(opts.admin_username)
.bind(pw_hash.to_string())
.execute(&mut **db)
.await?;
Ok(Redirect::to("/"))
}
pub fn routes() -> Vec<Route> {
routes![
dash,
login,
auth::login_form,
auth::update_password,
account,
admin,
initial_setup,
initial_complete,
]
}

View File

@@ -1,24 +1,19 @@
#![allow(private_interfaces)]
use std::str::Utf8Error;
use argon2::{Argon2, PasswordVerifier};
use base64::{DecodeError, Engine, alphabet::STANDARD, engine::GeneralPurposeConfig};
use password_hash::{PasswordHash, PasswordHasher, SaltString, rand_core::OsRng};
use rocket::{
Request, Route, TypedError, async_trait,
http::{CookieJar, Status},
post,
request::{FromRequest, Outcome},
routes,
serde::{Deserialize, Serialize, json::Json},
trace::debug,
async_trait, catch, catchers, form::Form, http::{CookieJar, Status}, post, request::{FromRequest, Outcome}, response::Redirect, routes, serde::{Deserialize, Serialize}, trace::debug, uri, Catcher, FromForm, Request, Route, TypedError
};
use rocket_db_pools::Connection;
use rocket_dyn_templates::{context, Template};
use crate::{Db, SqlError};
pub struct BasicAuth {
username: String,
is_admin: bool,
}
#[derive(Debug, TypedError)]
pub enum Unauthorized {
@@ -33,6 +28,8 @@ pub enum Unauthorized {
#[error(status = 401)]
UserNotFound,
#[error(status = 401)]
UserNotAuthorized,
#[error(status = 401)]
PasswordIncorrect,
#[error(status = 500)]
InternalError,
@@ -55,14 +52,21 @@ impl BasicAuth {
async fn from_req<'r>(req: &'r Request<'_>) -> Result<Self, Unauthorized> {
// TODO: actual sessions
if let Some(cookie) = req.cookies().get_private("SESSION") {
return Ok(Self {
username: cookie.value().into(),
});
// } else if let Some(username) = req.headers().get_one("test") {
// return Ok(Self {
// username: username.into(),
// });
let raw = cookie.value();
if let Some((role, username)) = raw.split_once(":") {
return Ok(Self {
username: username.into(),
is_admin: role == "admin"
});
} else {
return Err(Unauthorized::InternalError);
}
}
// else if let Some(username) = req.headers().get_one("test") {
// return Ok(Self {
// username: username.into()
// })
// }
let auth = req
.headers()
.get_one("Authorization")
@@ -89,14 +93,13 @@ impl BasicAuth {
.ok_or(Unauthorized::UserNotFound)?;
let hashed =
PasswordHash::new(&user.password).expect("Invalid password hash stored in the db");
// hashed
// .verify_password(&[&Argon2::default()], pass)
Argon2::default().verify_password(pass.as_bytes(), &hashed)
.map_err(|_| Unauthorized::PasswordIncorrect)
.map(|()| {
req.cookies().add_private(("SESSION", user.name.clone()));
req.cookies().add_private(("SESSION", format!("{}:{}", user.role, user.name)));
Self {
username: user.name,
is_admin: user.role == "admin"
}
})
}
@@ -118,6 +121,35 @@ impl BasicAuth {
pub fn username(&self) -> &str {
&self.username
}
// pub fn into_username(self) -> String {
// self.username
// }
pub fn is_admin(&self) -> bool {
self.is_admin
}}
pub struct AdminUser(BasicAuth);
impl std::ops::Deref for AdminUser {
type Target = BasicAuth;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl<'r> FromRequest<'r> for AdminUser {
type Forward = std::convert::Infallible;
type Error = Unauthorized;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error, Self::Forward> {
match BasicAuth::from_req(req).await {
Ok(v) if v.is_admin => rocket::outcome::Outcome::Success(Self(v)),
Ok(_) => rocket::outcome::Outcome::Error(Unauthorized::UserNotAuthorized),
Err(v) => rocket::outcome::Outcome::Error(v),
}
}
}
#[post("/auth/<username>/login.json")]
@@ -129,23 +161,22 @@ pub fn login(username: &str, auth: BasicAuth) -> Result<&'static str, Status> {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, FromForm)]
#[serde(crate = "rocket::serde")]
pub struct PasswordChange<'a> {
password: &'a str,
}
#[post("/auth/update_password", data = "<pw>")]
#[post("/update_password", data = "<pw>")]
pub async fn update_password(
auth: BasicAuth,
pw: Json<PasswordChange<'_>>,
pw: Form<PasswordChange<'_>>,
mut db: Connection<Db>,
) -> Result<&'static str, SqlError> {
let salt = SaltString::generate(&mut OsRng);
let pw_hash = Argon2::default()
.hash_password(pw.password.as_bytes(), salt.as_salt())
.expect("Failed to hash password");
// pw_hash.to_string()
sqlx::query("INSERT INTO users (name, password) VALUES (?1, ?2) ON CONFLICT DO UPDATE SET password = ?2")
.bind(auth.username)
.bind(pw_hash.to_string())
@@ -154,6 +185,38 @@ pub async fn update_password(
Ok("")
}
#[derive(Debug, Serialize, Deserialize, FromForm)]
#[serde(crate = "rocket::serde")]
pub struct LoginForm<'a> {
username: &'a str,
password: &'a str,
}
#[post("/login", data = "<pw>")]
pub async fn login_form(
pw: Form<LoginForm<'_>>,
mut db: Connection<Db>,
cookies: &CookieJar<'_>,
) -> Result<Redirect, Unauthorized> {
let user = sqlx::query!("SELECT * from users where name = ?", pw.username)
.fetch_optional(&mut **db)
.await?
.ok_or(Unauthorized::UserNotFound)?;
let hashed =
PasswordHash::new(&user.password).expect("Invalid password hash stored in the db");
Argon2::default().verify_password(pw.password.as_bytes(), &hashed)
.map_err(|_| Unauthorized::PasswordIncorrect)?;
cookies.add_private(("SESSION", format!("{}:{}", user.role, pw.username)));
Ok(Redirect::to(uri!("/")))
}
#[catch(401, error="<e>")]
pub fn auth_failure(e: &Unauthorized) -> Template {
Template::render("401", context! {
error: format!("{e:?}")
})
}
#[post("/auth/<username>/logout.json")]
pub fn logout(
username: &str,
@@ -169,5 +232,9 @@ pub fn logout(
}
pub fn routes() -> Vec<Route> {
routes![login, logout, update_password]
routes![login, logout]
}
pub fn catchers() -> Vec<Catcher> {
catchers![auth_failure]
}

View File

@@ -1,3 +1,4 @@
#![allow(private_interfaces)]
use rocket::{catch, catchers, fairing::AdHoc, launch, TypedError};
mod auth;
@@ -7,14 +8,18 @@ mod subscriptions;
mod suggestions;
mod time;
mod episodes;
mod admin;
use rocket_db_pools::{
Database,
sqlx,
};
use rocket_dyn_templates::Template;
pub use time::Timestamp;
mod format;
pub use format::*;
use crate::auth::catchers;
#[derive(Debug, TypedError)]
pub struct SqlError(sqlx::Error);
@@ -34,8 +39,8 @@ fn catch_sql(error: &SqlError) -> String {
struct Db(sqlx::SqlitePool);
const SQL_INIT: &[&str] = &[
"CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY NOT NULL, password TEXT NOT NULL);",
"REPLACE INTO users (name, password) VALUES ('matt', 'pass')",
"CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY NOT NULL, role TEXT NOT NULL, password TEXT NOT NULL);",
// "REPLACE INTO users (name, password) VALUES ('matt', 'admin', 'pass')",
"CREATE TABLE IF NOT EXISTS devices (
id TEXT NOT NULL,
user TEXT NOT NULL,
@@ -77,6 +82,7 @@ const SQL_INIT: &[&str] = &[
fn launch() -> _ {
rocket::build()
.attach(Db::init())
.attach(Template::fairing())
.attach(AdHoc::on_liftoff("Init db", |r| {
Box::pin(async {
if let Some(db) = Db::fetch(r) {
@@ -98,5 +104,7 @@ fn launch() -> _ {
// .mount("/", suggestions::routes())
// .mount("/api/2", suggestions::routes())
.mount("/api/2", episodes::routes())
.mount("/", admin::routes())
.register("/", catchers![catch_sql])
.register("/", catchers())
}