This commit is contained in:
nora 2025-07-13 01:04:41 +02:00
parent 0f46ff5a89
commit c789f7ad15
10 changed files with 406 additions and 9 deletions

9
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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);

View file

@ -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<Db>) -> Result<impl IntoResponse, Response> {
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<totp::TotpDevice>,
}
Ok(Html(Template { devices }.render().unwrap()))
}
#[derive(Deserialize)]
struct Delete2faForm {
device_id: i64,
}
async fn delete_2fa(
user: UserSession,
State(db): State<Db>,
Form(form): Form<Delete2faForm>,
) -> Result<impl IntoResponse, Response> {
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<Db>,
Form(form): Form<AddTotpForm>,
) -> Result<impl IntoResponse, Response> {
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<Db>) -> Result<impl IntoResponse, Response> {
let users = users::all_user_names(&db).await.map_err(|err| {

View file

@ -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,

141
idp/src/totp.rs Normal file
View file

@ -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::<sha1::Sha1>::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<Vec<TotpDevice>> {
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(())
}

27
idp/templates/2fa.html Normal file
View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>2FA - IDP</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<h1>See 2FA devices for your account</h1>
<a href="/">home</a>
<ul>
{% for device in devices %}
<li>
{{device.name}} (added
{{jiff::Timestamp::from_millisecond(*device.created_time).unwrap()
.strftime("%A, %d %B %Y at %I:%M %Q")}})
<form method="post" action="/2fa/delete" class="fake-form">
<input type="hidden" name="device_id" value="{{device.id}}" />
<button type="submit">delete</button>
</form>
</li>
{% endfor %}
</ul>
</body>
</html>

View file

@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Add 2FA - IDP</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<h1>Add 2FA to your account</h1>
<a href="/">home</a>
<form method="post" action="/add-totp">
<p>
Copy this secret into your authenticator app and enter the code from it
below.
</p>
<p>
You can also add a name for your devide to help you identify it in the
future.
</p>
<dl>
<dt>2FA TOTP Secret</dt>
<dd>{{totp_secret}}</dd>
</dl>
<div>
<label for="name">Device name</label>
<input id="name" name="name" />
</div>
<div>
<label for="code">2FA Code</label>
<input id="code" name="code" />
</div>
<div style="display: none" aria-hidden="true">
<input id="secret" name="secret" value="{{totp_secret}}" />
</div>
<button type="submit">Confirm</button>
{% if error %}
<p>Invalid 2FA code</p>
{% endif %}
</form>
</body>
</html>

View file

@ -21,5 +21,13 @@
<div>
<a href="/users">List all users</a>
</div>
{% if let Some(username) = username %}
<div>
<a href="/add-totp">Add a new 2FA device to your account</a>
</div>
<div>
<a href="/2fa">List all 2FA devices for your account</a>
</div>
{% endif %}
</body>
</html>

View file

@ -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;
form + :not(.fake-form) {
display: flex;
flex-direction: column;
gap: 10px;
}
.fake-form {
display: inline-block;
}