Render the login form with Hornbeam and expose Formbeam
This commit is contained in:
parent
14ed5de2a3
commit
0c617627fc
5
.gitignore
vendored
5
.gitignore
vendored
@ -3,6 +3,11 @@
|
||||
/.direnv
|
||||
/.devenv
|
||||
/book
|
||||
|
||||
|
||||
/static/light.css
|
||||
/static/dark.css
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
59
Cargo.lock
generated
59
Cargo.lock
generated
@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
@ -1198,6 +1198,15 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "formbeam_derive"
|
||||
version = "0.0.4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
@ -1563,6 +1572,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bevy_reflect",
|
||||
"fluent-templates",
|
||||
"formbeam",
|
||||
"hornbeam_grammar",
|
||||
"hornbeam_ir",
|
||||
"html-escape",
|
||||
@ -1650,6 +1660,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@ -1847,6 +1863,7 @@ dependencies = [
|
||||
"axum-extra",
|
||||
"axum-test",
|
||||
"base64 0.21.7",
|
||||
"bevy_reflect",
|
||||
"blake2",
|
||||
"chrono",
|
||||
"clap",
|
||||
@ -1854,6 +1871,7 @@ dependencies = [
|
||||
"confique",
|
||||
"eyre",
|
||||
"formbeam",
|
||||
"formbeam_derive",
|
||||
"futures",
|
||||
"governor",
|
||||
"hornbeam",
|
||||
@ -1866,6 +1884,7 @@ dependencies = [
|
||||
"pgtemp",
|
||||
"rand 0.8.5",
|
||||
"rand_xoshiro",
|
||||
"regex",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -2319,6 +2338,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@ -3887,6 +3916,19 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.22"
|
||||
@ -3968,9 +4010,18 @@ checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@ -4150,6 +4201,12 @@ dependencies = [
|
||||
"unic-langid-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
@ -24,8 +24,10 @@ confique = { version = "0.2.4", features = ["toml"], default-features = false }
|
||||
eyre = "0.6.8"
|
||||
futures = "0.3.29"
|
||||
governor = "0.6.0"
|
||||
hornbeam = { version = "0.0.4", path = "../hornbeam/hornbeam" }
|
||||
hornbeam = { version = "0.0.4", path = "../hornbeam/hornbeam", features = ["formbeam"] }
|
||||
formbeam = { version = "0.0.4", path = "../hornbeam/formbeam" }
|
||||
formbeam_derive = { version = "0.0.4", path = "../hornbeam/formbeam_derive" }
|
||||
bevy_reflect = { version = "0.14.0" }
|
||||
josekit = "0.8.4"
|
||||
metrics = "0.21.1"
|
||||
metrics-exporter-prometheus = "0.12.1"
|
||||
@ -41,7 +43,7 @@ subtle = "2.5.0"
|
||||
time = "0.3.30"
|
||||
tokio = { version = "1.33.0", features = ["rt", "macros"] }
|
||||
tower-cookies = "0.11.0"
|
||||
tower-http = { version = "0.6.4", features = ["trace", "cors", "set-header"] }
|
||||
tower-http = { version = "0.6.4", features = ["trace", "cors", "set-header", "fs"] }
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||
uuid = { version = "1.16.0", features = ["serde"] }
|
||||
@ -54,3 +56,4 @@ maplit = "1.0.2"
|
||||
pgtemp = "0.3.0"
|
||||
rand_xoshiro = "0.6.0"
|
||||
rstest = "0.21.0"
|
||||
regex = "1.11.1"
|
||||
|
@ -45,6 +45,9 @@ However, if desired, please contact me via the e-mail address found in the git c
|
||||
We have a Nix flake available containing all the required tools; either use direnv and `direnv allow` this repository
|
||||
or use `devenv shell`.
|
||||
|
||||
### Building the CSS
|
||||
|
||||
Use `scripts-dev/watch_css.sh` to build the CSS whenever the source change.
|
||||
|
||||
### Database
|
||||
|
||||
|
8
scripts-dev/watch_css.sh
Executable file
8
scripts-dev/watch_css.sh
Executable file
@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
while true; do
|
||||
find ./src_scss | entr -d bash -c 'for theme in "dark" "light"; do grass --style compressed src_scss/$theme.scss static/$theme.css; done'
|
||||
done
|
@ -70,7 +70,7 @@ impl IdCoopStore {
|
||||
}
|
||||
|
||||
/// Representation of a user
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
/// The unique ID for the user.
|
||||
/// Should never change.
|
||||
|
@ -1,9 +1,9 @@
|
||||
---
|
||||
source: src/tests/test_oidc_auth_flow.rs
|
||||
expression: "(headers, text)"
|
||||
expression: "(headers, xsrf_box)"
|
||||
---
|
||||
- content-length: "238"
|
||||
- content-length: "864"
|
||||
content-type: text/html; charset=utf-8
|
||||
set-cookie: __Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=43200000
|
||||
x-frame-options: DENY
|
||||
- "<form method='POST'>UN<input type='text' name='username'> PW<input type='password' name='password'> <input type='hidden' name='xsrf' value='HL4qRFKUlBqkrPTvAQ6z-w'><button type='submit'>click here to login</button> (temporary form)</form>"
|
||||
- "<input name=\"xsrf\" type=\"hidden\" value=\"HL4qRFKUlBqkrPTvAQ6z-w\">"
|
||||
|
@ -7,6 +7,7 @@ use axum_test::{TestResponse, TestServer};
|
||||
use insta::assert_yaml_snapshot;
|
||||
|
||||
use maplit::btreemap;
|
||||
use regex::Regex;
|
||||
use sqlx::types::Uuid;
|
||||
|
||||
use crate::{passwords::create_password_hash, tests::basic_system};
|
||||
@ -83,9 +84,15 @@ async fn test_full_flow() {
|
||||
|
||||
// 2. /login request
|
||||
let resp = client.get(&login_url).await;
|
||||
let (status, headers, text) = dump_resp_text("2. /login request", resp);
|
||||
let (status, mut headers, text) = dump_resp_text("2. /login request", resp);
|
||||
assert_eq!(status, 200);
|
||||
assert_yaml_snapshot!("2/login", (headers, text));
|
||||
headers.remove("Content-Length"); // too variable, unimportant
|
||||
let xsrf_box = Regex::new("<[^<>]+xsrf[^<>]+>")
|
||||
.unwrap()
|
||||
.find(&text)
|
||||
.unwrap()
|
||||
.as_str();
|
||||
assert_yaml_snapshot!("2/login", (headers, xsrf_box));
|
||||
|
||||
// 3. /login request with credentials
|
||||
let resp = client
|
||||
|
74
src/web.rs
74
src/web.rs
@ -12,6 +12,7 @@ use std::{
|
||||
convert::Infallible,
|
||||
future::{self, Future},
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
@ -21,15 +22,18 @@ use std::{
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, FromRequestParts},
|
||||
handler::HandlerWithoutStateExt,
|
||||
http::{request::Parts, HeaderName, HeaderValue, StatusCode, Uri},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
routing::{get, get_service, post},
|
||||
Extension, Router,
|
||||
};
|
||||
use governor::{clock::QuantaClock, state::keyed::DashMapStateStore, RateLimiter};
|
||||
use hornbeam::{initialise_template_manager, make_template_manager};
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer};
|
||||
use tower_http::{
|
||||
cors::CorsLayer, services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer,
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
@ -47,6 +51,7 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
pub mod errors;
|
||||
pub mod login;
|
||||
pub mod oauth_openid;
|
||||
pub mod sessionless_xsrf;
|
||||
@ -57,6 +62,65 @@ make_template_manager! {
|
||||
};
|
||||
}
|
||||
|
||||
struct Rendered(Result<String, hornbeam::TemplateError>);
|
||||
|
||||
impl IntoResponse for Rendered {
|
||||
fn into_response(self) -> Response {
|
||||
match self.0 {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(err) => {
|
||||
error!("failed to render template: {err:?}");
|
||||
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
if cfg!(debug_assertions) {
|
||||
format!("Failed to render template:\n{err}.")
|
||||
} else {
|
||||
// TODO use a more user-friendly page
|
||||
"Failed to render template.".to_owned()
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_static_directory_to_serve() -> PathBuf {
|
||||
fn is_valid_base_dir(path: &std::path::Path) -> bool {
|
||||
path.is_dir() && path.join("static").is_dir()
|
||||
}
|
||||
|
||||
let base_dir = if let Ok(base_dir_env) =
|
||||
std::env::var("IDCOOP_BASE").or(std::env::var("HORNBEAM_BASE"))
|
||||
{
|
||||
let path = PathBuf::from(&base_dir_env);
|
||||
if is_valid_base_dir(&path) {
|
||||
path
|
||||
} else {
|
||||
panic!("Could not find statics at position of IDCOOP_BASE or HORNBEAM_BASE environment variable ({base_dir_env:?})");
|
||||
}
|
||||
} else {
|
||||
let try_paths = ["."];
|
||||
let mut successful = None;
|
||||
for path in try_paths {
|
||||
let path = PathBuf::from(path);
|
||||
if is_valid_base_dir(&path) {
|
||||
successful = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
successful
|
||||
.unwrap_or_else(|| panic!("Could not find statics: tried looking in {try_paths:?} and no IDCOOP_BASE or HORNBEAM_BASE set!"))
|
||||
};
|
||||
|
||||
base_dir
|
||||
.canonicalize()
|
||||
.expect("can't canonicalise statics dir")
|
||||
.join("static")
|
||||
}
|
||||
|
||||
/// Make an axum `Router` but do not bind it to a port.
|
||||
/// This exposition allows us to perform integration testing easily.
|
||||
pub(crate) async fn make_router(
|
||||
@ -118,6 +182,12 @@ pub(crate) async fn make_router(
|
||||
get(oidc_discovery_configuration)
|
||||
.layer(CorsLayer::permissive().max_age(Duration::from_secs(600))),
|
||||
)
|
||||
.nest_service(
|
||||
"/static",
|
||||
get_service(ServeDir::new(find_static_directory_to_serve()).fallback(
|
||||
(|| async { (StatusCode::NOT_FOUND, "404 No such resource found") }).into_service(),
|
||||
)),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CookieManagerLayer::new())
|
||||
.layer(Extension(config))
|
||||
|
33
src/web/errors.rs
Normal file
33
src/web/errors.rs
Normal file
@ -0,0 +1,33 @@
|
||||
//! Errors for web forms.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use formbeam::FieldError;
|
||||
|
||||
/// Error code for error produced when an XSRF token is invalid.
|
||||
pub const ERRCODE_XSRF_INVALID: &str = "xsrf_invalid";
|
||||
|
||||
/// Shorthand for producing an error when an XSRF token is invalid.
|
||||
///
|
||||
/// For all legitimate users, in practice, this means the form has expired.
|
||||
/// It could also mean foul play is afoot, but this is less likely and in that
|
||||
/// case we don't need to display an error message.
|
||||
pub fn xsrf_invalid() -> FieldError {
|
||||
FieldError::Custom {
|
||||
code: ERRCODE_XSRF_INVALID.to_owned(),
|
||||
description: "Form expired; please try again.".to_owned(),
|
||||
values: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Error code for error produced when wrong login credentials are provided.
|
||||
pub const ERRCODE_INVALID_CREDENTIALS: &str = "invalid_credentials";
|
||||
|
||||
/// Shorthand for producing an error for invalid credentials.
|
||||
pub fn invalid_credentials() -> FieldError {
|
||||
FieldError::Custom {
|
||||
code: ERRCODE_INVALID_CREDENTIALS.to_owned(),
|
||||
description: "Invalid username or password; please try again.".to_owned(),
|
||||
values: BTreeMap::new(),
|
||||
}
|
||||
}
|
187
src/web/login.rs
187
src/web/login.rs
@ -23,6 +23,7 @@ use eyre::{bail, Context, ContextCompat};
|
||||
use formbeam::{traits::FormValidation, FormPartial};
|
||||
use formbeam_derive::Form;
|
||||
use governor::Jitter;
|
||||
use hornbeam::{render_template_string, ReflectedForm};
|
||||
use rand::Rng;
|
||||
use serde::Deserialize;
|
||||
use sqlx::types::Uuid;
|
||||
@ -265,6 +266,22 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Body parameter form for `POST /login`
|
||||
#[derive(Debug, Form)]
|
||||
pub struct LoginForm {
|
||||
/// User-supplied username
|
||||
#[form(max_chars(32), regex("^[a-z0-9]+$"))]
|
||||
username: String,
|
||||
|
||||
/// User-supplied password
|
||||
#[form(max_chars(256))]
|
||||
password: String,
|
||||
|
||||
/// Hidden XSRF token, used to check this request isn't being made from another site
|
||||
/// (i.e. protects against cross-site request forgery)
|
||||
xsrf: String,
|
||||
}
|
||||
|
||||
/// Query string parameters for `GET /login`
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery {
|
||||
@ -286,44 +303,28 @@ pub async fn get_login(
|
||||
Query(query): Query<LoginQuery>,
|
||||
cookies: Cookies,
|
||||
Extension(mut randgen): Extension<RandGen>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
) -> Response {
|
||||
match current_session {
|
||||
Ok(_session) => make_post_login_redirect(query.then),
|
||||
Err(_) => {
|
||||
let xsrf_token = sessionless_xsrf::get_token(&cookies, &mut randgen);
|
||||
Html(format!("<form method='POST'>UN<input type='text' name='username'> PW<input type='password' name='password'> <input type='hidden' name='xsrf' value='{}'><button type='submit'>click here to login</button> (temporary form)</form>", xsrf_token)).into_response()
|
||||
let form = ReflectedForm::<LoginFormRaw>::default();
|
||||
Rendered(render_template_string!(TEMPLATING, login, locale, {
|
||||
xsrf_token,
|
||||
form
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Body parameters for `POST /login`
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostLoginForm {
|
||||
/// User-supplied username
|
||||
username: String,
|
||||
/// User-supplied password
|
||||
password: String,
|
||||
/// Hidden XSRF token, used to check this request isn't being made from another site
|
||||
/// (i.e. protects against cross-site request forgery)
|
||||
xsrf: String,
|
||||
}
|
||||
|
||||
/// Perform a password hash operation, even though it's not needed.
|
||||
///
|
||||
/// This aims to prevent side-channel attacks such as timing attacks, to discern the existence of a user.
|
||||
fn dummy_password_hash(
|
||||
password: String,
|
||||
password_hash_config: &PasswordHashingConfig,
|
||||
) -> WebResult<Response> {
|
||||
fn dummy_password_hash(password: String, password_hash_config: &PasswordHashingConfig) {
|
||||
let _hash = create_password_hash(&password, password_hash_config)
|
||||
.context("unable to hash password!")?;
|
||||
|
||||
Ok(render_login_retry_form())
|
||||
}
|
||||
|
||||
/// Render the form to retry a login. Currently not implemented.
|
||||
fn render_login_retry_form() -> Response {
|
||||
(StatusCode::UNAUTHORIZED, "Wrong username or password!").into_response() // TODO(ui): this should re-render the login form for another go
|
||||
.expect("BUG: unable to hash dummy password!");
|
||||
}
|
||||
|
||||
/// `POST /login?then=...`
|
||||
@ -354,18 +355,58 @@ pub async fn post_login(
|
||||
SecureClientIp(src_ip): SecureClientIp,
|
||||
Extension(ratelimiters): Extension<Arc<Ratelimiters>>,
|
||||
Extension(mut randgen): Extension<RandGen>,
|
||||
Form(form): Form<PostLoginForm>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
Form(form_raw): Form<LoginFormRaw>,
|
||||
) -> WebResult<Response> {
|
||||
ratelimiters.housekeeping();
|
||||
ratelimiters
|
||||
.login
|
||||
.until_key_ready_with_jitter(&src_ip, Jitter::up_to(std::time::Duration::from_secs(1)))
|
||||
.await;
|
||||
|
||||
let mut validation = form_raw
|
||||
.validate()
|
||||
.await
|
||||
.context("failed to run form validator")?;
|
||||
|
||||
// New XSRF token for if we need to re-render the form
|
||||
let xsrf_token = sessionless_xsrf::get_token(&cookies, &mut randgen);
|
||||
|
||||
if !validation.is_valid() {
|
||||
let form = ReflectedForm::new(form_raw, validation);
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Rendered(render_template_string!(TEMPLATING, login, locale, {
|
||||
xsrf_token,
|
||||
form
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// INVARIANT: Form fields are syntactically valid at this point (but note XSRF not checked)
|
||||
|
||||
let form = form_raw
|
||||
.form()
|
||||
.map_err(|e| eyre!("failed to extract a so-called valid form: {e}"))?;
|
||||
|
||||
if !sessionless_xsrf::check_token(&cookies, &form.xsrf) {
|
||||
// Invalid XSRF token: try again
|
||||
return Ok(get_login(None, Query(query), cookies, Extension(randgen)).await);
|
||||
validation.xsrf.push(errors::xsrf_invalid());
|
||||
|
||||
let form = ReflectedForm::new(form_raw, validation);
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Rendered(render_template_string!(TEMPLATING, login, locale, {
|
||||
xsrf_token,
|
||||
form
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// INVARIANT: Form validated at this point, other than user credentials
|
||||
|
||||
// retrieve user details
|
||||
// N.B. it may be that there is no user returned in this step, but we need to
|
||||
// do a password hash check anyway to avoid user enumeration problems.
|
||||
@ -375,49 +416,59 @@ pub async fn post_login(
|
||||
.context("failed to look up user for login")?;
|
||||
|
||||
// verify credentials
|
||||
let user = match user_opt {
|
||||
Some(user) => match &user.password_hash {
|
||||
Some(password_hash) => match phil
|
||||
.do_limited(src_ip, || check_hash(&form.password, password_hash))
|
||||
.await
|
||||
.map_err(|_| eyre!("can't check password"))?
|
||||
{
|
||||
Ok(is_correct) => {
|
||||
if is_correct {
|
||||
user
|
||||
} else {
|
||||
return Ok(render_login_retry_form());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("failed to check password hash: {err:?}");
|
||||
return phil
|
||||
.do_limited(src_ip, || {
|
||||
dummy_password_hash(form.password, &config.password_hashing)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| eyre!("can't check password"))?;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return phil
|
||||
.do_limited(src_ip, || {
|
||||
dummy_password_hash(form.password, &config.password_hashing)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| eyre!("can't check password"))?;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return phil
|
||||
.do_limited(src_ip, || {
|
||||
dummy_password_hash(form.password, &config.password_hashing)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| eyre!("can't check password"))?;
|
||||
}
|
||||
let (Some(user), Some(password_hash)) =
|
||||
(user_opt.clone(), user_opt.and_then(|u| u.password_hash))
|
||||
else {
|
||||
// Either no user, or no password hash stored for that user
|
||||
// Avoid a user enumeration attack: do a dummy password hash
|
||||
// TODO(errors): convert do_limited 'too many waiters' to 429s rather than 500s?
|
||||
phil.do_limited(src_ip, || {
|
||||
dummy_password_hash(form.password, &config.password_hashing)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| eyre!("can't check password"))?;
|
||||
|
||||
validation.form_wide.push(errors::invalid_credentials());
|
||||
|
||||
let form = ReflectedForm::new(form_raw, validation);
|
||||
return Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Rendered(render_template_string!(TEMPLATING, login, locale, {
|
||||
xsrf_token,
|
||||
form
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
// TODO(errors): convert do_limited 'too many waiters' to 429s rather than 500s?
|
||||
let password_is_correct = phil
|
||||
.do_limited(src_ip, || check_hash(&form.password, &password_hash))
|
||||
.await
|
||||
.map_err(|_| eyre!("can't check password"))??;
|
||||
|
||||
if !password_is_correct {
|
||||
validation.form_wide.push(errors::invalid_credentials());
|
||||
|
||||
let form = ReflectedForm::new(form_raw, validation);
|
||||
return Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Rendered(render_template_string!(TEMPLATING, login, locale, {
|
||||
xsrf_token,
|
||||
form
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
///// INVARIANT: User authenticated beyond this point
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
// Generate a login session token and store the hash in our database
|
||||
let login_session_token = randgen.gen::<[u8; LOGIN_SESSION_TOKEN_BYTES]>();
|
||||
let login_session_token_b64 = BASE64_URL_SAFE_NO_PAD.encode(login_session_token);
|
||||
|
@ -56,15 +56,13 @@ where
|
||||
.await
|
||||
{
|
||||
Ok(Some(session)) => Ok(session),
|
||||
Ok(None) => {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid application session."));
|
||||
}
|
||||
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid application session.")),
|
||||
Err(err) => {
|
||||
error!("failed to check application session: {err:?}");
|
||||
return Err((
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"A fault occurred when checking your application session. If the issue persists, please contact an administrator.",
|
||||
));
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
0
src_scss/dark.scss
Normal file
0
src_scss/dark.scss
Normal file
0
src_scss/light.scss
Normal file
0
src_scss/light.scss
Normal file
11
templates/components/BarePage.hnb
Normal file
11
templates/components/BarePage.hnb
Normal file
@ -0,0 +1,11 @@
|
||||
html
|
||||
head
|
||||
meta {charset="UTF-8"}
|
||||
|
||||
title
|
||||
optional slot :title
|
||||
|
||||
link {rel="stylesheet", href="/statics/light.css"} // TODO support theming
|
||||
|
||||
body
|
||||
slot :main
|
37
templates/components/Text.hnb
Normal file
37
templates/components/Text.hnb
Normal file
@ -0,0 +1,37 @@
|
||||
// ReflectedForm instance
|
||||
//param $form
|
||||
|
||||
// The name of the form field
|
||||
//param $name
|
||||
|
||||
// Optional. Override the type of the textbox.
|
||||
// Example values: 'password'
|
||||
// For email, prefer instead to have an Email validator on the field
|
||||
// and this will be set automatically.
|
||||
//TODO param $type = "text"
|
||||
|
||||
|
||||
set $minlength = None
|
||||
set $maxlength = None
|
||||
set $required = None
|
||||
set $email = None
|
||||
set $pattern = None
|
||||
set $type = "text"
|
||||
|
||||
for $validator in $form.info.field_validators($name)
|
||||
match $validator
|
||||
MinLength($l) =>
|
||||
set $minlength = $l
|
||||
MaxLength($l) =>
|
||||
set $maxlength = $l
|
||||
Required =>
|
||||
set $required = ""
|
||||
Email =>
|
||||
set $type = "email"
|
||||
Regex($r) =>
|
||||
set $pattern = $r
|
||||
_ =>
|
||||
"$validator"
|
||||
|
||||
|
||||
input {$type, $name, $minlength?, $maxlength?, $required?, $pattern?}
|
12
templates/pages/login.hnb
Normal file
12
templates/pages/login.hnb
Normal file
@ -0,0 +1,12 @@
|
||||
BarePage
|
||||
form {method = "POST"}
|
||||
Text {$form, name = "username"}
|
||||
"UN"
|
||||
input {type = "text", name = "username"}
|
||||
" PW"
|
||||
input {type = "password", name = "password"}
|
||||
" "
|
||||
input {type = "hidden", name = "xsrf", value = $xsrf_token}
|
||||
button {type = "submit"}
|
||||
"click here to login"
|
||||
" (temporary form)"
|
Loading…
x
Reference in New Issue
Block a user