diff --git a/Cargo.lock b/Cargo.lock index d07fa72..d2dcfb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,6 +205,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.22.1" @@ -864,12 +870,15 @@ dependencies = [ "askama", "axum", "axum-extra", + "base32", "color-eyre", "hex", + "hmac", "jiff", "password-hash", "rand_core", "serde", + "sha1", "sqlx", "time", "tokio", diff --git a/idp/Cargo.toml b/idp/Cargo.toml index 640634b..2ee4a24 100644 --- a/idp/Cargo.toml +++ b/idp/Cargo.toml @@ -8,12 +8,15 @@ argon2 = "0.5.3" askama = "0.14.0" axum = { version = "0.8.4", features = ["macros"] } axum-extra = { version = "0.10.1", features = ["cookie"] } +base32 = "0.5.1" color-eyre = "0.6.5" hex = "0.4.3" +hmac = "0.12.1" jiff = "0.2.15" password-hash = { version = "0.5.0" } rand_core = { version = "0.6.0", features = ["getrandom"] } serde = { version = "1.0.219", features = ["derive"] } +sha1 = "0.10.6" sqlx = { version = "0.8.6", features = ["runtime-tokio", "migrate", "macros", "sqlite"] } time = "0.3.41" tokio = { version = "1.46.1", features = ["full"] } diff --git a/idp/migrations/20250712211103_totp.sql b/idp/migrations/20250712211103_totp.sql new file mode 100644 index 0000000..6044a5f --- /dev/null +++ b/idp/migrations/20250712211103_totp.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS used_totp ( + user_id INTEGER, + code TEXT, + + PRIMARY KEY (user_id, code), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS totp_devices ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + secret TEXT NOT NULL, + created_time INTEGER NOT NULL, + name TEXT NOT NULL, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) STRICT; + +CREATE INDEX totp_devices_user_id ON totp_devices(user_id); diff --git a/idp/src/main.rs b/idp/src/main.rs index b9d3f53..4e4edc7 100644 --- a/idp/src/main.rs +++ b/idp/src/main.rs @@ -1,4 +1,5 @@ mod session; +mod totp; mod users; use std::str::FromStr; @@ -9,7 +10,7 @@ use axum::{ extract::State, http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, - routing::get, + routing::{get, post}, }; use axum_extra::extract::{ CookieJar, @@ -58,6 +59,9 @@ async fn main() -> Result<()> { .route("/", get(root)) .route("/signup", get(signup).post(signup_post)) .route("/login", get(login).post(login_post)) + .route("/2fa", get(list_2fa)) + .route("/2fa/delete", post(delete_2fa)) + .route("/add-totp", get(add_totp).post(add_totp_post)) .route("/users", get(users)) .with_state(Db { pool }); @@ -116,11 +120,117 @@ struct LoginTemplate { error: bool, } +#[derive(askama::Template)] +#[template(path = "add-totp.html")] +struct AddTotpTemplate { + totp_secret: String, + error: bool, +} + #[axum::debug_handler] async fn login() -> impl IntoResponse { Html(LoginTemplate { error: false }.render().unwrap()) } +async fn list_2fa(user: UserSession, State(db): State) -> Result { + let Some(user) = user.0 else { + return Err(Redirect::to("/").into_response()); + }; + + let devices = totp::list_totp_devices(&db, user.user_id) + .await + .map_err(|err| { + error!(?err, "Error fetching totp devices"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + #[derive(askama::Template)] + #[template(path = "2fa.html")] + struct Template { + devices: Vec, + } + + Ok(Html(Template { devices }.render().unwrap())) +} + +#[derive(Deserialize)] +struct Delete2faForm { + device_id: i64, +} + +async fn delete_2fa( + user: UserSession, + State(db): State, + Form(form): Form, +) -> Result { + let Some(user) = user.0 else { + return Err(Redirect::to("/").into_response()); + }; + + totp::delete_totp_device(&db, user.user_id, form.device_id) + .await + .map_err(|err| { + error!(?err, "Failed to delete totp device"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + Ok(Redirect::to("/2fa")) +} + +#[axum::debug_handler] +async fn add_totp() -> impl IntoResponse { + let secret = totp::generate_secret(); + + Html( + AddTotpTemplate { + totp_secret: secret, + error: false, + } + .render() + .unwrap(), + ) +} + +#[derive(Deserialize)] +struct AddTotpForm { + name: String, + code: String, + secret: String, +} + +async fn add_totp_post( + user: UserSession, + State(db): State, + Form(form): Form, +) -> Result { + let Some(user) = user.0 else { + return Err(Redirect::to("/").into_response()); + }; + + let computed = totp::Totp::compute(&form.secret, jiff::Timestamp::now().as_second() as u64); + + if computed.digits != form.code.trim() { + return Err(Html( + AddTotpTemplate { + totp_secret: form.secret, + error: true, + } + .render() + .unwrap(), + ) + .into_response()); + } + + totp::insert_totp_device(&db, user.user_id, form.secret, form.name) + .await + .map_err(|err| { + error!(?err, "Error inserting totp device"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + Ok(Redirect::to("/2fa")) +} + #[axum::debug_handler] async fn users(State(db): State) -> Result { let users = users::all_user_names(&db).await.map_err(|err| { diff --git a/idp/src/session.rs b/idp/src/session.rs index f05c086..f0f6ec2 100644 --- a/idp/src/session.rs +++ b/idp/src/session.rs @@ -11,8 +11,7 @@ use crate::Db; #[derive(Debug, sqlx::FromRow)] pub struct SessionWithUser { - #[expect(dead_code)] - pub user_id: i32, + pub user_id: i64, /// unix ms #[expect(dead_code)] pub created: i64, diff --git a/idp/src/totp.rs b/idp/src/totp.rs new file mode 100644 index 0000000..72c1648 --- /dev/null +++ b/idp/src/totp.rs @@ -0,0 +1,141 @@ +use color_eyre::{Result, eyre::Context}; +use rand_core::RngCore; + +pub use compute::Totp; + +use crate::Db; + +mod compute { + use hmac::Mac; + + pub struct Totp { + pub digits: String, + } + + struct TotpConfig { + time_step_s: u64, + digits: u32, + } + + fn hotp(key: &[u8], counter: u64, digits: u32) -> String { + // Step 1: Generate an HMAC-SHA-1 value Let HS = HMAC-SHA-1(K,C) + let hs = { + let mut hmac = hmac::Hmac::::new_from_slice(key).unwrap(); + hmac.update(&counter.to_be_bytes()); + + hmac.finalize().into_bytes() + }; + + // Step 2: Generate a 4-byte string (Dynamic Truncation) + let s = { + let offset = hs[19] & 0b1111; + let p = &hs[offset as usize..][..4]; + let p = u32::from_be_bytes(p.try_into().unwrap()); + p & !(1 << 31) + }; + + // Step 3: Compute an HOTP value + let s = s; + + let d = s % 10_u32.pow(digits); + format!("{d:0>width$}", width = digits as usize) + } + + impl Totp { + pub fn compute(secret: &str, unix_seconds: u64) -> Self { + let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, &secret) + .unwrap_or_default(); // nonsense secret will result in the wrong code as intended + + Self::compute_inner( + &secret, + unix_seconds, + TotpConfig { + time_step_s: 30, + digits: 6, + }, + ) + } + + fn compute_inner(secret: &[u8], unix_seconds: u64, config: TotpConfig) -> Self { + let time_step = unix_seconds / config.time_step_s; + let code = hotp(secret, time_step, config.digits); + Totp { digits: code } + } + } + + #[cfg(test)] + mod tests { + use super::TotpConfig; + + #[test] + fn test_vectors() { + let secret = b"12345678901234567890"; + + let tests = [ + (59, "94287082"), + (1111111109, "07081804"), + (1111111111, "14050471"), + ]; + + for test in tests { + let totp = super::Totp::compute_inner( + secret, + test.0, + TotpConfig { + time_step_s: 30, + digits: 8, + }, + ); + assert_eq!(totp.digits, test.1); + } + } + } +} + +pub fn generate_secret() -> String { + let mut bytes = [0_u8; 16]; // decided on by vibes lol + rand_core::OsRng.fill_bytes(&mut bytes); + base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &bytes) +} + +pub async fn insert_totp_device(db: &Db, user_id: i64, secret: String, name: String) -> Result<()> { + sqlx::query( + "insert into totp_devices (user_id, secret, created_time, name) VALUES (?, ?, ?, ?)", + ) + .bind(user_id) + .bind(secret) + .bind(jiff::Timestamp::now().as_millisecond()) + .bind(name) + .execute(&db.pool) + .await + .wrap_err("inserting totp device")?; + + Ok(()) +} + +#[derive(sqlx::FromRow)] +pub struct TotpDevice { + pub id: i64, + pub created_time: i64, + pub name: String, +} + +pub async fn list_totp_devices(db: &Db, user_id: i64) -> Result> { + sqlx::query_as::<_, TotpDevice>( + "select id, created_time, name from totp_devices where user_id = ?", + ) + .bind(user_id) + .fetch_all(&db.pool) + .await + .wrap_err("fetching totp devices") +} + +pub async fn delete_totp_device(db: &Db, user_id: i64, totp_device_id: i64) -> Result<()> { + sqlx::query("delete from totp_devices where id = ? and user_id = ?") + .bind(totp_device_id) + .bind(user_id) + .execute(&db.pool) + .await + .wrap_err("failed to delete totp device")?; + Ok(()) +} diff --git a/idp/templates/2fa.html b/idp/templates/2fa.html new file mode 100644 index 0000000..bd7117e --- /dev/null +++ b/idp/templates/2fa.html @@ -0,0 +1,27 @@ + + + + + + 2FA - IDP + + + +

See 2FA devices for your account

+ home + +
    + {% for device in devices %} +
  • + {{device.name}} (added + {{jiff::Timestamp::from_millisecond(*device.created_time).unwrap() + .strftime("%A, %d %B %Y at %I:%M %Q")}}) +
    + + +
    +
  • + {% endfor %} +
+ + diff --git a/idp/templates/add-totp.html b/idp/templates/add-totp.html new file mode 100644 index 0000000..81f1f73 --- /dev/null +++ b/idp/templates/add-totp.html @@ -0,0 +1,47 @@ + + + + + + Add 2FA - IDP + + + +

Add 2FA to your account

+ home +
+

+ Copy this secret into your authenticator app and enter the code from it + below. +

+

+ You can also add a name for your devide to help you identify it in the + future. +

+
+
2FA TOTP Secret
+
{{totp_secret}}
+
+ +
+ + +
+ +
+ + +
+ + + + + + {% if error %} +

Invalid 2FA code

+ {% endif %} +
+ + diff --git a/idp/templates/index.html b/idp/templates/index.html index 13cbb05..9ec7118 100644 --- a/idp/templates/index.html +++ b/idp/templates/index.html @@ -21,5 +21,13 @@ + {% if let Some(username) = username %} + + + {% endif %} diff --git a/idp/templates/style.css b/idp/templates/style.css index a8ec5c0..0d5395d 100644 --- a/idp/templates/style.css +++ b/idp/templates/style.css @@ -3,6 +3,36 @@ body { width: 100%; } +@media (prefers-color-scheme: dark) { + body { + background-color: rgb(41, 41, 41); + color: white; + } + + a { + color: lightblue; + } + + input { + background-color: rgb(41, 41, 41); + border: 1px solid gray; + height: 1.8rem; + color: white; + } +} + +button { + background-color: coral; + border-width: 0; + + &:hover { + background-color: color-mix(in oklab, coral 80%, black 20%); + cursor: pointer; + } + + height: 2rem; +} + body { display: flex; flex-direction: column; @@ -11,11 +41,15 @@ body { /* Home Link */ a[href="/"] { - margin-bottom: 20px; + margin-bottom: 20px; } -form { - display: flex; - flex-direction: column; - gap: 10px; -} \ No newline at end of file +form + :not(.fake-form) { + display: flex; + flex-direction: column; + gap: 10px; +} + +.fake-form { + display: inline-block; +}