Allow two-factor sign-in in either order again

The review fixes made two-factor sign-in always start with the
password. That simplification was not wanted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitddf5a9599f9b68143b34d1efdd4f6c077f78b5ae
Parent917b640
5 files changed, 61 insertions(+), 14 deletions(-)
▾MREADME.md
@@ -59,7 +59,7 @@ The server updates the database schema at start. Back up the database file befor
- The first visit to a server with no users shows a setup form for the admin account. Anyone who reaches the server first can claim it, so set it up before you expose it.
- Admins add and delete users, change their role and reset passwords under Settings → Users. Admins cannot change their own role, so one admin always remains.
- Each user picks a sign-in mode under Settings → Security: password **or** passkey, or password **and** passkey (two-factor). Two-factor sign-in starts with the password. A user with a passkey can remove the password.
- Each user picks a sign-in mode under Settings → Security: password **or** passkey, or password **and** passkey (two-factor), in either order. A user with a passkey can remove the password.
- Wrong passwords are limited per client address: 5 per username and 30 across all usernames, then a 15-minute lockout.
- Devices sign in with a token. The Android app gets one by signing in through the browser. For other clients, create one under Settings → Devices.
@@ -101,7 +101,7 @@ Web endpoints use the session cookie. Devices use `Authorization: Bearer <token>
|---|---|---|
| `GET/POST /api/setup` | none | first-boot admin account |
| `POST /api/login`, `/api/logout` | password | can answer with a passkey challenge (two-factor) |
| `POST /api/passkey/login[/finish]` | none | passkey sign-in. Two-factor accounts start with `/api/login` instead |
| `POST /api/passkey/login[/finish]` | none | passkey sign-in. Can ask for the password next (two-factor) |
| `GET /api/me` | session | |
| `POST/DELETE /api/me/password`, `PUT /api/me/two-factor`, `PUT /api/me/retention` | session | |
| `GET /api/passkeys`, `POST /api/passkeys/register[/finish]`, `DELETE /api/passkeys/{id}` | session | |
▾Mcrates/api/src/lib.rs
@@ -86,6 +86,9 @@ pub struct Login {
#[serde(default)]
pub username: String,
pub password: String,
/// Set when a passkey sign-in asked for the password as the second step.
#[serde(default)]
pub state_id: Option<String>,
}
/// Reply to a sign-in step. `ok` means the session cookie is set.
@@ -95,6 +98,9 @@ pub struct LoginResult {
/// The password was right. The account also needs a passkey.
#[serde(default)]
pub passkey_challenge: Option<Challenge>,
/// The passkey was right. The account also needs its password, sent with this state_id.
#[serde(default)]
pub password_required: Option<String>,
}
/// The first leg of a WebAuthn ceremony.
▾Mcrates/server/src/passkeys.rs
@@ -38,6 +38,10 @@ pub enum Pending {
user_id: i64,
state: Box<PasskeyAuthentication>,
},
/// A passkey passed. The account also needs its password.
NeedsPassword {
user_id: i64,
},
}
/// WebAuthn takes two requests. This holds what the second one needs, keyed by a handle the client echoes.
@@ -223,6 +227,7 @@ pub fn second_factor(
Ok(Json(LoginResult {
ok: false,
passkey_challenge: Some(ch),
..Default::default()
})
.into_response())
}
@@ -288,10 +293,13 @@ pub async fn login_finish(
)?;
drop(db);
if two_factor && !password_done {
return Err(Error::BadRequest(
"this account needs its password and a passkey. Sign in with your password first."
.into(),
));
let state_id = s.ceremonies.put(Pending::NeedsPassword { user_id });
return Ok(Json(LoginResult {
ok: false,
password_required: Some(state_id),
..Default::default()
})
.into_response());
}
sign_in(&s, user_id)
}
▾Mcrates/server/src/routes.rs
@@ -16,7 +16,7 @@ use serde::Deserialize;
use tower_http::services::ServeDir;
use crate::auth::{self, Admin, ClientIp, User};
use crate::passkeys;
use crate::passkeys::{self, Pending};
use crate::{AppState, Error, now};
use crate::{device, guest};
@@ -112,11 +112,28 @@ async fn login(
headers: HeaderMap,
Json(b): Json<Login>,
) -> Result<Response> {
let ok = auth::check_password(&s, ip, b.username.trim(), &b.password).await?;
if ok.two_factor {
return passkeys::second_factor(&s, &uri, &headers, ok.id);
match &b.state_id {
// The passkey already passed. This is the password step of a two-factor sign-in.
Some(state_id) => {
let Some(Pending::NeedsPassword { user_id }) = s.ceremonies.take(state_id) else {
return Err(passkeys::expired());
};
let username: String =
s.db()
.query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| {
r.get(0)
})?;
let ok = auth::check_password(&s, ip, &username, &b.password).await?;
passkeys::sign_in(&s, ok.id)
}
None => {
let ok = auth::check_password(&s, ip, b.username.trim(), &b.password).await?;
if ok.two_factor {
return passkeys::second_factor(&s, &uri, &headers, ok.id);
}
passkeys::sign_in(&s, ok.id)
}
}
passkeys::sign_in(&s, ok.id)
}
async fn logout(State(s): State<AppState>, user: User) -> Result<impl IntoResponse> {
▾Mweb/src/main.rs
@@ -161,8 +161,10 @@ fn LoginForm() -> impl IntoView {
let password = RwSignal::new(String::new());
let error = RwSignal::new(None::<String>);
let busy = RwSignal::new(false);
// Set when a passkey passed and the account also needs its password.
let password_step = RwSignal::new(None::<String>);
// Follows a sign-in step to its end: a session, or a passkey prompt after the password.
// Follows a sign-in step to its end: a session, a passkey prompt, or a password prompt.
let handle = move |result: Result<LoginResult, String>| {
spawn_local(async move {
let mut result = result;
@@ -175,6 +177,14 @@ fn LoginForm() -> impl IntoView {
}
match result {
Ok(r) if r.ok => account.reload(),
Ok(LoginResult {
password_required: Some(state_id),
..
}) => {
password.set(String::new());
password_step.set(Some(state_id));
error.set(None);
}
Ok(_) => error.set(Some("Sign-in failed.".into())),
Err(e) => error.set(Some(e)),
}
@@ -188,6 +198,7 @@ fn LoginForm() -> impl IntoView {
let body = Login {
username: username.get_untracked(),
password: password.get_untracked(),
state_id: password_step.get_untracked(),
};
spawn_local(async move {
let result = http::post::<LoginResult>("/api/login", &body)
@@ -215,13 +226,18 @@ fn LoginForm() -> impl IntoView {
view! {
<form class="login" on:submit=submit>
<h1>"opentracker"</h1>
<label>"Username" <input autocomplete="username webauthn" required bind:value=username /></label>
<Show
when=move || password_step.get().is_none()
fallback=move || view! { <p>"This account also needs its password."</p> }
>
<label>"Username" <input autocomplete="username webauthn" required bind:value=username /></label>
</Show>
<label>
"Password"
<input type="password" autocomplete="current-password" required bind:value=password />
</label>
<button class="primary" disabled=busy>"Log in"</button>
<Show when=passkey::supported>
<Show when=move || passkey::supported() && password_step.get().is_none()>
<button type="button" disabled=busy on:click=with_passkey>"Sign in with a passkey"</button>
</Show>
<p class="error">{move || error.get()}</p>