Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
9580711d6b feat: ability to hide tags 2025-04-02 12:46:29 +02:00
1988abe369 blep 2025-03-26 14:23:30 +01:00
01ab37b401 fix: dumb css miss 2025-03-26 13:54:42 +01:00
82b517aa86 feat: cleaner width, closes #1 (yea thats it) 2025-03-19 17:06:20 +01:00
2c886f83e7 feat: TOTP 2FA, closes #5 2025-03-19 13:19:26 +01:00
47 changed files with 1399 additions and 245 deletions

View file

@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "select otp_method from otp where user_id = $1 and otp_method = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "otp_method",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b"
}

View file

@ -97,6 +97,11 @@
"ordinal": 18,
"name": "archived_at",
"type_info": "Timestamptz"
},
{
"ordinal": 19,
"name": "is_public",
"type_info": "Bool"
}
],
"parameters": {
@ -123,7 +128,8 @@
true,
true,
false,
true
true,
false
]
},
"hash": "164d77651f1f1b9ec7a28343db098305486e025bc4a5e71279a62da807ecea79"

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "select otp_method from otp where user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "otp_method",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = $1,\n\t\t\tname = $2,\n\t\t\tpronoun_subject = $3,\n\t\t\tpronoun_object = $4,\n\t\t\tpronoun_possessive = $5,\n\t\t\thandler_name = $6,\n\t\t\thandler_link = $7,\n\t\t\tkind = $8,\n\t\t\tbreed = $9,\n\t\t\tbehaviour = $10,\n\t\t\tdescription = $11,\n\t\t\tchassis_type = $12,\n\t\t\tchassis_id = $13,\n\t\t\tchassis_color = $14,\n\t\t\tarchived_at = null,\n\t\t\tupdated_at = current_timestamp\n\t\t\twhere id = $15 and bound_to_id = $16\n\t\t",
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = $1,\n\t\t\tname = $2,\n\t\t\tpronoun_subject = $3,\n\t\t\tpronoun_object = $4,\n\t\t\tpronoun_possessive = $5,\n\t\t\thandler_name = $6,\n\t\t\thandler_link = $7,\n\t\t\tkind = $8,\n\t\t\tbreed = $9,\n\t\t\tbehaviour = $10,\n\t\t\tdescription = $11,\n\t\t\tchassis_type = $12,\n\t\t\tchassis_id = $13,\n\t\t\tchassis_color = $14,\n\t\t\tis_public = $15,\n\t\t\tarchived_at = null,\n\t\t\tupdated_at = current_timestamp\n\t\t\twhere id = $16 and bound_to_id = $17\n\t\t",
"describe": {
"columns": [],
"parameters": {
@ -19,11 +19,12 @@
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Int4",
"Uuid"
]
},
"nullable": []
},
"hash": "5e6bd41ff3105cb2c41c82e0bb4ea973e358c702857c05158b32c8bc81c8942e"
"hash": "3152a8cb3d5be0a8be3b6e75754fa1d0fdbf2fc64b7086792d776318c1cec454"
}

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "delete from otp where user_id = $1 and otp_method = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\t\tselect * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null\n\t\t\t",
"query": "select * from doll_profiles where (id = $1 or microchip_id = $2)",
"describe": {
"columns": [
{
@ -97,6 +97,11 @@
"ordinal": 18,
"name": "archived_at",
"type_info": "Timestamptz"
},
{
"ordinal": 19,
"name": "is_public",
"type_info": "Bool"
}
],
"parameters": {
@ -124,8 +129,9 @@
true,
true,
false,
true
true,
false
]
},
"hash": "03800471afd396ebcabb3dfd9fb7523989c0d0af621aeea8c7480dede8ceef1b"
"hash": "32ee0b9c347300695e7aff02c940eb851984a761e4ce64239645088588d505da"
}

View file

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "update otp set recovery_key = $3 where user_id = $1 and otp_method = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8"
}

View file

@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "select * from otp where user_id = $1 and otp_method = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "otp_method",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "secret_seed",
"type_info": "Bpchar"
},
{
"ordinal": 4,
"name": "recovery_key",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "select id from doll_profiles where id = $1 or microchip_id = $2",
"query": "select id from doll_profiles where id = $1",
"describe": {
"columns": [
{
@ -11,13 +11,12 @@
],
"parameters": {
"Left": [
"Int4",
"Text"
"Int4"
]
},
"nullable": [
false
]
},
"hash": "44834b8a95718d0ae8ffc96e93469c6e0b4e6ca1160f7ada141f9515a6921ec9"
"hash": "70736384c06084a3967b2d008f4c843d239a6a36ae02b1e2fd306498a04746af"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tinsert into doll_profiles\n\t\t\t(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id)\n\t\t\tvalues ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)\n\t\t",
"query": "\n\t\t\tinsert into doll_profiles\n\t\t\t(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id, is_public)\n\t\t\tvalues ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)\n\t\t",
"describe": {
"columns": [],
"parameters": {
@ -20,10 +20,11 @@
"Varchar",
"Varchar",
"Varchar",
"Uuid"
"Uuid",
"Bool"
]
},
"nullable": []
},
"hash": "a3d6e4ddfa10505e777ccb7a184e17a57c3620cd56ae3858f33db020c6be42f2"
"hash": "7d5fef137e77f2ed7a13958a4bd345eccbded1a4d838e7953dd906b484ff9a91"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = null,\n\t\t\tname = '',\n\t\t\tpronoun_subject = '',\n\t\t\tpronoun_object = '',\n\t\t\tpronoun_possessive = '',\n\t\t\thandler_name = '',\n\t\t\thandler_link = null,\n\t\t\tkind = null,\n\t\t\tbreed = null,\n\t\t\tbehaviour = null,\n\t\t\tdescription = null,\n\t\t\tchassis_type = null,\n\t\t\tchassis_id = null,\n\t\t\tchassis_color = null,\n\t\t\tupdated_at = current_timestamp,\n\t\t\tarchived_at = current_timestamp\n\t\t\twhere id = $1 and bound_to_id = $2\n\t\t",
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = null,\n\t\t\tname = '',\n\t\t\tpronoun_subject = '',\n\t\t\tpronoun_object = '',\n\t\t\tpronoun_possessive = '',\n\t\t\thandler_name = '',\n\t\t\thandler_link = null,\n\t\t\tkind = null,\n\t\t\tbreed = null,\n\t\t\tbehaviour = null,\n\t\t\tdescription = null,\n\t\t\tchassis_type = null,\n\t\t\tchassis_id = null,\n\t\t\tchassis_color = null,\n\t\t\tis_public = false,\n\t\t\tupdated_at = current_timestamp,\n\t\t\tarchived_at = current_timestamp\n\t\t\twhere id = $1 and bound_to_id = $2\n\t\t",
"describe": {
"columns": [],
"parameters": {
@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "fd4b05dbaea47ab4a0da33ba4ba4f05eec757e511c686690346e4c3cc1fb3a28"
"hash": "baec493b4eb0998f3e5e0be8b8bb4740630a222b3bef2d7d32ac61928734d30d"
}

View file

@ -97,6 +97,11 @@
"ordinal": 18,
"name": "archived_at",
"type_info": "Timestamptz"
},
{
"ordinal": 19,
"name": "is_public",
"type_info": "Bool"
}
],
"parameters": {
@ -123,7 +128,8 @@
true,
true,
false,
true
true,
false
]
},
"hash": "e3234918965fd36a56fc48fc78956b53c8a84f08e0424d63e930f9dcfc449175"

View file

@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Bpchar",
"Varchar"
]
},
"nullable": []
},
"hash": "f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\t\tselect * from doll_profiles where (id = $1 or microchip_id = $2)\n\t\t\t",
"query": "select * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null and is_public is true",
"describe": {
"columns": [
{
@ -97,6 +97,11 @@
"ordinal": 18,
"name": "archived_at",
"type_info": "Timestamptz"
},
{
"ordinal": 19,
"name": "is_public",
"type_info": "Bool"
}
],
"parameters": {
@ -124,8 +129,9 @@
true,
true,
false,
true
true,
false
]
},
"hash": "b032b09996d538f01c221d2d2b09563c1a0a9164416b3f5c08de54c2f5b19fa4"
"hash": "f46606ed7e634ac9ef528348868b4f05eca9df12fda01f9042939aca4689d3f1"
}

41
Cargo.lock generated
View file

@ -229,6 +229,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "base32"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
[[package]]
name = "base64"
version = "0.21.7"
@ -431,6 +437,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6"
[[package]]
name = "cookie"
version = "0.18.1"
@ -654,6 +666,7 @@ dependencies = [
"rocket_dyn_templates",
"serde_json",
"sqlx",
"totp-rs",
"uuid",
]
@ -2068,6 +2081,17 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
[[package]]
name = "qrcodegen-image"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708"
dependencies = [
"base64 0.22.1",
"image",
"qrcodegen",
]
[[package]]
name = "quote"
version = "1.0.38"
@ -3110,6 +3134,23 @@ dependencies = [
"winnow",
]
[[package]]
name = "totp-rs"
version = "5.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b2f27dad992486c26b4e7455f38aa487e838d6d61b57e72906ee2b8c287a90"
dependencies = [
"base32",
"constant_time_eq",
"hmac",
"qrcodegen-image",
"rand",
"sha1",
"sha2",
"url",
"urlencoding",
]
[[package]]
name = "tower-service"
version = "0.3.3"

View file

@ -23,3 +23,4 @@ orion = "0.17"
uuid = { version = "1.11", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
qrcode-generator = "5"
totp-rs = { version = "5.6", features = ["gen_secret", "qr"] }

6
TODO.md Normal file
View file

@ -0,0 +1,6 @@
1. change the tag access status filter into a struct rust-side
2. go over all access routes currently in place and double-check / update them
3. add a way to discern public and private tags inside the tag list (account/index)
4. add a way to toggle that (account/index)
5. tag create / edit form: add a "save in private" btn that saves the tag but in private mode
6. audit: private/public switch

View file

@ -61,11 +61,10 @@ picture.block>img {
body {
margin: 0 auto 2em auto;
max-width: 700px;
}
footer {
margin-top: 2em;
margin: 2em 1em;
padding-top: .5em;
border-top: 1pt solid var(--clr-primary-a50);
font-size: 0.9em;
@ -103,6 +102,7 @@ textarea,
border-radius: 4pt;
padding: 4pt 8pt;
background-color: var(--clr-surface-tonal-a10);
font-size: 1em;
}
input,
@ -123,11 +123,15 @@ input:hover,
select:hover,
button:hover,
textarea:hover,
button:hover,
.btn:hover {
border-color: var(--clr-primary-a0);
}
button:hover,
.btn:hover {
cursor: pointer;
}
textarea {
resize: vertical;
font-size: 1em;
@ -157,7 +161,8 @@ section {
p.form-error,
div.form-error,
a.error {
a.error,
button.error {
background-color: var(--clr-error-surface);
border: 2pt solid var(--clr-error-primary-0);
color: var(--clr-error-primary-40);
@ -165,6 +170,15 @@ a.error {
padding: .5em 1em;
}
a.error:hover,
button.error:hover,
a.error:focus,
button.error:focus,
a.error:active,
button.error:active {
border-color: var(--clr-error-primary-40);
}
p.note {
font-size: .8em;
}
@ -267,8 +281,14 @@ p.note {
font-size: .9em;
}
div.submit {
margin: 2em 0;
display: flex;
justify-content: space-around;
align-items: center;
}
button.submit {
margin: 2em auto;
font-size: 1.2em;
display: block;
}
@ -285,13 +305,15 @@ button.submit {
}
input#ident,
input#microchip_id {
input#microchip_id,
input.id {
font-family: monospace;
font-size: 1.6em;
box-sizing: content-box;
}
input#ident {
input#ident,
input.id {
width: 6ch;
}
@ -371,6 +393,28 @@ input#ident {
font-size: 2em;
}
.totp-qrcode>img {
display: block;
margin: 0 auto;
height: 160pt;
}
.totp-form input {
width: 100%;
text-align: center;
margin-bottom: 1em;
}
pre.recovery-key {
text-align: center;
background-color: var(--clr-surface-tonal-a10);
border: 2pt solid var(--clr-surface-tonal-a50);
border-radius: 4pt;
font-size: 1.2em;
font-weight: bold;
padding: .5em 2em;
}
@media screen and (max-width: 400px) {
header.padded>nav {
display: flex;
@ -381,9 +425,13 @@ input#ident {
p.subnav {
flex-direction: column;
}
.split {
flex-direction: column;
}
}
@media screen and (max-width: 700px) {
@media screen and (max-width: 800px) {
div.dual-fields {
flex-direction: column;
}
@ -406,3 +454,9 @@ input#ident {
align-items: center;
}
}
@media screen and (min-width: 800px) {
.dual-fields>*:last-child {
flex: 1;
}
}

11
migrations/8_totp.sql Normal file
View file

@ -0,0 +1,11 @@
create table otp (
user_id uuid not null,
created_at timestamptz not null default current_timestamp,
-- enum str on client side
otp_method varchar(32) not null,
-- 160bit base32-encoded key
secret_seed char(32) not null,
recovery_key varchar(512) not null,
primary key (user_id, otp_method),
foreign key (user_id) references users (id) on delete cascade
);

View file

@ -0,0 +1,2 @@
alter table doll_profiles
add column is_public boolean not null default true;

View file

@ -1,2 +1,3 @@
pub mod otp;
pub mod pw;
pub mod session;

53
src/auth/otp.rs Normal file
View file

@ -0,0 +1,53 @@
use rocket::{
http::{Cookie, CookieJar},
time::Duration,
};
use totp_rs::{Secret, TOTP};
const ISSUER: &'static str = "dolltags";
fn make_cookie<'a>(value: String) -> Cookie<'a> {
Cookie::build(("otp_init", value))
.max_age(Duration::minutes(5))
.http_only(true)
.same_site(rocket::http::SameSite::Strict)
.path("/account/settings")
.build()
}
/// Creates a TOTP instance using in-code defaults and user OTP settings.
/// i use the internal user account UUID instead of their username to cover the case
/// where a user needs to change their account username.
pub fn make_totp(account_id: &str, secret: Vec<u8>) -> Result<TOTP, totp_rs::TotpUrlError> {
TOTP::new(
totp_rs::Algorithm::SHA1,
6,
1,
30,
secret,
Some(String::from(ISSUER)),
String::from(account_id),
)
}
/// Adds the provided OTP secret as encrypted cookie for 5 minutes
pub fn cache_secret<'a>(cookies: &CookieJar<'a>, secret: &'a Secret) {
if let Secret::Encoded(b32) = secret.to_encoded() {
cookies.add_private(make_cookie(b32));
}
}
/// Tries to get a potentially existing secret from the cookies.
/// If no cookie is set or if the cookie's value doesn't parse to
/// a valid base32 secret this returns None
pub fn get_secret(cookies: &CookieJar<'_>) -> Option<Secret> {
cookies.get_private("otp_init").and_then(|cookie| {
let val = cookie.value_trimmed();
Secret::Encoded(String::from(val)).to_raw().ok()
})
}
/// Manually revokes the encrypted cookie
pub fn remove_secret(cookies: &CookieJar<'_>) {
cookies.remove_private(make_cookie(String::from("woof")))
}

View file

@ -1,10 +1,11 @@
use std::str::FromStr;
use rocket::{
http::{CookieJar, Status},
http::{Cookie, CookieJar, Status},
outcome::try_outcome,
request::{FromRequest, Outcome},
response::Redirect,
time::Duration,
Request,
};
use rocket_dyn_templates::{context, Template};
@ -110,6 +111,52 @@ impl<'a> FromRequest<'a> for Admin {
}
}
/// Creates a 2FA auth session cookie with short lifespan and a bit of enforcing
fn make_2fa_auth_cookie<'a>(value: String) -> Cookie<'a> {
Cookie::build(("2fa_auth", value))
.max_age(Duration::minutes(5))
.http_only(true)
.same_site(rocket::http::SameSite::Strict)
.path("/login")
.build()
}
/// Starts a 2FA auth session for a login, caching the user ID in a temp. short-lived session
pub fn init_2fa_auth(cookies: &CookieJar<'_>, user_id: &Uuid) {
cookies.add_private(make_2fa_auth_cookie(user_id.to_string()));
}
/// Gets the user id from the currently active auth session, or nothing otherwise
pub fn check_2fa_auth(cookies: &CookieJar<'_>) -> Option<Uuid> {
cookies
.get_private("2fa_auth")
.and_then(|v| Uuid::from_str(&v.value()).ok())
}
/// Clears the auth session
pub fn clear_2fa_auth(cookies: &CookieJar<'_>) {
cookies.remove_private(make_2fa_auth_cookie(String::new()));
}
/// [`AuthSession`] is used as a temporary user ID storage during the login procedures.
/// It retrieves its data from a private cookie from the cookie jar
#[derive(Debug)]
pub struct AuthSession(pub Uuid);
#[rocket::async_trait]
impl<'a> FromRequest<'a> for AuthSession {
type Error = ();
async fn from_request(req: &'a Request<'_>) -> Outcome<Self, Self::Error> {
let cookies = req.cookies();
match check_2fa_auth(cookies) {
Some(v) => Outcome::Success(AuthSession(v)),
None => Outcome::Forward(Status::Unauthorized),
}
}
}
#[catch(401)]
pub fn unauthorized(req: &Request) -> Redirect {
let next = req.uri().to_string();

View file

@ -3,6 +3,7 @@ use uuid::{uuid, Uuid};
use super::schema::{CreateDollProfile, DbHook, TrxHook};
/// Lists all the unarchived tags this account has
pub async fn list(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<DollProfile>> {
sqlx::query_as!(
DollProfile,
@ -13,6 +14,7 @@ pub async fn list(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<DollProfile>
.await
}
/// Lists the IDs of archived tags that are bound to this account
pub async fn list_archived(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<i32>> {
sqlx::query_scalar!(
"select id from doll_profiles where bound_to_id = $1 and archived_at is not null",
@ -22,6 +24,7 @@ pub async fn list_archived(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<i32
.await
}
/// Lists all the user's tags
pub async fn list_all(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<DollProfile>> {
sqlx::query_as!(
DollProfile,
@ -32,37 +35,38 @@ pub async fn list_all(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<DollProf
.await
}
/// Tries to get the requested tag based on the provided ID or microchip ID.
/// Will only include public non-archived tags.
pub async fn get_public(
db: &mut DbHook,
ident: i32,
microchip_id: &str,
) -> sqlx::Result<Option<DollProfile>> {
sqlx::query_as!(
DollProfile,
r#"select * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null and is_public is true"#,
ident,
microchip_id
).fetch_optional(&mut **db)
.await
}
pub async fn get(
db: &mut DbHook,
ident: i32,
microchip_id: &str,
include_archived: bool,
) -> sqlx::Result<Option<DollProfile>> {
if include_archived {
sqlx::query_as!(
DollProfile,
r#"
select * from doll_profiles where (id = $1 or microchip_id = $2)
"#,
ident,
microchip_id
)
.fetch_optional(&mut **db)
.await
} else {
sqlx::query_as!(
DollProfile,
r#"
select * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null
"#,
ident,
microchip_id
)
.fetch_optional(&mut **db)
.await
}
sqlx::query_as!(
DollProfile,
r#"select * from doll_profiles where (id = $1 or microchip_id = $2)"#,
ident,
microchip_id
)
.fetch_optional(&mut **db)
.await
}
/// Checks if some of the provided IDs are already used, returning the list of IDs that are used
pub async fn check_ids(db: &mut DbHook, idents: &Vec<i32>) -> sqlx::Result<Vec<i32>> {
sqlx::query_scalar!(
"select id from doll_profiles where id in (select * from unnest($1::int[]))",
@ -72,23 +76,23 @@ pub async fn check_ids(db: &mut DbHook, idents: &Vec<i32>) -> sqlx::Result<Vec<i
.await
}
pub async fn id_exists(db: &mut DbHook, ident: i32, microchip_id: &str) -> sqlx::Result<bool> {
Ok(sqlx::query!(
"select id from doll_profiles where id = $1 or microchip_id = $2",
ident,
microchip_id
/// Checks if a tag exists with this ID (in global, collision check method)
pub async fn id_exists(db: &mut DbHook, ident: i32) -> sqlx::Result<bool> {
Ok(
sqlx::query!("select id from doll_profiles where id = $1", ident)
.fetch_optional(&mut **db)
.await?
.is_some(),
)
.fetch_optional(&mut **db)
.await?
.is_some())
}
/// Creates a new tag using the form data from [`CreateDollProfile`]
pub async fn create(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Result<()> {
sqlx::query!(
r#"
insert into doll_profiles
(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id, is_public)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
"#,
doll.id,
doll.microchip_id,
@ -106,11 +110,13 @@ pub async fn create(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Resul
doll.chassis_id,
doll.chassis_color,
doll.bound_to_id,
doll.is_public,
).execute(&mut **db).await?;
Ok(())
}
/// Edits the given tag with the create/edit form,
/// editing a doll_profile will also unarchive it
pub async fn edit(
db: &mut DbHook,
@ -134,9 +140,10 @@ pub async fn edit(
chassis_type = $12,
chassis_id = $13,
chassis_color = $14,
is_public = $15,
archived_at = null,
updated_at = current_timestamp
where id = $15 and bound_to_id = $16
where id = $16 and bound_to_id = $17
"#,
doll.microchip_id,
doll.name,
@ -152,6 +159,7 @@ pub async fn edit(
doll.chassis_type,
doll.chassis_id,
doll.chassis_color,
doll.is_public,
doll.id,
bound_account_id
)
@ -161,7 +169,7 @@ pub async fn edit(
Ok(())
}
/// deleting a doll profile only wipes the data associated to it but retains two bits of info:
/// deleting (or archiving) a doll profile only wipes the data associated to it but retains two bits of info:
/// - the tag's ID
/// - the account which created this tag
///
@ -187,6 +195,7 @@ pub async fn delete(trx: &mut TrxHook<'_>, id: i32, bound_account_id: &Uuid) ->
chassis_type = null,
chassis_id = null,
chassis_color = null,
is_public = false,
updated_at = current_timestamp,
archived_at = current_timestamp
where id = $1 and bound_to_id = $2

View file

@ -1,6 +1,7 @@
pub mod admin;
pub mod doll;
pub mod migrate;
pub mod otp;
pub mod reseed;
pub mod schema;
pub mod user;

88
src/db/otp.rs Normal file
View file

@ -0,0 +1,88 @@
use uuid::Uuid;
use super::schema::{DbHook, OTP};
pub const METHOD_TOTP: &'static str = "totp";
/// Checks that the provided user has the specified OTP method
pub async fn has_otp(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<bool> {
Ok(sqlx::query!(
"select otp_method from otp where user_id = $1 and otp_method = $2",
id,
method
)
.fetch_optional(&mut **db)
.await?
.is_some())
}
/// Lists the OTP methods the user has enabled
pub async fn list_enabled_methods(db: &mut DbHook, id: &Uuid) -> sqlx::Result<Vec<String>> {
sqlx::query_scalar!("select otp_method from otp where user_id = $1", id)
.fetch_all(&mut **db)
.await
}
/// Gets the requested OTP method config, if set
pub async fn get_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<Option<OTP>> {
sqlx::query_as!(
OTP,
"select * from otp where user_id = $1 and otp_method = $2",
id,
method
)
.fetch_optional(&mut **db)
.await
}
/// Adds a new otp method with the provided config; will fail without check if one is already set
pub async fn add_otp_method(
db: &mut DbHook,
id: &Uuid,
secret: &str,
hashed_recovery_key: &str,
) -> sqlx::Result<()> {
sqlx::query!(
"insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)",
id,
METHOD_TOTP,
secret,
hashed_recovery_key,
)
.execute(&mut **db)
.await?;
Ok(())
}
/// Deletes the given OTP method's configuration for the user
pub async fn delete_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<()> {
sqlx::query!(
"delete from otp where user_id = $1 and otp_method = $2",
id,
method
)
.execute(&mut **db)
.await?;
Ok(())
}
/// Changes the recovery key for the given OTP method; if there was none defined, will do nothing
pub async fn change_recovery_key(
db: &mut DbHook,
id: &Uuid,
otp_method: &str,
hashed_recovery_key: &str,
) -> sqlx::Result<()> {
sqlx::query!(
"update otp set recovery_key = $3 where user_id = $1 and otp_method = $2",
id,
otp_method,
hashed_recovery_key
)
.execute(&mut **db)
.await?;
Ok(())
}

View file

@ -23,6 +23,7 @@ pub struct DollProfile {
pub created_at: chrono::DateTime<Utc>,
pub updated_at: Option<chrono::DateTime<Utc>>,
pub archived_at: Option<chrono::DateTime<Utc>>,
pub is_public: bool,
pub bound_to_id: Uuid,
@ -58,6 +59,7 @@ pub struct CreateDollProfile<'a> {
pub id: i32,
pub microchip_id: Option<&'a str>,
pub bound_to_id: &'a Uuid,
pub is_public: bool,
pub name: &'a str,
pub pronoun_subject: &'a str,
@ -98,6 +100,18 @@ pub struct User {
pub is_admin: bool,
}
/// A user's OTP config for a given OTP scheme.
/// WAT? why does it work without a serialize impl.?
#[derive(Debug)]
pub struct OTP {
pub user_id: Uuid,
pub created_at: chrono::DateTime<Utc>,
pub otp_method: String,
pub secret_seed: String,
pub recovery_key: String,
}
/// The service status aggregate
#[derive(Debug, Serialize)]
#[serde(crate = "rocket::serde")]

View file

@ -4,12 +4,32 @@ use rocket::{form, request::FromParam};
use crate::db::{doll, schema::DollTagsDb};
const SYMBOLS: [char; 36] = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
];
/// Generate a random recovery key, of length 14 (16 with the two `-`)
/// and format `xxxx-xxxxxx-xxxx` using the charset `a-z0-9`
pub fn generate_recovery_key() -> String {
let uniform = Uniform::new_inclusive::<usize, usize>(0, 35);
let mut rng = thread_rng();
let first: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
let second: String = (1..=6).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
let third: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
format!("{}-{}-{}", first, second, third)
}
/// Generate 10 random doll tags IDs, hoping to have at least 5 of them available
pub fn generate_ids() -> Vec<i32> {
let uniform = Uniform::new_inclusive::<i32, i32>(100_000, 999_999);
let mut rng = thread_rng();
(1..=10).map(|_| uniform.sample(&mut rng)).collect()
}
/// Generates 10 random doll tags IDs and check against the DB to find up to 5 free ones
pub async fn pick_ids(mut db: DollTagsDb) -> Result<Vec<i32>, sqlx::Error> {
let mut ids_bundle = generate_ids();
let occupied_ids = doll::check_ids(&mut *db, &ids_bundle).await?;

View file

@ -68,20 +68,26 @@ fn rocket() -> _ {
.mount(
"/account",
routes![
account::index,
account::qr_profile,
account::show_settings,
account::change_settings,
account::change_password,
account::common::index,
account::common::qr_profile,
account::settings::show_settings,
account::settings::change_settings,
account::settings::change_password,
form::register_tag::show_register,
form::register_tag::handle_register,
form::register_tag::show_edit_tag,
form::register_tag::handle_edit_tag,
account::ask_delete,
account::confirm_delete,
account::ask_terminate_account,
account::confirm_terminate_account,
account::export_data,
account::common::ask_delete,
account::common::confirm_delete,
account::common::ask_terminate_account,
account::common::confirm_terminate_account,
account::common::export_data,
account::otp::show_totp_enable_start,
account::otp::handle_totp_enable_start,
account::otp::show_confirm_totp_regenerate_key,
account::otp::regenerate_key,
account::otp::show_confirm_totp_disable,
account::otp::handle_confirm_totp_disable,
],
)
.mount(
@ -105,6 +111,10 @@ fn rocket() -> _ {
accounts::show_login,
accounts::handle_login,
accounts::logout,
accounts::show_2fa_form,
accounts::handle_2fa_form,
accounts::show_2fa_recover_form,
accounts::handle_2fa_recover_form,
],
)
}

View file

@ -0,0 +1,154 @@
use std::net::IpAddr;
use qrcode_generator::QrCodeEcc;
use rocket::{
http::{uri::Absolute, CookieJar},
response::Redirect,
serde::{json::Json, Serialize},
State,
};
use rocket_dyn_templates::{context, Template};
use sqlx::Acquire;
use crate::{
auth::session,
config::Config,
db::{
doll,
schema::{DollProfile, DollTagsDb, User},
user,
},
pages::CommonTemplateState,
routes::error_handlers::{PageResult, RawResult},
};
#[get("/")]
pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult {
let tags = doll::list(&mut *db, &user.id).await?;
let archived_tags = doll::list_archived(&mut *db, &user.id).await?;
Ok(Template::render(
"account/index",
context! {
meta,
user,
tags,
archived_tags,
},
)
.into())
}
#[get("/qr-png/<id>")]
pub fn qr_profile(id: &str, _user: User, config: &State<Config>) -> PageResult {
let public_uri = Absolute::parse(&config.public_url)?;
let built_uri = uri!(public_uri, crate::routes::public::short_url(id));
let image = qrcode_generator::to_png_to_vec(built_uri.to_string(), QrCodeEcc::Low, 400)?;
Ok(image.into())
}
#[derive(Debug, Serialize)]
#[serde(crate = "rocket::serde")]
pub struct DataDump {
account: User,
tags: Vec<DollProfile>,
reserved_tags: Vec<i32>,
}
#[get("/data_dump")]
pub async fn export_data(
mut db: DollTagsDb,
user: User,
client_ip: IpAddr,
) -> RawResult<Json<DataDump>> {
let tags = doll::list_all(&mut *db, &user.id).await?;
let reserved_tags = doll::list_archived(&mut *db, &user.id).await?;
warn!(
"[audit|{}] [{}] exported data",
client_ip,
user.id.to_string()
);
Ok(Json(DataDump {
account: user,
tags,
reserved_tags,
}))
}
#[get("/delete/<id>")]
pub async fn ask_delete(
mut db: DollTagsDb,
id: i32,
user: User,
meta: CommonTemplateState,
) -> PageResult {
let db_tag = doll::get(&mut *db, id, "").await?;
if let Some(tag) = db_tag {
if tag.bound_to_id != user.id || tag.archived_at.is_some() {
Ok(Redirect::to(uri!("/account", index)).into())
} else {
Ok(Template::render(
"tag/delete",
context! {
meta,
tag,
},
)
.into())
}
} else {
Ok(Redirect::to(uri!("/account", index)).into())
}
}
#[get("/yes_delete_this/<id>")]
pub async fn confirm_delete(
mut db: DollTagsDb,
id: i32,
user: User,
client_ip: IpAddr,
) -> PageResult {
let mut trx = db.begin().await?;
doll::delete(&mut trx, id, &user.id).await?;
trx.commit().await?;
warn!(
"[audit|{}] [{}] deleted tag {:0>6}",
client_ip,
user.id.to_string(),
id
);
Ok(Redirect::to(uri!("/account", index)).into())
}
#[get("/terminate")]
pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template {
Template::render("account/terminate", context! {meta})
}
#[get("/termin@or")]
pub async fn confirm_terminate_account(
mut db: DollTagsDb,
user: User,
cookies: &CookieJar<'_>,
client_ip: IpAddr,
) -> PageResult {
let mut trx = db.begin().await?;
doll::delete_all_from_account(&mut trx, &user.id).await?;
user::delete(&mut trx, &user.id).await?;
session::logout(cookies);
trx.commit().await?;
warn!(
"[audit|{}] [{}] deleted account",
client_ip,
user.id.to_string()
);
Ok(Redirect::to("/").into())
}

View file

@ -0,0 +1,3 @@
pub mod common;
pub mod otp;
pub mod settings;

210
src/routes/account/otp.rs Normal file
View file

@ -0,0 +1,210 @@
use std::net::IpAddr;
use rocket::{form::Form, http::CookieJar, response::Redirect, tokio::task};
use rocket_dyn_templates::{context, Template};
use totp_rs::Secret;
use crate::{
auth::{self, pw},
db::{
self,
otp::METHOD_TOTP,
schema::{DollTagsDb, User},
},
ids::generate_recovery_key,
pages::CommonTemplateState,
routes::{self, error_handlers::PageResult},
};
#[get("/settings/totp?<invalid_code>")]
pub async fn show_totp_enable_start(
mut db: DollTagsDb,
invalid_code: bool,
user: User,
cookies: &CookieJar<'_>,
meta: CommonTemplateState,
) -> PageResult {
if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
}
// cookie recovery in case it was a form error and not a new session
let mut totp_secret = auth::otp::get_secret(cookies);
if totp_secret.is_none() {
let new_secret = Secret::generate_secret();
auth::otp::cache_secret(cookies, &new_secret);
totp_secret = Some(new_secret);
}
let totp_secret = totp_secret.unwrap();
let totp = auth::otp::make_totp(&user.id.to_string(), totp_secret.to_bytes()?)?;
let totp_qrcode = totp.get_qr_base64()?;
Ok(Template::render(
"account/otp/start",
context! {
meta,
totp_qrcode,
invalid_code,
secret: totp.get_secret_base32(),
},
)
.into())
}
#[derive(FromForm)]
pub struct OtpEnableForm {
#[field(validate = len(6..=6))]
pub otp_code: String,
}
#[post("/settings/totp", data = "<form>")]
pub async fn handle_totp_enable_start(
mut db: DollTagsDb,
form: Form<OtpEnableForm>,
user: User,
cookies: &CookieJar<'_>,
meta: CommonTemplateState,
client_ip: IpAddr,
) -> PageResult {
if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
}
let secret = match auth::otp::get_secret(cookies) {
Some(v) => v,
None => return Ok(Redirect::to(uri!("/account", show_totp_enable_start(false))).into()),
};
let totp = auth::otp::make_totp(&user.id.to_string(), secret.to_bytes()?)?;
if !totp.check_current(&form.otp_code)? {
return Ok(Redirect::to(uri!("/account", show_totp_enable_start(true))).into());
}
warn!(
"[audit|{}] [{}] enabled TOTP 2FA",
client_ip,
user.id.to_string(),
);
let recovery_key = generate_recovery_key();
{
let recovery_key = recovery_key.clone();
let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??;
db::otp::add_otp_method(
&mut *db,
&user.id,
&totp.get_secret_base32(),
&hashed_recovery_key,
)
.await?;
}
auth::otp::remove_secret(cookies);
Ok(Template::render(
"account/otp/confirm",
context! {
meta,
recovery_key,
},
)
.into())
}
#[get("/settings/totp/generate-key")]
pub async fn show_confirm_totp_regenerate_key(
mut db: DollTagsDb,
user: User,
meta: CommonTemplateState,
) -> PageResult {
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
}
Ok(Template::render(
"account/otp/confirm_regenerate_key",
context! {
meta,
},
)
.into())
}
#[post("/settings/totp/generate-key")]
pub async fn regenerate_key(
mut db: DollTagsDb,
user: User,
meta: CommonTemplateState,
client_ip: IpAddr,
) -> PageResult {
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
}
warn!(
"[audit|{}] [{}] regenerated TOTP 2FA recovery key",
client_ip,
user.id.to_string(),
);
let recovery_key = generate_recovery_key();
{
let recovery_key = recovery_key.clone();
let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??;
db::otp::change_recovery_key(&mut *db, &user.id, METHOD_TOTP, &hashed_recovery_key).await?;
}
Ok(Template::render(
"account/otp/regenerate_key",
context! {
recovery_key,
meta,
},
)
.into())
}
#[get("/settings/totp/disable")]
pub async fn show_confirm_totp_disable(
mut db: DollTagsDb,
user: User,
meta: CommonTemplateState,
) -> PageResult {
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
}
Ok(Template::render(
"account/otp/disable",
context! {
meta,
},
)
.into())
}
#[post("/settings/totp/disable")]
pub async fn handle_confirm_totp_disable(
mut db: DollTagsDb,
user: User,
client_ip: IpAddr,
) -> PageResult {
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
}
warn!(
"[audit|{}] [{}] deactivated TOTP 2FA",
client_ip,
user.id.to_string(),
);
db::otp::delete_otp_method(&mut *db, &user.id, METHOD_TOTP).await?;
Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into())
}

View file

@ -1,67 +1,42 @@
use std::net::IpAddr;
use qrcode_generator::QrCodeEcc;
use rocket::{
form::{self, Contextual, Error, Form},
http::{uri::Absolute, CookieJar},
response::Redirect,
serde::{json::Json, Serialize},
tokio::task,
State,
};
use rocket_dyn_templates::{context, Template};
use sqlx::Acquire;
use crate::{
auth::{pw, session},
config::Config,
auth::pw,
db::{
doll,
schema::{DollProfile, DollTagsDb, User},
otp,
schema::{DollTagsDb, User},
user,
},
pages::CommonTemplateState,
routes::error_handlers::PageResult,
};
use super::error_handlers::{PageResult, RawResult};
#[get("/")]
pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult {
let tags = doll::list(&mut *db, &user.id).await?;
let archived_tags = doll::list_archived(&mut *db, &user.id).await?;
#[get("/settings")]
pub async fn show_settings(
mut db: DollTagsDb,
user: User,
meta: CommonTemplateState,
) -> PageResult {
let enabled_otp_methods = otp::list_enabled_methods(&mut *db, &user.id).await?;
Ok(Template::render(
"account/index",
context! {
meta,
user,
tags,
archived_tags,
},
)
.into())
}
#[get("/qr-png/<id>")]
pub fn qr_profile(id: &str, _user: User, config: &State<Config>) -> PageResult {
let public_uri = Absolute::parse(&config.public_url)?;
let built_uri = uri!(public_uri, crate::routes::public::short_url(id));
let image = qrcode_generator::to_png_to_vec(built_uri.to_string(), QrCodeEcc::Low, 400)?;
Ok(image.into())
}
#[get("/settings")]
pub fn show_settings(user: User, meta: CommonTemplateState) -> Template {
Template::render(
"account/settings",
context! {
user,
meta,
enabled_otp_methods,
prev_common: form::Context::default(),
prev_password: form::Context::default(),
},
)
.into())
}
#[derive(FromForm)]
@ -209,108 +184,3 @@ pub async fn change_password(
Ok(Redirect::to(uri!("/account", show_settings)).into())
}
#[derive(Debug, Serialize)]
#[serde(crate = "rocket::serde")]
pub struct DataDump {
account: User,
tags: Vec<DollProfile>,
reserved_tags: Vec<i32>,
}
#[get("/data_dump")]
pub async fn export_data(
mut db: DollTagsDb,
user: User,
client_ip: IpAddr,
) -> RawResult<Json<DataDump>> {
let tags = doll::list_all(&mut *db, &user.id).await?;
let reserved_tags = doll::list_archived(&mut *db, &user.id).await?;
warn!(
"[audit|{}] [{}] exported data",
client_ip,
user.id.to_string()
);
Ok(Json(DataDump {
account: user,
tags,
reserved_tags,
}))
}
#[get("/delete/<id>")]
pub async fn ask_delete(
mut db: DollTagsDb,
id: i32,
user: User,
meta: CommonTemplateState,
) -> PageResult {
let db_tag = doll::get(&mut *db, id, "", false).await?;
if let Some(tag) = db_tag {
if tag.bound_to_id != user.id {
Ok(Redirect::to(uri!("/account", index)).into())
} else {
Ok(Template::render(
"tag/delete",
context! {
meta,
tag,
},
)
.into())
}
} else {
Ok(Redirect::to(uri!("/account", index)).into())
}
}
#[get("/yes_delete_this/<id>")]
pub async fn confirm_delete(
mut db: DollTagsDb,
id: i32,
user: User,
client_ip: IpAddr,
) -> PageResult {
let mut trx = db.begin().await?;
doll::delete(&mut trx, id, &user.id).await?;
trx.commit().await?;
warn!(
"[audit|{}] [{}] deleted tag {:0>6}",
client_ip,
user.id.to_string(),
id
);
Ok(Redirect::to(uri!("/account", index)).into())
}
#[get("/terminate")]
pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template {
Template::render("account/terminate", context! {meta})
}
#[get("/termin@or")]
pub async fn confirm_terminate_account(
mut db: DollTagsDb,
user: User,
cookies: &CookieJar<'_>,
client_ip: IpAddr,
) -> PageResult {
let mut trx = db.begin().await?;
doll::delete_all_from_account(&mut trx, &user.id).await?;
user::delete(&mut trx, &user.id).await?;
session::logout(cookies);
trx.commit().await?;
warn!(
"[audit|{}] [{}] deleted account",
client_ip,
user.id.to_string()
);
Ok(Redirect::to("/").into())
}

View file

@ -115,7 +115,6 @@ pub async fn handle_in_page_forms(
id_public_to_db(values.tag_id)
.expect("is form-validated so should always succeed"),
"",
true,
)
.await?;
if target_tag.is_none() {

View file

@ -8,15 +8,24 @@ use rocket::{
tokio::task,
};
use rocket_dyn_templates::{context, Template};
use totp_rs::Secret;
use crate::{
auth::{pw, session},
auth::{
otp::make_totp,
pw,
session::{self, AuthSession},
},
db::{
otp::{self, METHOD_TOTP},
schema::{DollTagsDb, User},
user,
},
pages::CommonTemplateState,
routes::error_handlers::{PageResponse, PageResult},
routes::{
self,
error_handlers::{PageResponse, PageResult},
},
};
#[derive(Debug, FromForm)]
@ -91,17 +100,211 @@ pub async fn handle_login(
task::spawn_blocking(move || pw::verify(&password, &user.password)).await??;
if right_password && user.enabled {
session::login(cookies, &user.id);
let enabled_methods = otp::list_enabled_methods(&mut *db, &user.id).await?;
if enabled_methods.len() == 0 {
session::login(cookies, &user.id);
warn!(
"[audit|{}] [{}] login successful ({})",
client_ip,
user.id.to_string(),
&values.username
);
let next_url = String::from(next.unwrap_or("/"));
Ok(Redirect::to(next_url).into())
} else {
warn!(
"[audit|{}] [{}] login user credentials validated; 2FA needed ({})",
client_ip,
user.id.to_string(),
&values.username
);
// only one impl. for now; flow and UI may change if/when more are added
if enabled_methods[0].contains(METHOD_TOTP) {
session::init_2fa_auth(cookies, &user.id);
Ok(Redirect::to(uri!(show_2fa_form(next))).into())
} else {
panic!(
"login - 2FA redirect - {:?} not implemented",
enabled_methods
);
}
}
} else {
Ok(miss())
}
}
#[get("/login/2fa?<next>")]
pub fn show_2fa_form(
maybe_loggedin: Option<User>,
_auth_session: AuthSession,
cookies: &CookieJar<'_>,
next: Option<&str>,
meta: CommonTemplateState,
) -> PageResult {
if maybe_loggedin.is_some() {
let next = String::from(next.unwrap_or("/"));
session::clear_2fa_auth(cookies);
Ok(Redirect::to(next).into())
} else {
Ok(Template::render(
"account/otp/login",
context! {
recovery_url: uri!(show_2fa_recover_form(next)),
meta,
},
)
.into())
}
}
#[derive(Debug, FromForm)]
pub struct TOTP2FAForm<'a> {
#[field(validate=len(6..=6))]
pub otp_code: &'a str,
}
#[post("/login/2fa?<next>", data = "<form>")]
pub async fn handle_2fa_form(
mut db: DollTagsDb,
maybe_loggedin: Option<User>,
auth_session: AuthSession,
next: Option<&str>,
cookies: &CookieJar<'_>,
form: Form<Contextual<'_, TOTP2FAForm<'_>>>,
meta: CommonTemplateState,
client_ip: IpAddr,
) -> PageResult {
if maybe_loggedin.is_some() {
let next = String::from(next.unwrap_or("/"));
return Ok(Redirect::to(next).into());
}
let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
let (form_data, otp_config) = match (&form.value, otp_config) {
(Some(form), Some(otp)) => (form, otp),
_ => {
return Ok(Template::render(
"account/otp/login",
context! {
recovery_url: uri!(show_2fa_recover_form(next)),
failure: true,
meta,
},
)
.into())
}
};
let totp = make_totp(
&auth_session.0.to_string(),
Secret::Encoded(otp_config.secret_seed)
.to_raw()?
.to_bytes()?,
)?;
if totp.check_current(form_data.otp_code)? {
session::clear_2fa_auth(cookies);
session::login(cookies, &auth_session.0);
warn!(
"[audit|{}] [{}] login successful ({})",
"[audit|{}] [{}] TOTP 2FA login successful",
client_ip,
user.id.to_string(),
&values.username
&auth_session.0.to_string()
);
let next_url = String::from(next.unwrap_or("/"));
Ok(Redirect::to(next_url).into())
} else {
Ok(miss())
Ok(Template::render(
"account/otp/login",
context! {
recovery_url: uri!(show_2fa_recover_form(next)),
failure: true,
meta,
},
)
.into())
}
}
#[get("/login/2fa/recover?<next>")]
pub fn show_2fa_recover_form(
maybe_loggedin: Option<User>,
_auth_session: AuthSession,
next: Option<&str>,
meta: CommonTemplateState,
) -> PageResult {
if maybe_loggedin.is_some() {
let next = String::from(next.unwrap_or("/"));
return Ok(Redirect::to(next).into());
}
Ok(Template::render("account/otp/recovery_login", context! {meta}).into())
}
#[derive(Debug, FromForm)]
pub struct TOTP2FARecoverForm<'a> {
#[field(validate=len(16..=16))]
pub recovery_key: &'a str,
}
#[post("/login/2fa/recover?<next>", data = "<form>")]
pub async fn handle_2fa_recover_form(
mut db: DollTagsDb,
maybe_loggedin: Option<User>,
auth_session: AuthSession,
cookies: &CookieJar<'_>,
form: Form<Contextual<'_, TOTP2FARecoverForm<'_>>>,
next: Option<&str>,
meta: CommonTemplateState,
client_ip: IpAddr,
) -> PageResult {
if maybe_loggedin.is_some() {
let next = String::from(next.unwrap_or("/"));
return Ok(Redirect::to(next).into());
}
let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
let (form_data, otp_config) = match (&form.value, otp_config) {
(Some(form), Some(otp)) => (form, otp),
_ => {
return Ok(Template::render(
"account/otp/recovery_login",
context! {failure: true, meta},
)
.into())
}
};
let submitted_key = String::from(form_data.recovery_key);
let hashed_key = otp_config.recovery_key;
let right_recovery_key =
task::spawn_blocking(move || pw::verify(&submitted_key, &hashed_key)).await??;
if right_recovery_key {
warn!(
"[audit|{}] [{}] TOTP 2FA recovery key used - disabling TOTP 2FA",
client_ip,
&auth_session.0.to_string()
);
otp::delete_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
session::clear_2fa_auth(cookies);
session::login(cookies, &auth_session.0);
Ok(Redirect::to(uri!(
"/account",
routes::account::settings::show_settings,
"#otp"
))
.into())
} else {
Ok(Template::render("account/otp/recovery_login", context! {failure: true, meta}).into())
}
}

View file

@ -41,6 +41,7 @@ impl From<DollProfile> for FakeContext {
"microchip_id",
vec![tag.microchip_id.unwrap_or(String::from(""))],
),
("is_public", vec![tag.is_public.to_string()]),
("name", vec![tag.name]),
("pronoun_subject", vec![tag.pronoun_subject]),
("pronoun_object", vec![tag.pronoun_object]),
@ -85,24 +86,24 @@ pub async fn show_edit_tag(
) -> PageResult {
let normalized_id = match id_public_to_db(id) {
Some(v) => v,
None => return Ok(Redirect::to(uri!("/account", account::index)).into()),
None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()),
};
let tag = match doll::get(&mut *db, normalized_id, "", true).await? {
let tag = match doll::get(&mut *db, normalized_id, "").await? {
Some(v) => {
if v.bound_to_id != user.id {
return Ok(Redirect::to(uri!("/account", account::index)).into());
return Ok(Redirect::to(uri!("/account", account::common::index)).into());
}
v
}
None => return Ok(Redirect::to(uri!("/account", account::index)).into()),
None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()),
};
Ok(Template::render(
"register_tag",
context! {
mode: "edit",
id,
id: normalized_id,
previous: FakeContext::from(tag),
meta,
},
@ -117,6 +118,8 @@ pub struct TagForm<'a> {
#[field(validate=len(..32))]
pub microchip_id: &'a str,
pub is_public: bool,
#[field(validate=len(1..=256))]
pub name: &'a str,
#[field(validate=len(1..=32))]
@ -205,7 +208,7 @@ pub async fn handle_register(
let normalized_microchip_id = tag.microchip_id.to_lowercase();
let microchip_id = normalize_opt(&normalized_microchip_id);
if doll::id_exists(&mut *db, id, microchip_id.unwrap_or("")).await? {
if doll::id_exists(&mut *db, id).await? {
// TODO: that's weird... what was i expecting to do here?
return Ok(Redirect::found(uri!("/account", show_register)).into());
}
@ -229,6 +232,7 @@ pub async fn handle_register(
chassis_id: normalize_opt(tag.chassis_id),
chassis_color: normalize_opt(tag.chassis_color),
bound_to_id: &user.id,
is_public: tag.is_public,
},
)
.await;
@ -250,7 +254,11 @@ pub async fn handle_register(
tag.ident
);
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
if tag.is_public {
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
} else {
Ok(Redirect::to(uri!(account::common::index)).into())
}
}
#[post("/edit_tag/<id>", data = "<tag>")]
@ -262,7 +270,9 @@ pub async fn handle_edit_tag(
meta: CommonTemplateState,
) -> PageResult {
let id = match id_public_to_db(id) {
None => return Ok(Redirect::to(uri!("/account", crate::routes::account::index)).into()),
None => {
return Ok(Redirect::to(uri!("/account", crate::routes::account::common::index)).into())
}
Some(v) => v,
};
let tag = match tag.value {
@ -314,9 +324,14 @@ pub async fn handle_edit_tag(
chassis_id: normalize_opt(tag.chassis_id),
chassis_color: normalize_opt(tag.chassis_color),
bound_to_id: &user.id,
is_public: tag.is_public,
},
)
.await?;
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
if tag.is_public {
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
} else {
Ok(Redirect::to(uri!(account::common::index)).into())
}
}

View file

@ -47,7 +47,7 @@ pub async fn show_profile(
let internal_id = ident.and_then(|v| id_public_to_db(v)).unwrap_or(0);
let microchip_id = microchip_id.unwrap_or("");
let profile = match doll::get(&mut *db, internal_id, microchip_id, false).await? {
let profile = match doll::get_public(&mut *db, internal_id, microchip_id).await? {
Some(p) => p,
None => return Ok(Redirect::to(uri!(index(Some(true), ident, Some(microchip_id)))).into()),
};

View file

@ -26,6 +26,8 @@
<input type="checkbox" name="anti_bot" style="display: none">
<button type="submit" class="submit">Log in</button>
<div class="submit">
<button type="submit" class="submit">Log in</button>
</div>
</form>
{% endblock main %}

View file

@ -0,0 +1,15 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}TOTP enabled - {% endblock title %}
{% block main %}
<p>TOTP 2FA was successfully enabled.</p>
<p>
Before moving on, it's strongly recommended that you save this key somewhere safe as it will be
needed to disable 2FA should you lose access to your 2FA codes.
</p>
<pre class="recovery-key"><code>{{recovery_key}}</code></pre>
<a href="/account/settings" class="btn">Finish and go to settings</a>
{% endblock main %}

View file

@ -0,0 +1,19 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}Regenerate your recovery key - {% endblock title %}
{% block main %}
<p>You're about to regenerate a recovery key.</p>
<p>
This will invalidate the current 2FA recovery key and generate a new one
which you'll have to store safely.
</p>
<section class="split">
<form method="post">
<button type="submit" class="error">Regenerate a new key</button>
</form>
<p><a href="/account/settings" class="btn">Or go back to the settings</a></p>
</section>
{% endblock main %}

View file

@ -0,0 +1,24 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}Disable TOTP 2FA - {% endblock title %}
{% block main %}
<p>You're about to disable TOTP 2FA.</p>
<p>
TOTP 2FA is an important additional security measure that comes into play when
your password gets compromised by someone.
</p>
<p>
If you're about to disable it due to having lost your recovery key, know that
you can instead choose to <a href="/account/settings/totp/generate-key">generate a new key</a>.
</p>
<p>If you still want to disable TOTP 2FA on your account, click on the red button below.</p>
<section class="split">
<form method="post">
<button type="submit" class="error">Disable TOTP 2FA</button>
</form>
<p><a href="/account/settings" class="btn">Or go back to the settings</a></p>
</section>
{% endblock main %}

View file

@ -0,0 +1,17 @@
{% extends "base" %}
{% block title %}Second authentication factor - {% endblock title %}
{% block main %}
<form method="post">
<h2><label for="otp_code">Enter your TOTP code</label></h2>
{% if failure %}
<p class="form-error">Couldn't validate this code.</p>
{% endif %}
<input type="text" id="otp_code" name="otp_code" minlength="6" maxlength="6" placeholder="000000" required />
<button type="submit">Log in</button>
<p><a href="{{recovery_url}}">Use your recovery key instead</a></p>
</form>
{% endblock main %}

View file

@ -0,0 +1,19 @@
{% extends "base" %}
{% block title %}Second authentication factor - {% endblock title %}
{% block main %}
<form method="post">
<h2><label for="recovery_key">Enter your recovery key</label></h2>
<p>Entering your recovery key will log you in and will disable 2FA.</p>
<p>You will be brought to the settings page in case you'd want to reconfigure it.</p>
{% if failure %}
<p class="form-error">Couldn't validate this key.</p>
{% endif %}
<input type="text" id="recovery_key" name="recovery_key" minlength="16" maxlength="16"
placeholder="xxxx-xxxxxx-xxxx" required />
<button type="submit">Log in</button>
</form>
{% endblock main %}

View file

@ -0,0 +1,11 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}Regenerate your recovery key - {% endblock title %}
{% block main %}
<p>Here's your new recovery key.</p>
<pre class="recovery-key"><code>{{recovery_key}}</code></pre>
<p>Make sure to save it somewhere safe, it may come in handy.</p>
<p><a href="/account/settings" class="btn">Finish and go back to settings</a></p>
{% endblock main %}

View file

@ -0,0 +1,29 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}Adding a one-time password - {% endblock title %}
{% block main %}
<p>To add a one-time password provider, scan this QrCode with your authenticator app.</p>
<section class="split">
<div class="totp-qrcode">
<img src="data:image/png;base64, {{totp_qrcode}}" alt="{{secret}}" />
<p class="note">Alternatively, you can copy/paste this code: <code>{{secret}}</code></p>
</div>
<div class="totp-form">
<p><label for="otp_code">
Once it's added on your authenticator app,
enter the generated 6-digit code.
</label></p>
<form method="post">
<input type="text" name="otp_code" id="otp_code" minlength="6" maxlength="6" required />
{% if invalid_code %}
<p class="field-error">The OTP code you sent was invalid, please retry.</p>
{% endif %}
<button type="submit" class="submit">Enable 2FA</button>
</form>
</div>
</section>
{% endblock main %}

View file

@ -66,6 +66,29 @@
</form>
</div>
<section id="otp">
<h3>Two-factor authentication</h3>
{% if "totp" in enabled_otp_methods %}
<p>You have enabled time-based 2FA, which will prompt you for a code on each login.</p>
<p>
You may <a href="/account/settings/totp/generate-key">regenerate a recovery key</a>
in case you lost your current one,
or <a href="/account/settings/totp/disable">disable TOTP 2FA altogether</a>.
</p>
{% endif %}
{% if enabled_otp_methods|length == 0 %}
<p>
You don't have two-factor auth enabled.<br />
You can add one using your authenticator app of choice by clicking below.
</p>
<a href="/account/settings/totp" class="btn">Enable 2FA with an authenticator</a>
{% endif %}
</section>
<section id="data-export">
<h3>Exporting your data</h3>

View file

@ -1,10 +1,11 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}{% if mode == "register" %}Register a new tag{% else %}Edit {{id}}{% endif %} - {% endblock title
{% block title %}{% if mode == "register" %}Register a new tag{% else %}Edit {{id|pretty_id}}{% endif %} - {% endblock
title
%}
{% block main %}
<h2>{% if mode == "register" %}Register a new tag{% else %}Edit {{id}}{% endif %}</h2>
<h2>{% if mode == "register" %}Register a new tag{% else %}Edit {{id|pretty_id}}{% endif %}</h2>
<aside>
<h3>A foreword</h3>
@ -205,13 +206,30 @@
</div>
</section>
<button class="submit" type="submit">
{% if mode == "register" %}
Register this tag!
{% else %}
Save your changes
{% endif %}
</button>
<div class="submit">
<div>
{% set tag_vis = previous.values | get(key="is_public", default=[]) %}
{% set is_public = tag_vis | length > 0 and tag_vis | first == "true" %}
<label for="is_public">Who can see the tag?</label>
<select name="is_public" id="is_public">
<option value="true">Everyone, it's public</option>
<option value="false" {% if is_public==false %}selected{% endif %}>No one, keep it private for now
</option>
</select>
{{form::error(ctx=previous, name="is_public")}}
</div>
<div>
<button class="submit" type="submit">
{% if mode == "register" %}
Register this tag!
{% else %}
Save your changes
{% endif %}
</button>
</div>
</div>
</form>
</section>