sum more bullshit
This commit is contained in:
parent
8f5bc07070
commit
d83f044dcd
@ -5,6 +5,7 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/web/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/web/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/web/procmacros/src" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/backend/target" />
|
<excludeFolder url="file://$MODULE_DIR$/backend/target" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/web/target" />
|
<excludeFolder url="file://$MODULE_DIR$/web/target" />
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
#![allow(async_fn_in_trait)]
|
#![allow(async_fn_in_trait)]
|
||||||
|
|
||||||
|
use crate::cloudflare::CloudflareDnsProvider;
|
||||||
|
use derive_more::Display;
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use derive_more::Display;
|
use tokio::sync::RwLock;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
|
||||||
use crate::cloudflare::CloudflareDnsProvider;
|
|
||||||
|
|
||||||
pub mod cloudflare;
|
pub mod cloudflare;
|
||||||
|
|
||||||
@ -127,7 +127,7 @@ impl DnsProviderType {
|
|||||||
Ok(Self::Cloudflare(Arc::new(RwLock::new(CloudflareDnsProvider::new(api_key, zone_id)?))))
|
Ok(Self::Cloudflare(Arc::new(RwLock::new(CloudflareDnsProvider::new(api_key, zone_id)?))))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_provider(&mut self) -> &mut Arc<RwLock<impl DnsProvider>> {
|
pub fn as_provider(&mut self) -> &mut Arc<RwLock<impl DnsProvider<'_>>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cloudflare(provider) => provider,
|
Self::Cloudflare(provider) => provider,
|
||||||
}
|
}
|
||||||
|
|||||||
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
22
web/assets/_hyperscript.min.js
vendored
Normal file
22
web/assets/_hyperscript.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -4,8 +4,10 @@ CREATE TABLE users (
|
|||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE NOT NULL,
|
||||||
email_confirmed_at TIMESTAMPTZ,
|
email_confirmed_at TIMESTAMPTZ,
|
||||||
|
password_reset_requested_at TIMESTAMPTZ,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
settings_unlocked_at TIMESTAMPTZ,
|
||||||
two_factor_secret TEXT
|
two_factor_secret TEXT
|
||||||
);
|
);
|
||||||
@ -1,10 +1,6 @@
|
|||||||
#![feature(str_as_str)]
|
|
||||||
#![feature(associated_type_defaults)]
|
|
||||||
|
|
||||||
mod dns;
|
mod dns;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
use tokio::signal;
|
|
||||||
use microtld_backend::DnsProvider;
|
use microtld_backend::DnsProvider;
|
||||||
use crate::web::service::WebService;
|
use crate::web::service::WebService;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
use maud::DOCTYPE;
|
|
||||||
use crate::web::content::style;
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::web::content::style;
|
||||||
|
|
||||||
pub fn head(page_title: &str, page_slug: &str) -> Markup {
|
pub fn head(page_title: &str, page_slug: &str) -> Markup {
|
||||||
let css = style::stylesheet();
|
let css = style::stylesheet();
|
||||||
@ -14,6 +13,7 @@ pub fn head(page_title: &str, page_slug: &str) -> Markup {
|
|||||||
link rel="icon" href="/static/favicon.ico";
|
link rel="icon" href="/static/favicon.ico";
|
||||||
|
|
||||||
script src="/assets/htmx.min.js" defer {}
|
script src="/assets/htmx.min.js" defer {}
|
||||||
|
script src="/assets/_hyperscript.min.js" defer {}
|
||||||
|
|
||||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||||
} // TODO: Drnk monter
|
} // TODO: Drnk monter
|
||||||
|
|||||||
@ -58,10 +58,11 @@ pub fn header(is_auth: bool) -> Markup {
|
|||||||
a.link href="/status" { "Status" }
|
a.link href="/status" { "Status" }
|
||||||
div class="spacer" {}
|
div class="spacer" {}
|
||||||
@if is_auth {
|
@if is_auth {
|
||||||
button.link href="/logout" class="auth-shown" hx-get="/auth/logoutprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Logout" }
|
a.link href="/profile" { "Profile" }
|
||||||
|
a.link href="/auth/logoutprompt" hx-get="/auth/logoutprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Logout" }
|
||||||
} @else {
|
} @else {
|
||||||
button.link href="/login" class="auth-hidden" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Login" }
|
a.link href="/auth/loginprompt" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Login" }
|
||||||
button.link href="/register" class="auth-hidden" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Register" }
|
a.link href="/auth/registerprompt" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Register" }
|
||||||
}
|
}
|
||||||
a.link href="https://github.com/microtld/microtld" {img src="/assets/img/github-mark.svg";}
|
a.link href="https://github.com/microtld/microtld" {img src="/assets/img/github-mark.svg";}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,4 +4,5 @@ pub mod head;
|
|||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
|
pub mod profilesettings;
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
use axum::http::HeaderValue;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use maud::DOCTYPE;
|
use maud::DOCTYPE;
|
||||||
use crate::web::content::partials::footer::footer;
|
use crate::web::content::partials::footer::footer;
|
||||||
use crate::web::content::partials::head::head;
|
use crate::web::content::partials::head::head;
|
||||||
@ -54,6 +56,35 @@ inventory::submit! {
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, button {
|
||||||
|
background: var(--border);
|
||||||
|
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
color: var(--nav-link);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, button:hover {
|
||||||
|
background: color-mix(in srgb, var(--border) 50%, transparent);
|
||||||
|
|
||||||
|
transition: background 0.2s ease-in-out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .modal-close {
|
> .modal-close {
|
||||||
@ -87,7 +118,7 @@ inventory::submit! {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show_error(message: &str) -> Markup {
|
pub fn show_error(message: &str) -> impl IntoResponse {
|
||||||
html! {
|
html! {
|
||||||
h1 { (message) }
|
h1 { (message) }
|
||||||
}
|
}
|
||||||
@ -104,7 +135,7 @@ pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) -
|
|||||||
(content)
|
(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
div popover id="modal" {
|
div popover id="modal" _="on htmx:afterSwap call my.showPopover()" {
|
||||||
div id="modal-content" {}
|
div id="modal-content" {}
|
||||||
button class="modal-close" popovertarget="modal" popovertargetaction="hide" { "Close" }
|
button class="modal-close" popovertarget="modal" popovertargetaction="hide" { "Close" }
|
||||||
}
|
}
|
||||||
|
|||||||
82
web/src/web/content/partials/profilesettings.rs
Normal file
82
web/src/web/content/partials/profilesettings.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pub fn profilesettings(locked: bool, errormsg: Option<&str>, username: &str, email: &str) -> Markup {
|
||||||
|
html! {
|
||||||
|
h3 {
|
||||||
|
"Settings are: "
|
||||||
|
span id="settings-state" {
|
||||||
|
@if locked { "Locked" } @else { "Unlocked" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if let Some(errormsg) = errormsg {
|
||||||
|
p { (errormsg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
@if locked {
|
||||||
|
table {
|
||||||
|
tr {
|
||||||
|
td colspan="3" {
|
||||||
|
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
|
||||||
|
label for="username" { "Username" }
|
||||||
|
input type="text" name="username" value=(username) disabled;
|
||||||
|
input type="submit" value="Update" disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td colspan="3" {
|
||||||
|
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
|
||||||
|
label for="email" { "Email" }
|
||||||
|
input type="email" name="email" value=(email) disabled;
|
||||||
|
input type="submit" value="Update" disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td colspan="3" {
|
||||||
|
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
|
||||||
|
label for="password" { "Password" }
|
||||||
|
input type="password" name="password" disabled;
|
||||||
|
input type="submit" value="Update" disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
table {
|
||||||
|
tr {
|
||||||
|
td colspan="3" {
|
||||||
|
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
|
||||||
|
label for="username" { "Username" }
|
||||||
|
input type="text" name="username" value=(username);
|
||||||
|
input type="submit" value="Update";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td colspan="3" {
|
||||||
|
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
|
||||||
|
label for="email" { "Email" }
|
||||||
|
input type="email" name="email" value=(email);
|
||||||
|
input type="submit" value="Update";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td colspan="3" {
|
||||||
|
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
|
||||||
|
label for="password" { "Password" }
|
||||||
|
input type="password" name="password";
|
||||||
|
input type="submit" value="Update";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,2 @@
|
|||||||
pub(crate) mod content;
|
pub(crate) mod content;
|
||||||
pub(crate) mod htmx;
|
|
||||||
pub(crate) mod service;
|
pub(crate) mod service;
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
use argon2::password_hash::rand_core::{OsRng, RngCore};
|
use argon2::password_hash::rand_core::{OsRng, RngCore};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::prelude::BASE64_URL_SAFE;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::{Error, PgPool};
|
use sqlx::{Error, PgPool};
|
||||||
|
use sqlx::postgres::PgQueryResult;
|
||||||
use sqlx::types::Json;
|
use sqlx::types::Json;
|
||||||
use time::{OffsetDateTime, Duration};
|
use time::{OffsetDateTime, Duration};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -47,13 +50,20 @@ impl Confirmation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_identifier(identifier: &Vec<u8>, db: &PgPool) -> anyhow::Result<Option<Confirmation>> {
|
||||||
|
sqlx::query_as::<_, Confirmation>("SELECT * FROM confirmations WHERE identifier = $1")
|
||||||
|
.bind(identifier)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await.map_err(|e| anyhow::anyhow!("Database error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
/// for use in http://localhost:3000/auth/confirm?token=<token>
|
/// for use in http://localhost:3000/auth/confirm?token=<token>
|
||||||
pub fn get_url_token(&self) -> anyhow::Result<String> {
|
pub fn get_url_token(&self) -> anyhow::Result<String> {
|
||||||
Ok(base64::encode([self.identifier.as_ref(), self.verifier.ok_or_else(||anyhow::anyhow!("Cannot construct url token from non-original confirmation"))?.as_ref()].concat()))
|
Ok(BASE64_URL_SAFE.encode([self.identifier.as_ref(), self.verifier.ok_or_else(||anyhow::anyhow!("Cannot construct url token from non-original confirmation"))?.as_ref()].concat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_url_token(token: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
pub fn decode_url_token(token: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
||||||
let decoded_token = base64::decode(token)?;
|
let decoded_token = BASE64_URL_SAFE.decode(token)?;
|
||||||
let (identifier, verifier) = decoded_token.split_at(16);
|
let (identifier, verifier) = decoded_token.split_at(16);
|
||||||
Ok((identifier.to_vec(), verifier.to_vec()))
|
Ok((identifier.to_vec(), verifier.to_vec()))
|
||||||
}
|
}
|
||||||
@ -96,6 +106,31 @@ impl Confirmation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete(self, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
sqlx::query("DELETE FROM confirmations WHERE identifier = $1")
|
||||||
|
.bind(&self.identifier)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_for_user(user_id: Uuid, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
sqlx::query("DELETE FROM confirmations WHERE user_id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_for_user_with_type(user_id: Uuid, action_type: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
sqlx::query("DELETE FROM confirmations WHERE user_id = $1 AND action_type = $2")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(action_type)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_expired(&self) -> bool {
|
pub fn is_expired(&self) -> bool {
|
||||||
self.expires_at < OffsetDateTime::now_utc()
|
self.expires_at < OffsetDateTime::now_utc()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,3 +57,12 @@ pub async fn verify_password_async(
|
|||||||
.map_err(|e|anyhow::anyhow!("Failed to verify password: {}", e))
|
.map_err(|e|anyhow::anyhow!("Failed to verify password: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ensure_password_strength(password: &str) -> Result<(), String> {
|
||||||
|
if password.len() < 10 || password.len() > 128 {
|
||||||
|
return Err("Password must be 10 to 128 characters".into());
|
||||||
|
}
|
||||||
|
if password.chars().any(|c| !c.is_ascii_alphanumeric() && !c.is_ascii_punctuation()) {
|
||||||
|
return Err("Password must contain only ASCII alphanumeric/punctuation characters".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
|
use lightningcss::traits::Op;
|
||||||
|
use maud::html;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Error, PgPool};
|
use sqlx::{Error, PgPool};
|
||||||
|
use sqlx::postgres::PgQueryResult;
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::web::service::auth::confirmation::Confirmation;
|
use crate::web::service::auth::confirmation::Confirmation;
|
||||||
@ -17,13 +20,36 @@ pub struct User {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub email_confirmed_at: Option<OffsetDateTime>,
|
pub email_confirmed_at: Option<OffsetDateTime>,
|
||||||
|
pub password_reset_requested_at: Option<OffsetDateTime>,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
pub updated_at: OffsetDateTime,
|
pub updated_at: OffsetDateTime,
|
||||||
|
pub settings_unlocked_at: Option<OffsetDateTime>,
|
||||||
pub two_factor_secret: Option<String>,
|
pub two_factor_secret: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
|
pub async fn get_user_by_name(username: &str, db: &PgPool) -> anyhow::Result<Option<Self>> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_email(email: &str, db: &PgPool) -> anyhow::Result<Option<Self>> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_id(id: Uuid, db: &PgPool) -> anyhow::Result<Option<Self>> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register(username: String, email: String, password_hash: String, db: &PgPool) -> Result<Self, RegistrationError> {
|
pub async fn register(username: String, email: String, password_hash: String, db: &PgPool) -> Result<Self, RegistrationError> {
|
||||||
// ON CONFLICT DO NOTHING prevents errors on duplicate email
|
// ON CONFLICT DO NOTHING prevents errors on duplicate email
|
||||||
// without revealing whether the email already exists
|
// without revealing whether the email already exists
|
||||||
@ -41,6 +67,7 @@ impl User {
|
|||||||
.fetch_optional(db)
|
.fetch_optional(db)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
println!("{:?}", result);
|
||||||
match result {
|
match result {
|
||||||
Ok(u) => {
|
Ok(u) => {
|
||||||
if let Some(u) = u {
|
if let Some(u) = u {
|
||||||
@ -63,6 +90,73 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_password(&mut self, password_hash: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_email(&mut self, email: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
sqlx::query("UPDATE users SET email = $1 WHERE id = $2")
|
||||||
|
.bind(&email)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_username(&mut self, username: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
|
||||||
|
.bind(&username)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_settings_unlocked(&self) -> bool {
|
||||||
|
if let Some(unlocked_at) = self.settings_unlocked_at && OffsetDateTime::now_utc() - unlocked_at < Duration::minutes(5) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_settings_unlocked(&mut self, unlocked: bool, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
if unlocked {
|
||||||
|
self.settings_unlocked_at = Some(OffsetDateTime::now_utc());
|
||||||
|
sqlx::query("UPDATE users SET settings_unlocked_at = $1 WHERE id = $2")
|
||||||
|
.bind(&self.settings_unlocked_at)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
} else {
|
||||||
|
self.settings_unlocked_at = None;
|
||||||
|
sqlx::query("UPDATE users SET settings_unlocked_at = NULL WHERE id = $1")
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_email_confirmed(&mut self, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
self.email_confirmed_at = Some(OffsetDateTime::now_utc());
|
||||||
|
sqlx::query("UPDATE users SET email_confirmed_at = $1 WHERE id = $2")
|
||||||
|
.bind(&self.email_confirmed_at)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_password_reset_requested(&mut self, db: &PgPool) -> anyhow::Result<PgQueryResult> {
|
||||||
|
self.password_reset_requested_at = Some(OffsetDateTime::now_utc());
|
||||||
|
sqlx::query("UPDATE users SET password_reset_requested_at = $1 WHERE id = $2")
|
||||||
|
.bind(&self.password_reset_requested_at)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(db)
|
||||||
|
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum RegistrationError {
|
pub enum RegistrationError {
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
|
use crate::web::service::auth::user::User;
|
||||||
|
use crate::web::service::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
http::{request::Parts, StatusCode},
|
http::{request::Parts, StatusCode},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::web::service::AppState;
|
|
||||||
use crate::web::service::auth::user::User;
|
|
||||||
|
|
||||||
pub struct AuthUser(pub User);
|
pub struct AuthUser(pub User);
|
||||||
|
|
||||||
@ -27,10 +26,7 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
|
let user: User = User::get_user_by_id(user_id, &state.db).await
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct DnsRecordRequest {
|
|
||||||
name: Option<String>,
|
|
||||||
target: Option<String>,
|
|
||||||
ttl: Option<u32>,
|
|
||||||
record_type: Option<String>,
|
|
||||||
priority: Option<u16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,2 +1 @@
|
|||||||
pub mod dns;
|
pub mod authuser;
|
||||||
mod authuser;
|
|
||||||
@ -19,7 +19,7 @@ pub struct WebService {
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
db: PgPool,
|
db: PgPool,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
micro_tld: MicroTld
|
micro_tld: MicroTld,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -47,7 +47,7 @@ impl WebService {
|
|||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db: pool,
|
db: pool,
|
||||||
config, micro_tld,
|
config, micro_tld
|
||||||
};
|
};
|
||||||
let router = routes::apply_routes(axum::Router::new(), state.db.clone()).await.with_state(state.clone());
|
let router = routes::apply_routes(axum::Router::new(), state.db.clone()).await.with_state(state.clone());
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
use maud::html;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
use time::OffsetDateTime;
|
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
|
use crate::web::content::partials::page::{page, show_error};
|
||||||
use crate::web::service::AppState;
|
use crate::web::service::AppState;
|
||||||
use crate::web::service::auth::confirmation::Confirmation;
|
use crate::web::service::auth::confirmation::Confirmation;
|
||||||
use crate::web::service::auth::user::User;
|
use crate::web::service::auth::user::User;
|
||||||
@ -15,81 +16,103 @@ pub(crate) struct Confirm {
|
|||||||
token: String
|
token: String
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Route<'_, Query<Confirm>> for Confirm {
|
impl Route<'_, (), Query<Confirm>> for Confirm {
|
||||||
const PATH: &'static str = "/confirm";
|
const PATH: &'static str = "/confirm";
|
||||||
|
|
||||||
async fn process(session: Session, state: State<AppState>, query: Query<Confirm>) -> impl IntoResponse {
|
async fn process(_: (), session: Session, state: State<AppState>, query: Query<Confirm>) -> impl IntoResponse {
|
||||||
if session.is_empty().await {
|
|
||||||
return "Not logged in".into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap();
|
let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap();
|
||||||
let verifier_hash = sha2::Sha256::digest(&verifier).0;
|
let verifier_hash = sha2::Sha256::digest(&verifier).0;
|
||||||
|
|
||||||
let confirmation: Option<Confirmation> = sqlx::query_as("SELECT * FROM confirmations WHERE identifier = $1")
|
let confirmation: Option<Confirmation> =
|
||||||
.bind(&identifier)
|
Confirmation::get_by_identifier(&identifier, &state.db).await.unwrap_or(None);
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
|
|
||||||
match confirmation {
|
let content = match confirmation {
|
||||||
Some(confirmation) => {
|
Some(confirmation) => {
|
||||||
if confirmation.action_type != "email_confirmation" {
|
if confirmation.action_type != "email_confirmation" {
|
||||||
return "Invalid confirmation type".into_response();
|
html! {
|
||||||
}
|
h1 {
|
||||||
|
"Invalid confirmation type"
|
||||||
if confirmation.is_expired() {
|
|
||||||
return "Confirmation expired".into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() {
|
|
||||||
1 => {
|
|
||||||
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE id = $1")
|
|
||||||
.bind(confirmation.user_id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await.unwrap_or(None);
|
|
||||||
|
|
||||||
match user {
|
|
||||||
Some(mut user) => {
|
|
||||||
user.email_confirmed_at = Some(OffsetDateTime::now_utc());
|
|
||||||
match sqlx::query("UPDATE users SET email_confirmed_at = $1 WHERE id = $2")
|
|
||||||
.bind(&user.email_confirmed_at)
|
|
||||||
.bind(&user.id)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await {
|
|
||||||
Ok(_) => {
|
|
||||||
match sqlx::query("DELETE FROM confirmations WHERE identifier = $1")
|
|
||||||
.bind(&identifier)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await {
|
|
||||||
Ok(_) => {
|
|
||||||
"Email confirmed".into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
"Error confirming email".into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
"Error confirming email".into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
"User not found".into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
} else if confirmation.is_expired() {
|
||||||
"Invalid token".into_response()
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Confirmation expired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() {
|
||||||
|
1 => {
|
||||||
|
let user: Option<User> = User::get_user_by_id(confirmation.user_id, &state.db).await.unwrap_or(None);
|
||||||
|
|
||||||
|
match user {
|
||||||
|
Some(mut user) => {
|
||||||
|
if Confirmation::delete_all_for_user_with_type(user.id, "email_confirmation", &state.db).await.is_err() {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Error confirming email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match user.set_email_confirmed(&state.db).await {
|
||||||
|
Ok(_) => {
|
||||||
|
match confirmation.delete(&state.db).await {
|
||||||
|
Ok(_) => {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Email confirmed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Error deleting confirmation: {}", e);
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Error confirming email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Error updating user: {}", e);
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Error confirming email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"User not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Invalid token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
"Confirmation not found".into_response()
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Confirmation not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
page(Self::NAME, Self::PATH, !session.is_empty().await, content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
use axum::extract::{Form, State};
|
|
||||||
use axum::http::{HeaderValue, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Redirect};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tower_sessions::Session;
|
|
||||||
use crate::web::content::partials::page::show_error;
|
use crate::web::content::partials::page::show_error;
|
||||||
use crate::web::service::AppState;
|
use crate::web::service::auth::hash::{hash_password_async, verify_password_async};
|
||||||
use crate::web::service::auth::user::User;
|
use crate::web::service::auth::user::User;
|
||||||
use crate::web::service::routes::Route;
|
use crate::web::service::routes::Route;
|
||||||
use crate::web::service::auth::hash::{hash_password_async, verify_password_async};
|
use crate::web::service::AppState;
|
||||||
|
use axum::extract::{Form, State};
|
||||||
|
use axum::http::{HeaderValue, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
pub(crate) struct Login {
|
pub(crate) struct Login {
|
||||||
@ -15,15 +15,11 @@ pub(crate) struct Login {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Route<'_, Form<Login>> for Login {
|
impl Route<'_, (), Form<Login>> for Login {
|
||||||
const PATH: &'static str = "/login";
|
const PATH: &'static str = "/login";
|
||||||
|
|
||||||
async fn process(session: Session, state: State<AppState>, Form(form): Form<Login>) -> impl IntoResponse {
|
async fn process(_: (), session: Session, state: State<AppState>, Form(form): Form<Login>) -> impl IntoResponse {
|
||||||
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE username = $1")
|
let user: Option<User> = User::get_user_by_name(&form.username, &state.db).await.unwrap_or(None);
|
||||||
.bind(&form.username)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
|
|
||||||
let Some(user) = user else {
|
let Some(user) = user else {
|
||||||
println!("User not found");
|
println!("User not found");
|
||||||
@ -32,6 +28,11 @@ impl Route<'_, Form<Login>> for Login {
|
|||||||
return show_error("Invalid email or password").into_response();
|
return show_error("Invalid email or password").into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if user.email_confirmed_at.is_none() {
|
||||||
|
println!("Email not confirmed");
|
||||||
|
return show_error("Email not confirmed").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone())
|
if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone())
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
@ -52,7 +53,7 @@ impl Route<'_, Form<Login>> for Login {
|
|||||||
// Validate redirect target if using a ?next= parameter.
|
// Validate redirect target if using a ?next= parameter.
|
||||||
// Only allow relative paths. Reject absolute URLs to prevent open redirects.
|
// Only allow relative paths. Reject absolute URLs to prevent open redirects.
|
||||||
let mut response = StatusCode::OK.into_response();
|
let mut response = StatusCode::OK.into_response();
|
||||||
response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap());
|
response.headers_mut().insert("HX-Redirect", HeaderValue::from_static("/"));
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,13 +7,13 @@ use crate::web::service::routes::Route;
|
|||||||
|
|
||||||
pub(crate) struct LoginPrompt;
|
pub(crate) struct LoginPrompt;
|
||||||
|
|
||||||
impl Route<'_, ()> for LoginPrompt {
|
impl Route<'_, (), ()> for LoginPrompt {
|
||||||
const PATH: &'static str = "/loginprompt";
|
const PATH: &'static str = "/loginprompt";
|
||||||
|
|
||||||
async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
|
async fn process(_: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
|
||||||
html! {
|
html! {
|
||||||
h1 { "Login" }
|
h1 { "Login" }
|
||||||
form hx-post="/auth/login" hx-target="#modal-content" hx-swap="innerHTML" {
|
form method="post" action="/auth/login" hx-post="/auth/login" hx-target="#modal-content" hx-swap="innerHTML" {
|
||||||
label for="username" { "Username" }
|
label for="username" { "Username" }
|
||||||
input type="text" name="username" placeholder="Username" {}
|
input type="text" name="username" placeholder="Username" {}
|
||||||
|
|
||||||
@ -24,6 +24,11 @@ impl Route<'_, ()> for LoginPrompt {
|
|||||||
|
|
||||||
button type="submit" { "Login" }
|
button type="submit" { "Login" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
a href="/auth/registerprompt" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Register" }
|
||||||
|
a href="/auth/registerprompt" hx-get="/auth/resetpasswordprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Reset Password" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,20 +1,19 @@
|
|||||||
use axum::extract::{Form, State};
|
|
||||||
use axum::http::{HeaderValue, Response, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Redirect};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tower_sessions::Session;
|
|
||||||
use crate::web::service::AppState;
|
|
||||||
use crate::web::service::routes::Route;
|
use crate::web::service::routes::Route;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderValue, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
pub(crate) struct Logout;
|
pub(crate) struct Logout;
|
||||||
|
|
||||||
impl Route<'_, ()> for Logout {
|
impl Route<'_, (), ()> for Logout {
|
||||||
const PATH: &'static str = "/logout";
|
const PATH: &'static str = "/logout";
|
||||||
|
|
||||||
async fn process(session: Session, state: State<AppState>, _: ()) -> impl IntoResponse {
|
async fn process(_: (), session: Session, _state: State<AppState>, _: ()) -> impl IntoResponse {
|
||||||
session.flush().await.expect("failed to flush session");
|
session.flush().await.expect("failed to flush session");
|
||||||
let mut response = StatusCode::OK.into_response();
|
let mut response = StatusCode::OK.into_response();
|
||||||
response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap());
|
response.headers_mut().insert("HX-Redirect", HeaderValue::from_static("/"));
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,13 +7,13 @@ use crate::web::service::routes::Route;
|
|||||||
|
|
||||||
pub(crate) struct LogoutPrompt;
|
pub(crate) struct LogoutPrompt;
|
||||||
|
|
||||||
impl Route<'_, ()> for LogoutPrompt {
|
impl Route<'_, (), ()> for LogoutPrompt {
|
||||||
const PATH: &'static str = "/logoutprompt";
|
const PATH: &'static str = "/logoutprompt";
|
||||||
|
|
||||||
async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
|
async fn process(_: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
|
||||||
html! {
|
html! {
|
||||||
h1 { "Logout" }
|
h1 { "Logout" }
|
||||||
form hx-post="/auth/logout" hx-target="#modal-content" hx-swap="innerHTML" {
|
form method="post" action="/auth/logout" hx-post="/auth/logout" hx-target="#modal-content" hx-swap="innerHTML" {
|
||||||
p { "Are you sure you want to logout?" }
|
p { "Are you sure you want to logout?" }
|
||||||
button type="submit" { "Logout" }
|
button type="submit" { "Logout" }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
pub mod loginprompt;
|
mod loginprompt;
|
||||||
pub mod registerprompt;
|
mod registerprompt;
|
||||||
pub mod logoutprompt;
|
mod logoutprompt;
|
||||||
pub mod login;
|
mod login;
|
||||||
pub mod register;
|
mod register;
|
||||||
pub mod logout;
|
mod logout;
|
||||||
mod confirm;
|
mod confirm;
|
||||||
|
mod resetpasswordprompt;
|
||||||
|
mod requestresetpassword;
|
||||||
|
mod resetpassword;
|
||||||
|
mod resetpassworddo;
|
||||||
|
|
||||||
pub(crate) const PATH: &'static str = "/auth";
|
pub(crate) const PATH: &'static str = "/auth";
|
||||||
|
|
||||||
@ -15,3 +19,7 @@ pub(crate) use login::Login;
|
|||||||
pub(crate) use register::Register;
|
pub(crate) use register::Register;
|
||||||
pub(crate) use logout::Logout;
|
pub(crate) use logout::Logout;
|
||||||
pub(crate) use confirm::Confirm;
|
pub(crate) use confirm::Confirm;
|
||||||
|
pub(crate) use resetpasswordprompt::ResetPasswordPrompt;
|
||||||
|
pub(crate) use requestresetpassword::RequestResetPassword;
|
||||||
|
pub(crate) use resetpassword::ResetPassword;
|
||||||
|
pub(crate) use resetpassworddo::DoResetPassword;
|
||||||
@ -6,7 +6,7 @@ use tower_sessions::Session;
|
|||||||
use crate::web::content::partials::page::show_error;
|
use crate::web::content::partials::page::show_error;
|
||||||
use crate::web::service::AppState;
|
use crate::web::service::AppState;
|
||||||
use crate::web::service::routes::Route;
|
use crate::web::service::routes::Route;
|
||||||
use crate::web::service::auth::hash::hash_password_async;
|
use crate::web::service::auth::hash::{ensure_password_strength, hash_password_async};
|
||||||
use crate::web::service::auth::user::{RegistrationError, User};
|
use crate::web::service::auth::user::{RegistrationError, User};
|
||||||
|
|
||||||
#[derive(Deserialize, Default, Debug)]
|
#[derive(Deserialize, Default, Debug)]
|
||||||
@ -17,15 +17,16 @@ pub(crate) struct Register {
|
|||||||
password_confirmation: String,
|
password_confirmation: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Route<'_, Form<Register>> for Register {
|
impl Route<'_, (), Form<Register>> for Register {
|
||||||
const PATH: &'static str = "/register";
|
const PATH: &'static str = "/register";
|
||||||
|
|
||||||
async fn process(_session: Session, State(state): State<AppState>, Form(form): Form<Register>) -> impl IntoResponse {
|
async fn process(_: (), _session: Session, State(state): State<AppState>, Form(form): Form<Register>) -> impl IntoResponse {
|
||||||
if form.password != form.password_confirmation {
|
if form.password != form.password_confirmation {
|
||||||
return show_error("Passwords do not match").into_response();
|
return show_error("Passwords do not match").into_response();
|
||||||
}
|
}
|
||||||
if form.password.len() < 10 || form.password.len() > 128 {
|
|
||||||
return show_error("Password must be 10 to 128 characters").into_response();
|
if let Err(e) = ensure_password_strength(&form.password) {
|
||||||
|
return show_error(&e).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await {
|
let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await {
|
||||||
|
|||||||
@ -7,13 +7,13 @@ use crate::web::service::routes::Route;
|
|||||||
|
|
||||||
pub(crate) struct RegisterPrompt;
|
pub(crate) struct RegisterPrompt;
|
||||||
|
|
||||||
impl Route<'_, ()> for RegisterPrompt {
|
impl Route<'_, (), ()> for RegisterPrompt {
|
||||||
const PATH: &'static str = "/registerprompt";
|
const PATH: &'static str = "/registerprompt";
|
||||||
|
|
||||||
async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
|
async fn process(_: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
|
||||||
html! {
|
html! {
|
||||||
h1 { "Create an account" }
|
h1 { "Create an account" }
|
||||||
form hx-post="/auth/register" hx-target="#modal-content" hx-swap="innerHTML" {
|
form method="post" action="/auth/register" hx-post="/auth/register" hx-target="#modal-content" hx-swap="innerHTML" {
|
||||||
label for="username" { "Username" }
|
label for="username" { "Username" }
|
||||||
input type="text" name="username" placeholder="Username" {}
|
input type="text" name="username" placeholder="Username" {}
|
||||||
|
|
||||||
@ -32,6 +32,11 @@ impl Route<'_, ()> for RegisterPrompt {
|
|||||||
|
|
||||||
button type="submit" { "Register" }
|
button type="submit" { "Register" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
a href="/auth/loginprompt" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" { " Login " }
|
||||||
|
a href="/auth/resetpasswordprompt" hx-get="/auth/resetpasswordprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Reset Password" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
54
web/src/web/service/routes/auth/requestresetpassword.rs
Normal file
54
web/src/web/service/routes/auth/requestresetpassword.rs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Form;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use crate::web::content::partials::page::show_error;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use crate::web::service::auth::confirmation::Confirmation;
|
||||||
|
use crate::web::service::auth::email::EmailHandler;
|
||||||
|
use crate::web::service::auth::user::User;
|
||||||
|
use crate::web::service::routes::Route;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(crate) struct RequestResetPassword {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_, (), Form<RequestResetPassword>> for RequestResetPassword {
|
||||||
|
const PATH: &'static str = "/requestresetpassword";
|
||||||
|
|
||||||
|
async fn process(_: (), session: Session, state: State<AppState>, Form(form): Form<RequestResetPassword>) -> impl IntoResponse {
|
||||||
|
let user: Option<User> = User::get_user_by_email(&form.email, &state.db).await.unwrap_or(None);
|
||||||
|
|
||||||
|
let Some(mut user) = user else {
|
||||||
|
return show_error("If this account exists, a password reset email will be sent").into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(last_reset) = user.password_reset_requested_at && OffsetDateTime::now_utc() - last_reset < time::Duration::minutes(5) {
|
||||||
|
return show_error("Please wait 5 minutes before requesting a new password reset").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.set_password_reset_requested(&state.db).await.is_err() {
|
||||||
|
return show_error("Failed to request password reset").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let email_handler = EmailHandler{};
|
||||||
|
let confirmation = Confirmation::new(user.id, "password_reset".into(), Duration::hours(1));
|
||||||
|
match confirmation.get_url_token() {
|
||||||
|
Ok(token) => {
|
||||||
|
println!("http://localhost:3000/auth/resetpassword?token={}", urlencoding::encode(&token));
|
||||||
|
email_handler.send_password_reset_email(form.email, token);
|
||||||
|
if confirmation.submit(&state.db).await.is_err() {
|
||||||
|
return show_error("Failed to send password reset").into_response();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return show_error("Failed to send password reset").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show_error("If this account exists, a password reset email will be sent").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
113
web/src/web/service/routes/auth/resetpassword.rs
Normal file
113
web/src/web/service/routes/auth/resetpassword.rs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use maud::html;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::Digest;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use crate::web::content::partials::page::page;
|
||||||
|
use crate::web::content::style::CssFragment;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use crate::web::service::auth::confirmation::Confirmation;
|
||||||
|
use crate::web::service::auth::user::User;
|
||||||
|
use crate::web::service::routes::Route;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(crate) struct ResetPassword {
|
||||||
|
token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
inventory::submit! {
|
||||||
|
CssFragment(
|
||||||
|
r#"
|
||||||
|
.password-reset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
> form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_, (), Query<ResetPassword>> for ResetPassword {
|
||||||
|
const PATH: &'static str = "/resetpassword";
|
||||||
|
|
||||||
|
async fn process(_: (), session: Session, state: State<AppState>, query: Query<ResetPassword>) -> impl IntoResponse {
|
||||||
|
let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap();
|
||||||
|
let verifier_hash = sha2::Sha256::digest(&verifier).0;
|
||||||
|
|
||||||
|
let confirmation: Option<Confirmation> =
|
||||||
|
Confirmation::get_by_identifier(&identifier, &state.db).await.unwrap_or(None);
|
||||||
|
|
||||||
|
let content: maud::PreEscaped<String> = match confirmation {
|
||||||
|
Some(confirmation) => {
|
||||||
|
if confirmation.action_type != "password_reset" {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Invalid confirmation type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if confirmation.is_expired() {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Password Reset Expired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() {
|
||||||
|
1 => {
|
||||||
|
let user: Option<User> = User::get_user_by_id(confirmation.user_id, &state.db).await.unwrap_or(None);
|
||||||
|
match user {
|
||||||
|
Some(user) => {
|
||||||
|
html! {
|
||||||
|
div class="password-reset" {
|
||||||
|
form method="post" action="/auth/doresetpassword" hx-post="/auth/doresetpassword" hx-target="#modal-content" hx-swap="innerHTML" {
|
||||||
|
input type="hidden" name="token" value=(query.token);
|
||||||
|
label for="password" { "Password" }
|
||||||
|
input type="password" name="password" required;
|
||||||
|
label for="password_confirmation" { "Confirm Password" }
|
||||||
|
input type="password" name="password_confirmation" required;
|
||||||
|
button type="submit" { "Reset Password" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"User Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Invalid Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
html! {
|
||||||
|
h1 {
|
||||||
|
"Password Reset Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
page(Self::NAME, Self::PATH, !session.is_empty().await, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
web/src/web/service/routes/auth/resetpassworddo.rs
Normal file
82
web/src/web/service/routes/auth/resetpassworddo.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Form;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::Digest;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use crate::web::content::partials::page::show_error;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use crate::web::service::auth::confirmation::Confirmation;
|
||||||
|
use crate::web::service::auth::hash::{ensure_password_strength, hash_password_async};
|
||||||
|
use crate::web::service::auth::user::User;
|
||||||
|
use crate::web::service::routes::Route;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct DoResetPassword {
|
||||||
|
token: String,
|
||||||
|
password: String,
|
||||||
|
password_confirmation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_, (), Form<DoResetPassword>> for DoResetPassword {
|
||||||
|
const PATH: &'static str = "/doresetpassword";
|
||||||
|
async fn process(_: (), _: Session, state: State<AppState>, Form(form): Form<DoResetPassword>) -> impl IntoResponse {
|
||||||
|
if form.password != form.password_confirmation {
|
||||||
|
return show_error("Passwords do not match").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ensure_password_strength(&form.password).is_err() {
|
||||||
|
return show_error("Password is not strong enough").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (identifier, verifier) = Confirmation::decode_url_token(&form.token).unwrap();
|
||||||
|
let verifier_hash = sha2::Sha256::digest(&verifier).0;
|
||||||
|
|
||||||
|
let confirmation: Option<Confirmation> =
|
||||||
|
Confirmation::get_by_identifier(&identifier, &state.db).await.unwrap_or(None);
|
||||||
|
|
||||||
|
if confirmation.is_none() {
|
||||||
|
return show_error("Invalid confirmation").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmation = confirmation.unwrap();
|
||||||
|
if confirmation.action_type != "password_reset" {
|
||||||
|
return show_error("Invalid confirmation type").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirmation.is_expired() {
|
||||||
|
return show_error("Password reset expired").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() != 1 {
|
||||||
|
return show_error("Invalid confirmation").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = User::get_user_by_id(confirmation.user_id, &state.db).await;
|
||||||
|
if user.is_err() {
|
||||||
|
return show_error("User not found").into_response();
|
||||||
|
}
|
||||||
|
let user = user.unwrap();
|
||||||
|
if user.is_none() {
|
||||||
|
return show_error("User not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut user = user.unwrap();
|
||||||
|
|
||||||
|
let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await {
|
||||||
|
Ok(hash) => hash,
|
||||||
|
Err(_) => return show_error("Password reset failed").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if Confirmation::delete_all_for_user_with_type(user.id, "password_reset", &state.db).await.is_err() {
|
||||||
|
return show_error("Password reset failed").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.update_password(&password_hash, &state.db).await.is_err() {
|
||||||
|
return show_error("Password reset failed").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
return show_error("Password reset successful").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
web/src/web/service/routes/auth/resetpasswordprompt.rs
Normal file
29
web/src/web/service/routes/auth/resetpasswordprompt.rs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use maud::html;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use crate::web::service::routes::Route;
|
||||||
|
|
||||||
|
pub(crate) struct ResetPasswordPrompt;
|
||||||
|
|
||||||
|
impl Route<'_, (), ()> for ResetPasswordPrompt {
|
||||||
|
const PATH: &'static str = "/resetpasswordprompt";
|
||||||
|
|
||||||
|
async fn process(_ad1: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
|
||||||
|
html! {
|
||||||
|
h1 { "Reset Password" }
|
||||||
|
form method="post" action="/auth/requestresetpassword" hx-post="/auth/requestresetpassword" hx-target="#modal-content" hx-swap="innerHTML" {
|
||||||
|
label for="email" { "Email" }
|
||||||
|
input type="email" name="email" id="email" required;
|
||||||
|
|
||||||
|
button type="submit" { "Request Reset" }
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
a href="/auth/loginprompt" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Login" }
|
||||||
|
a href="/auth/registerprompt" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Register" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,16 @@
|
|||||||
mod root;
|
mod root;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod profile;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use axum::extract::{Request, State};
|
use axum::extract::{FromRequestParts, Request, State};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{middleware, Router};
|
use axum::{middleware, Router};
|
||||||
use axum::error_handling::HandleErrorLayer;
|
use axum::error_handling::HandleErrorLayer;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::get;
|
||||||
use sqlx::{PgPool};
|
use sqlx::{PgPool};
|
||||||
use tower_csrf::{CrossOriginProtectionLayer, ProtectionError};
|
use tower_csrf::{CrossOriginProtectionLayer, ProtectionError};
|
||||||
use tower_governor::governor::GovernorConfig;
|
use tower_governor::governor::GovernorConfig;
|
||||||
@ -24,11 +25,11 @@ use crate::web::service::{
|
|||||||
};
|
};
|
||||||
use tower_governor::GovernorLayer;
|
use tower_governor::GovernorLayer;
|
||||||
|
|
||||||
pub(crate) trait Route<'a, AD> {
|
pub(crate) trait Route<'a, AS: Sized + FromRequestParts<AppState>, AD: Sized> {
|
||||||
const PATH: &'static str;
|
const PATH: &'static str;
|
||||||
const NAME: &'static str = "MicroTLD";
|
const NAME: &'static str = "MicroTLD";
|
||||||
|
|
||||||
async fn process(session: Session, state: State<AppState>, query: AD) -> impl IntoResponse {
|
async fn process(_ad1: AS, _session: Session, _state: State<AppState>, _query: AD) -> impl IntoResponse {
|
||||||
"You should fill out get_content for this route."
|
"You should fill out get_content for this route."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,25 +52,47 @@ async fn my_custom_middleware(
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
macro_rules! routes {
|
||||||
|
( $router:expr, $( $method:ident ( $type:path ) ),* $(,)? ) => {
|
||||||
|
$router
|
||||||
|
$(
|
||||||
|
.route(<$type>::PATH, axum::routing::$method(<$type>::process))
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn auth_routes() -> Router<AppState> {
|
fn auth_routes() -> Router<AppState> {
|
||||||
let governor_config = GovernorConfig::default(); // 1 request per 500ms per IP
|
let governor_config = GovernorConfig::default(); // 1 request per 500ms per IP
|
||||||
let governor_layer = GovernorLayer::new(Arc::new(governor_config));
|
let governor_layer = GovernorLayer::new(Arc::new(governor_config));
|
||||||
|
|
||||||
Router::new()
|
routes!(
|
||||||
.layer(governor_layer)
|
Router::new()
|
||||||
.route(auth::LoginPrompt::PATH, get(auth::LoginPrompt::process))
|
.layer(governor_layer),
|
||||||
.route(auth::LogoutPrompt::PATH, get(auth::LogoutPrompt::process))
|
get(auth::LoginPrompt),
|
||||||
.route(auth::RegisterPrompt::PATH, get(auth::RegisterPrompt::process))
|
get(auth::LogoutPrompt),
|
||||||
.route(auth::Login::PATH, post(auth::Login::process))
|
get(auth::RegisterPrompt),
|
||||||
.route(auth::Logout::PATH, post(auth::Logout::process))
|
get(auth::ResetPasswordPrompt),
|
||||||
.route(auth::Register::PATH, post(auth::Register::process))
|
get(auth::ResetPassword),
|
||||||
.route(auth::Confirm::PATH, get(auth::Confirm::process))
|
post(auth::RequestResetPassword),
|
||||||
|
post(auth::DoResetPassword),
|
||||||
|
post(auth::Login),
|
||||||
|
post(auth::Logout),
|
||||||
|
post(auth::Register),
|
||||||
|
get(auth::Confirm)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn apply_routes(router: axum::Router<AppState>, pool: PgPool) -> Router<AppState> {
|
fn profile_routes() -> Router<AppState> {
|
||||||
|
routes!(
|
||||||
|
Router::new(),
|
||||||
|
get(profile::Profile),
|
||||||
|
post(profile::UnlockSettings),
|
||||||
|
post(profile::UpdateSettings)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn apply_routes(router: Router<AppState>, pool: PgPool) -> Router<AppState> {
|
||||||
let css = style::stylesheet();
|
let css = style::stylesheet();
|
||||||
|
|
||||||
let csrf_layer = tower::ServiceBuilder::new()
|
let csrf_layer = tower::ServiceBuilder::new()
|
||||||
@ -84,10 +107,13 @@ pub async fn apply_routes(router: axum::Router<AppState>, pool: PgPool) -> Route
|
|||||||
))
|
))
|
||||||
.layer(CrossOriginProtectionLayer::default());
|
.layer(CrossOriginProtectionLayer::default());
|
||||||
|
|
||||||
router
|
routes!(
|
||||||
.route(root::Root::PATH, get(root::Root::process))
|
router
|
||||||
.route(&css.route, get(css_handler))
|
|
||||||
.nest(auth::PATH, auth_routes())
|
.nest(auth::PATH, auth_routes())
|
||||||
|
.nest(profile::PATH, profile_routes()),
|
||||||
|
get(root::Root),
|
||||||
|
)
|
||||||
|
.route(&css.route, get(css_handler))
|
||||||
.route("/assets/{*path}", get(static_handler))
|
.route("/assets/{*path}", get(static_handler))
|
||||||
.layer(csrf_layer)
|
.layer(csrf_layer)
|
||||||
.layer(session_layer(pool).await)
|
.layer(session_layer(pool).await)
|
||||||
|
|||||||
9
web/src/web/service/routes/profile/mod.rs
Normal file
9
web/src/web/service/routes/profile/mod.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
mod unlock;
|
||||||
|
mod profile;
|
||||||
|
mod update;
|
||||||
|
|
||||||
|
pub(crate) const PATH: &'static str = "/profile";
|
||||||
|
|
||||||
|
pub(crate) use profile::Profile;
|
||||||
|
pub(crate) use unlock::UnlockSettings;
|
||||||
|
pub(crate) use update::UpdateSettings;
|
||||||
112
web/src/web/service/routes/profile/profile.rs
Normal file
112
web/src/web/service/routes/profile/profile.rs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use maud::html;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use crate::web::content::partials::page::page;
|
||||||
|
use crate::web::content::partials::profilesettings::profilesettings;
|
||||||
|
use crate::web::content::style::CssFragment;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use crate::web::service::extractors::authuser::AuthUser;
|
||||||
|
use crate::web::service::routes::Route;
|
||||||
|
|
||||||
|
inventory::submit! {
|
||||||
|
CssFragment(r#"
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .height-100 {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .grid-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(1px, 5rem) 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .flex-h {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .spacer-5 {
|
||||||
|
max-width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile vr {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .flex-v {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile form.gridform-h {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr auto;
|
||||||
|
}
|
||||||
|
"#)
|
||||||
|
}
|
||||||
|
pub(crate) struct Profile;
|
||||||
|
|
||||||
|
impl Route<'_, AuthUser, ()> for Profile {
|
||||||
|
const PATH: &'static str = "/";
|
||||||
|
|
||||||
|
async fn process(AuthUser(user): AuthUser, session: Session, State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse {
|
||||||
|
page(Self::NAME, Self::PATH, !session.is_empty().await, html! {
|
||||||
|
div class="profile" {
|
||||||
|
h1 { "Profile" }
|
||||||
|
div class="grid-3" {
|
||||||
|
div class="flex-v height-100" {
|
||||||
|
form method="post" action="/profile/unlock-settings" hx-post="/profile/unlock-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
|
||||||
|
table {
|
||||||
|
tr {
|
||||||
|
td { label for="lock" { "Lock settings" } }
|
||||||
|
td {
|
||||||
|
@if user.is_settings_unlocked() {
|
||||||
|
input type="checkbox" name="lock" value="true" checked;
|
||||||
|
} @else {
|
||||||
|
input type="checkbox" name="lock" value="true";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { label for="password" { "Current Password" } }
|
||||||
|
td {input type="password" name="password" value="";}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input type="submit" value="Set Lock";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="spacer-5 flex-h height-100" {
|
||||||
|
vr {}
|
||||||
|
}
|
||||||
|
div id="profilesettings" class="flex-v height-100" {
|
||||||
|
(profilesettings(!user.is_settings_unlocked(), None, &user.username, &user.email))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
60
web/src/web/service/routes/profile/unlock.rs
Normal file
60
web/src/web/service/routes/profile/unlock.rs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Form;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use maud::html;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use crate::web::content::partials;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use crate::web::service::auth::hash::verify_password_async;
|
||||||
|
use crate::web::service::extractors::authuser::AuthUser;
|
||||||
|
use crate::web::service::routes::Route;
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(crate) struct UnlockSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub lock: bool,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_, AuthUser, Form<UnlockSettings>> for UnlockSettings {
|
||||||
|
const PATH: &'static str = "/unlock-settings";
|
||||||
|
|
||||||
|
async fn process(AuthUser(mut user): AuthUser, session: Session, state: State<AppState>, Form(form): Form<UnlockSettings>) -> impl IntoResponse {
|
||||||
|
if form.lock {
|
||||||
|
match user.set_settings_unlocked(false, &state.db).await {
|
||||||
|
Ok(r) =>
|
||||||
|
if r.rows_affected() == 0 {
|
||||||
|
return html! {
|
||||||
|
h1 { "DB Error" }
|
||||||
|
p { "Could not lock settings: " }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
Err(_) => return html! {
|
||||||
|
h1 { "DB Error" }
|
||||||
|
p { "Could not lock settings: " }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone())
|
||||||
|
.await
|
||||||
|
.is_err() {
|
||||||
|
return partials::profilesettings::profilesettings(!user.is_settings_unlocked(), Some("Incorrect password"), &user.username, &user.email);
|
||||||
|
}
|
||||||
|
match user.set_settings_unlocked(true, &state.db).await {
|
||||||
|
Ok(r) =>
|
||||||
|
if r.rows_affected() == 0 {
|
||||||
|
return html! {
|
||||||
|
h1 { "DB Error" }
|
||||||
|
p { "Could not unlock settings: " }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
Err(_) => return html! {
|
||||||
|
h1 { "DB Error" }
|
||||||
|
p { "Could not unlock settings: " }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partials::profilesettings::profilesettings(!user.is_settings_unlocked(), None, &user.username, &user.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
web/src/web/service/routes/profile/update.rs
Normal file
69
web/src/web/service/routes/profile/update.rs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
use std::os::linux::raw::stat;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::Form;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use crate::web::content::partials::profilesettings::profilesettings;
|
||||||
|
use crate::web::service::AppState;
|
||||||
|
use crate::web::service::auth::hash::{ensure_password_strength, hash_password_async};
|
||||||
|
use crate::web::service::extractors::authuser::AuthUser;
|
||||||
|
use crate::web::service::routes::Route;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(crate) struct UpdateSettings {
|
||||||
|
username: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_, AuthUser, Form<UpdateSettings>> for UpdateSettings {
|
||||||
|
const PATH: &'static str = "/update-settings";
|
||||||
|
|
||||||
|
async fn process(AuthUser(mut user): AuthUser, session: Session, state: State<AppState>, Form(form): Form<UpdateSettings>) -> impl IntoResponse {
|
||||||
|
let mut message: Option<String> = None;
|
||||||
|
'update_block: {
|
||||||
|
if let Some(username) = form.username {
|
||||||
|
if let Err(e) = user.update_username(&username, &state.db).await {
|
||||||
|
message = Some(e.to_string());
|
||||||
|
break 'update_block;
|
||||||
|
}
|
||||||
|
message = Some("Updated Username".to_string());
|
||||||
|
} else if let Some(email) = form.email {
|
||||||
|
if let Err(e) = user.update_email(&email, &state.db).await {
|
||||||
|
message = Some(e.to_string());
|
||||||
|
break 'update_block;
|
||||||
|
}
|
||||||
|
message = Some("Updated Email".to_string());
|
||||||
|
} else if let Some(password) = form.password {
|
||||||
|
if let Err(e) = ensure_password_strength(&password) {
|
||||||
|
message = Some(e.to_string());
|
||||||
|
break 'update_block;
|
||||||
|
}
|
||||||
|
let password_hash = match hash_password_async(password, state.config.pepper.clone()).await {
|
||||||
|
Ok(hash) => hash,
|
||||||
|
Err(e) => {
|
||||||
|
message = Some(e.to_string());
|
||||||
|
break 'update_block;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = user.update_password(&password_hash, &state.db).await {
|
||||||
|
message = Some(e.to_string());
|
||||||
|
break 'update_block;
|
||||||
|
}
|
||||||
|
user.password_hash = password_hash;
|
||||||
|
if let Err(e) = user.set_settings_unlocked(false, &state.db).await {
|
||||||
|
message = Some(e.to_string());
|
||||||
|
break 'update_block;
|
||||||
|
}
|
||||||
|
message = Some("Updated Password".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(s) = message {
|
||||||
|
profilesettings(!user.is_settings_unlocked(), Some(&s), &user.username, &user.email)
|
||||||
|
} else {
|
||||||
|
profilesettings(!user.is_settings_unlocked(), None, &user.username, &user.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,17 +3,32 @@ use axum::response::IntoResponse;
|
|||||||
use maud::html;
|
use maud::html;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use crate::web::content::partials::page::page;
|
use crate::web::content::partials::page::page;
|
||||||
|
use crate::web::content::style::CssFragment;
|
||||||
use crate::web::service::AppState;
|
use crate::web::service::AppState;
|
||||||
use crate::web::service::routes::Route;
|
use crate::web::service::routes::Route;
|
||||||
|
|
||||||
pub(crate) struct Root;
|
pub(crate) struct Root;
|
||||||
|
|
||||||
impl Route<'_, ()> for Root {
|
inventory::submit! {
|
||||||
|
CssFragment(r#"
|
||||||
|
#home {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_, (), ()> for Root {
|
||||||
const PATH: &'static str = "/";
|
const PATH: &'static str = "/";
|
||||||
|
|
||||||
async fn process(session: Session, State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse {
|
async fn process(_: (), session: Session, State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse {
|
||||||
page(Self::NAME, Self::PATH, !session.is_empty().await, html! {
|
page(Self::NAME, Self::PATH, !session.is_empty().await, html! {
|
||||||
"Hello, world!"
|
div id="home" {
|
||||||
|
h1 { "Welcome to MicroTLD" }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user