Add initial setup and admin page
This commit is contained in:
173
src/admin.rs
Normal file
173
src/admin.rs
Normal 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,
|
||||
]
|
||||
}
|
||||
113
src/auth.rs
113
src/auth.rs
@@ -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]
|
||||
}
|
||||
|
||||
12
src/main.rs
12
src/main.rs
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user