mirror of
https://github.com/Noratrieb/oh-oh.git
synced 2026-01-14 17:15:02 +01:00
2fa
This commit is contained in:
parent
0f46ff5a89
commit
c789f7ad15
10 changed files with 406 additions and 9 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
19
idp/migrations/20250712211103_totp.sql
Normal file
19
idp/migrations/20250712211103_totp.sql
Normal 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);
|
||||
112
idp/src/main.rs
112
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<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| {
|
||||
|
|
|
|||
|
|
@ -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
141
idp/src/totp.rs
Normal 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
27
idp/templates/2fa.html
Normal 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>
|
||||
47
idp/templates/add-totp.html
Normal file
47
idp/templates/add-totp.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue