Files
wt cf25e28c28
Rust verification / test (push) Successful in 4m10s
initial commit
2026-10-28 15:59:54 +07:00

1201 lines
48 KiB
Rust

//! WebAuthn ceremonies with server-side, expiring, one-use challenges.
//! Existing Laragear encrypted PEM credentials are imported without reenrolment.
use crate::{ApiError, AppState, auth};
use axum::{
Extension, Json, Router,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, patch, post},
};
use base64::{
Engine,
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
};
use serde_json::{Value, json};
use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
use std::time::Duration;
use webauthn_rs::prelude::*;
use webauthn_rs_core::proto::{
COSEAlgorithm, COSEEC2Key, COSEKey, COSEKeyType, COSEOKPKey, COSERSAKey, CredentialV3,
ECDSACurve, EDDSACurve, UserVerificationPolicy,
};
type ResultResponse = Result<Response, ApiError>;
const MODEL: &str = "App\\Models\\User";
fn invalid() -> ApiError {
ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"The given data was invalid.",
)
}
fn denied() -> ApiError {
ApiError(StatusCode::UNAUTHORIZED, "unauthorized")
}
fn broken() -> ApiError {
ApiError(
StatusCode::SERVICE_UNAVAILABLE,
"WebAuthn configuration is unavailable",
)
}
fn encoded(bytes: impl AsRef<[u8]>) -> String {
URL_SAFE_NO_PAD.encode(bytes)
}
fn decoded(s: &str) -> Result<Vec<u8>, ApiError> {
URL_SAFE_NO_PAD
.decode(s.trim_end_matches('='))
.or_else(|_| STANDARD.decode(s))
.map_err(|_| invalid())
}
pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::raw_sql("CREATE TABLE IF NOT EXISTS rust_webauthn_challenges(session_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS rust_webauthn_metadata(credential_id TEXT PRIMARY KEY REFERENCES webauthn_credentials(id) ON DELETE CASCADE,credential TEXT NOT NULL);").execute(db).await?;
Ok(())
}
pub fn routes(_state: AppState) -> Router<AppState> {
Router::new()
.route("/webauthn/register/options", post(register_options))
.route("/webauthn/register", post(register))
.route("/webauthn/login/options", post(login_options))
.route("/webauthn/login", post(login))
.route("/webauthn/credentials", get(list))
.route("/webauthn/credentials/{id}/name", patch(rename))
.route("/webauthn/credentials/{id}", axum::routing::delete(delete))
.route("/webauthn/lost", post(lost))
.route("/webauthn/recover", post(recover))
}
fn required() -> bool {
std::env::var("WEBAUTHN_USER_VERIFICATION").as_deref() == Ok("required")
}
fn webauthn() -> Result<Webauthn, ApiError> {
let origin =
Url::parse(&std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost".into()))
.map_err(|_| broken())?;
let rp = std::env::var("WEBAUTHN_ID")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| origin.host_str().unwrap_or("localhost").into());
let rp = Url::parse(&rp)
.ok()
.and_then(|u| u.host_str().map(str::to_owned))
.unwrap_or(rp);
let name = std::env::var("WEBAUTHN_NAME")
.or_else(|_| std::env::var("APP_NAME"))
.unwrap_or_else(|_| "2FAuth".into());
let mut builder = WebauthnBuilder::new(&rp, &origin)
.map_err(|_| broken())?
.rp_name(&name)
.timeout(Duration::from_secs(60));
for origin in std::env::var("WEBAUTHN_ORIGINS")
.unwrap_or_default()
.split(',')
.filter(|s| !s.trim().is_empty())
{
builder = builder.append_allowed_origin(&Url::parse(origin.trim()).map_err(|_| broken())?);
}
if std::env::var("WEBAUTHN_USER_VERIFICATION").as_deref() == Ok("discouraged") {
builder = builder.danger_set_user_presence_only_security_keys(true);
}
builder.build().map_err(|_| broken())
}
async fn session(s: &AppState, h: &HeaderMap, mutation: bool) -> Result<SqliteRow, ApiError> {
let row = auth::session(&s.db, h).await?.ok_or(ApiError(
StatusCode::from_u16(419).unwrap(),
"Session expired",
))?;
if mutation && !auth::csrf_valid(h, row.try_get("csrf_hash")?) {
return Err(ApiError(
StatusCode::from_u16(419).unwrap(),
"CSRF token mismatch",
));
}
Ok(row)
}
async fn manage(s: &AppState, h: &HeaderMap, mutation: bool) -> Result<SqliteRow, ApiError> {
if std::env::var("AUTH_GUARD")
.or_else(|_| std::env::var("AUTHENTICATION_GUARD"))
.as_deref()
== Ok("reverse-proxy-guard")
{
return Err(ApiError(StatusCode::FORBIDDEN, "Forbidden"));
}
let row = session(s, h, mutation).await?;
let Some(id) = row.try_get::<Option<i64>, _>("user_id")? else {
return Err(denied());
};
let user = sqlx::query("SELECT * FROM users WHERE id=?")
.bind(id)
.fetch_optional(&s.db)
.await?
.ok_or_else(denied)?;
if !user.try_get::<bool, _>("is_admin")?
&& crate::settings::all(&s.db).await?["useSsoOnly"] == true
{
return Err(ApiError(
StatusCode::FORBIDDEN,
"This authentication method is for administrators only. Users must log in with SSO.",
));
}
Ok(user)
}
async fn save_challenge(
s: &AppState,
h: &HeaderMap,
user: i64,
kind: &str,
payload: Value,
) -> Result<(), ApiError> {
let session = session(s, h, true).await?;
sqlx::query("DELETE FROM rust_webauthn_challenges WHERE expires_at<?")
.bind(auth::now())
.execute(&s.db)
.await?;
sqlx::query("INSERT INTO rust_webauthn_challenges(session_hash,user_id,kind,payload,expires_at) VALUES(?,?,?,?,?) ON CONFLICT(session_hash) DO UPDATE SET user_id=excluded.user_id,kind=excluded.kind,payload=excluded.payload,expires_at=excluded.expires_at")
.bind(session.try_get::<String,_>("token_hash")?).bind(user).bind(kind).bind(payload.to_string()).bind(auth::now()+60).execute(&s.db).await?;
Ok(())
}
async fn take_challenge(s: &AppState, h: &HeaderMap, kind: &str) -> Result<(i64, Value), ApiError> {
let session = session(s, h, true).await?;
let row=sqlx::query("DELETE FROM rust_webauthn_challenges WHERE session_hash=? AND kind=? AND expires_at>? RETURNING user_id,payload")
.bind(session.try_get::<String,_>("token_hash")?).bind(kind).bind(auth::now()).fetch_optional(&s.db).await?.ok_or_else(denied)?;
Ok((
row.try_get("user_id")?,
serde_json::from_str(row.try_get("payload")?).map_err(|_| denied())?,
))
}
fn pem_key(pem: &str) -> Result<COSEKey, ApiError> {
use openssl::{
bn::{BigNum, BigNumContext},
nid::Nid,
pkey::{Id, PKey},
};
let key = PKey::public_key_from_pem(pem.as_bytes()).map_err(|_| broken())?;
Ok(match key.id() {
Id::EC => {
let ec = key.ec_key().map_err(|_| broken())?;
let (curve, algorithm, length) = match ec.group().curve_name() {
Some(Nid::X9_62_PRIME256V1) => (ECDSACurve::SECP256R1, COSEAlgorithm::ES256, 32),
Some(Nid::SECP384R1) => (ECDSACurve::SECP384R1, COSEAlgorithm::ES384, 48),
Some(Nid::SECP521R1) => (ECDSACurve::SECP521R1, COSEAlgorithm::ES512, 66),
_ => return Err(broken()),
};
let mut x = BigNum::new().map_err(|_| broken())?;
let mut y = BigNum::new().map_err(|_| broken())?;
let mut ctx = BigNumContext::new().map_err(|_| broken())?;
ec.public_key()
.affine_coordinates_gfp(ec.group(), &mut x, &mut y, &mut ctx)
.map_err(|_| broken())?;
COSEKey {
type_: algorithm,
key: COSEKeyType::EC_EC2(COSEEC2Key {
curve,
x: x.to_vec_padded(length).map_err(|_| broken())?.into(),
y: y.to_vec_padded(length).map_err(|_| broken())?.into(),
}),
}
}
Id::RSA => {
let rsa = key.rsa().map_err(|_| broken())?;
let e: [u8; 3] = rsa.e().to_vec().try_into().map_err(|_| broken())?;
COSEKey {
type_: COSEAlgorithm::RS256,
key: COSEKeyType::RSA(COSERSAKey {
n: rsa.n().to_vec().into(),
e,
}),
}
}
Id::ED25519 => COSEKey {
type_: COSEAlgorithm::EDDSA,
key: COSEKeyType::EC_OKP(COSEOKPKey {
curve: EDDSACurve::ED25519,
x: key.raw_public_key().map_err(|_| broken())?.into(),
}),
},
_ => return Err(broken()),
})
}
async fn credential(s: &AppState, row: &SqliteRow) -> Result<Credential, ApiError> {
let id: String = row.try_get("id")?;
let stored: String = row.try_get("public_key")?;
// Laragear's encrypted cast is unconditional, regardless of the account encryption switch.
let pem = s
.cipher
.as_ref()
.ok_or_else(broken)?
.decrypt(&stored)
.map_err(|_| broken())?;
let key = pem_key(&pem)?;
let counter = row.try_get::<Option<i64>, _>("counter")?.unwrap_or(0);
let mut cred: Credential = CredentialV3 {
cred_id: decoded(&id)?,
cred: key.clone(),
counter: counter.try_into().map_err(|_| broken())?,
verified: false,
registration_policy: UserVerificationPolicy::Preferred,
}
.into();
if let Some(raw) = sqlx::query_scalar::<_, String>(
"SELECT credential FROM rust_webauthn_metadata WHERE credential_id=?",
)
.bind(&id)
.fetch_optional(&s.db)
.await?
{
cred = serde_json::from_str(&raw).map_err(|_| broken())?;
// The Laravel table remains authoritative for key, counter and status.
cred.cred = key;
cred.counter = counter.try_into().map_err(|_| broken())?;
}
// Laragear does not bind future assertions to the registration's UV flag.
// Enforce the configured ceremony policy separately rather than requiring UV
// just because this authenticator happened to verify a user during enrollment.
cred.user_verified = false;
cred.registration_policy = UserVerificationPolicy::Preferred;
cred.transports = row
.try_get::<Option<String>, _>("transports")?
.and_then(|s| serde_json::from_str(&s).ok());
Ok(cred)
}
async fn rows(s: &AppState, user: i64) -> Result<Vec<SqliteRow>, ApiError> {
Ok(sqlx::query("SELECT * FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=? AND disabled_at IS NULL ORDER BY created_at,id").bind(user).bind(MODEL).fetch_all(&s.db).await?)
}
fn options_value<T: serde::Serialize>(value: T) -> Result<Value, ApiError> {
let mut v = serde_json::to_value(value).map_err(|_| broken())?["publicKey"].take();
if required() {
if v.get("authenticatorSelection").is_some() {
v["authenticatorSelection"]["userVerification"] = json!("required");
} else {
v["userVerification"] = json!("required");
}
}
Ok(v)
}
async fn register_options(State(s): State<AppState>, h: HeaderMap) -> ResultResponse {
let user = manage(&s, &h, true).await?;
let id: i64 = user.try_get("id")?;
let existing = rows(&s, id).await?;
let handle = existing
.first()
.and_then(|r| r.try_get::<String, _>("user_id").ok())
.and_then(|s| Uuid::parse_str(&s).ok())
.unwrap_or_else(Uuid::new_v4);
let exclude = existing
.iter()
.map(|row| decoded(&row.get::<String, _>("id")).map(Into::into))
.collect::<Result<Vec<_>, _>>()?;
let (options, state) = webauthn()?
.start_securitykey_registration(
handle,
user.try_get("email")?,
user.try_get("name")?,
Some(exclude),
None,
None,
)
.map_err(|_| invalid())?;
let mut options = options_value(options)?;
let mut state = serde_json::to_value(state).map_err(|_| broken())?;
// Laragear offers Ed25519 alongside ES256 and RS256. The library supports
// EdDSA verification but excludes it from its default advertised list.
state["rs"]["credential_algorithms"]
.as_array_mut()
.ok_or_else(broken)?
.push(json!("EDDSA"));
options["pubKeyCredParams"]
.as_array_mut()
.ok_or_else(broken)?
.push(json!({"type":"public-key","alg":-8}));
save_challenge(
&s,
&h,
id,
"register",
json!({"state":state,"handle":handle,"required":required()}),
)
.await?;
Ok(Json(options).into_response())
}
async fn register(State(s): State<AppState>, h: HeaderMap, Json(v): Json<Value>) -> ResultResponse {
let user = manage(&s, &h, true).await?;
let (id, payload) = take_challenge(&s, &h, "register").await?;
if id != user.try_get::<i64, _>("id")? {
return Err(denied());
}
let response: RegisterPublicKeyCredential =
serde_json::from_value(v.clone()).map_err(|_| invalid())?;
let state: SecurityKeyRegistration =
serde_json::from_value(payload["state"].clone()).map_err(|_| denied())?;
let key = webauthn()?
.finish_securitykey_registration(&response, &state)
.map_err(|_| invalid())?;
let cred: Credential = key.into();
if decoded(&response.id)?.as_slice() != cred.cred_id.as_slice()
|| response.raw_id.as_slice() != cred.cred_id.as_slice()
{
return Err(invalid());
}
if payload["required"] == true && !cred.user_verified {
return Err(invalid());
}
let pem = cred
.cred
.get_openssl_pkey()
.map_err(|_| invalid())?
.public_key_to_pem()
.map_err(|_| broken())?;
let pem = String::from_utf8(pem).map_err(|_| broken())?;
let encrypted = s
.cipher
.as_ref()
.ok_or_else(broken)?
.encrypt(&pem)
.map_err(|_| broken())?;
let client: Value = serde_json::from_slice(&decoded(
v["response"]["clientDataJSON"]
.as_str()
.ok_or_else(invalid)?,
)?)
.map_err(|_| invalid())?;
let origin = client["origin"].as_str().ok_or_else(invalid)?;
let rp = std::env::var("WEBAUTHN_ID")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
Url::parse(origin)
.ok()
.and_then(|u| u.host_str().map(str::to_owned))
.unwrap_or_default()
});
let raw_attestation = decoded(
v["response"]["attestationObject"]
.as_str()
.ok_or_else(invalid)?,
)?;
let attestation: serde_cbor_2::Value =
serde_cbor_2::from_slice(&raw_attestation).map_err(|_| invalid())?;
let mut aaguid = None;
if let serde_cbor_2::Value::Map(map) = attestation
&& let Some(serde_cbor_2::Value::Bytes(data)) =
map.get(&serde_cbor_2::Value::Text("authData".into()))
{
aaguid = data
.get(37..53)
.and_then(|b| Uuid::from_slice(b).ok())
.map(|u| u.to_string());
}
let credential_id = encoded(&cred.cred_id);
let mut tx = s.db.begin().await?;
let duplicate: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM webauthn_credentials WHERE id=?)")
.bind(&credential_id)
.fetch_one(&mut *tx)
.await?;
if duplicate {
return Err(invalid());
}
sqlx::query("INSERT INTO webauthn_credentials(id,authenticatable_type,authenticatable_id,user_id,alias,counter,rp_id,origin,transports,aaguid,public_key,attestation_format,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)")
.bind(&credential_id).bind(MODEL).bind(id).bind(payload["handle"].as_str().ok_or_else(denied)?).bind(v["response"]["alias"].as_str()).bind(cred.counter as i64).bind(rp).bind(origin).bind(cred.transports.as_ref().map(|t|serde_json::to_string(t).unwrap())).bind(aaguid).bind(encrypted).bind(serde_json::to_value(&cred.attestation_format).map_err(|_|broken())?.as_str().unwrap_or("none")).execute(&mut *tx).await?;
sqlx::query("INSERT INTO rust_webauthn_metadata(credential_id,credential) VALUES(?,?)")
.bind(&credential_id)
.bind(serde_json::to_string(&cred).map_err(|_| broken())?)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
async fn login_options(
State(s): State<AppState>,
h: HeaderMap,
Json(v): Json<Value>,
) -> ResultResponse {
session(&s, &h, true).await?;
let email = v["email"]
.as_str()
.filter(|s| s.contains('@'))
.ok_or_else(invalid)?;
let user = sqlx::query("SELECT id,is_admin FROM users WHERE LOWER(email)=LOWER(?)")
.bind(email)
.fetch_optional(&s.db)
.await?
.ok_or_else(invalid)?;
let id: i64 = user.try_get("id")?;
if crate::settings::all(&s.db).await?["useSsoOnly"] == true
&& !user.try_get::<bool, _>("is_admin")?
{
return Err(ApiError(
StatusCode::FORBIDDEN,
"This authentication method is for administrators only. Users must log in with SSO.",
));
}
let mut credentials = vec![];
for row in rows(&s, id).await? {
credentials.push(SecurityKey::from(credential(&s, &row).await?));
}
let (options, state) = webauthn()?
.start_securitykey_authentication(&credentials)
.map_err(|_| invalid())?;
let mut state = serde_json::to_value(state).map_err(|_| broken())?;
// Laragear does not persist backup eligibility. Permit the same one-way eligibility
// upgrade as the library's passkey API, while preserving all signature/UV checks.
state["ast"]["allow_backup_eligible_upgrade"] = json!(true);
save_challenge(
&s,
&h,
id,
"login",
json!({"state":state,"required":required()}),
)
.await?;
Ok(Json(options_value(options)?).into_response())
}
async fn login(
State(s): State<AppState>,
h: HeaderMap,
peer: Option<Extension<axum::extract::ConnectInfo<std::net::SocketAddr>>>,
Json(v): Json<Value>,
) -> ResultResponse {
let context = crate::auth_audit::Context::from_headers(
&h,
peer.map(|Extension(axum::extract::ConnectInfo(peer))| peer),
);
let old = session(&s, &h, true).await?;
let (id, payload) = take_challenge(&s, &h, "login").await?;
if !auth::throttle_limit(&s.db, format!("webauthn-login:{id}"), 10).await? {
return Err(ApiError(
StatusCode::TOO_MANY_REQUESTS,
"Too Many Attempts.",
));
}
let user = sqlx::query("SELECT * FROM users WHERE id=? AND LOWER(email)=LOWER(?)")
.bind(id)
.bind(v["email"].as_str().ok_or_else(invalid)?)
.fetch_optional(&s.db)
.await?
.ok_or_else(denied)?;
let admin: bool = user.try_get("is_admin")?;
let settings = crate::settings::regular_settings(&s.db).await?;
if settings["useSsoOnly"] == true && !admin {
return Err(ApiError(
StatusCode::FORBIDDEN,
"This authentication method is for administrators only. Users must log in with SSO.",
));
}
let response: PublicKeyCredential = serde_json::from_value(v.clone()).map_err(|_| invalid())?;
let credential_id = encoded(decoded(v["id"].as_str().ok_or_else(invalid)?)?);
if response.raw_id.as_slice() != decoded(&credential_id)?.as_slice() {
return Err(invalid());
}
let row=sqlx::query("SELECT * FROM webauthn_credentials WHERE id=? AND authenticatable_id=? AND authenticatable_type=? AND disabled_at IS NULL").bind(&credential_id).bind(id).bind(MODEL).fetch_optional(&s.db).await?.ok_or_else(denied)?;
if let Some(handle) = v["response"]["userHandle"]
.as_str()
.filter(|s| !s.is_empty())
{
let uuid = Uuid::parse_str(row.try_get("user_id")?).map_err(|_| denied())?;
if decoded(handle)?.as_slice() != uuid.as_bytes() {
crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?;
return Err(denied());
}
}
let state: SecurityKeyAuthentication =
serde_json::from_value(payload["state"].clone()).map_err(|_| denied())?;
let result = match webauthn()?.finish_securitykey_authentication(&response, &state) {
Ok(result) => result,
Err(_) => {
crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?;
return Err(denied());
}
};
if payload["required"] == true && !result.user_verified() {
crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?;
return Err(denied());
}
let counter: i64 = row.try_get::<Option<i64>, _>("counter")?.unwrap_or(0);
if (counter > 0 || result.counter() > 0) && result.counter() as i64 <= counter {
crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?;
return Err(denied());
}
let mut updated_credential = credential(&s, &row).await?;
updated_credential.counter = result.counter();
updated_credential.backup_eligible = result.backup_eligible();
updated_credential.backup_state = result.backup_state();
let sid = auth::random();
let csrf = auth::random();
let mut tx = s.db.begin().await?;
let updated=sqlx::query("UPDATE webauthn_credentials SET counter=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND disabled_at IS NULL AND COALESCE(counter,0)=?").bind(result.counter() as i64).bind(&credential_id).bind(counter).execute(&mut *tx).await?;
if updated.rows_affected() != 1 {
return Err(denied());
}
sqlx::query("INSERT INTO rust_webauthn_metadata(credential_id,credential) VALUES(?,?) ON CONFLICT(credential_id) DO UPDATE SET credential=excluded.credential").bind(&credential_id).bind(serde_json::to_string(&updated_credential).map_err(|_|broken())?).execute(&mut *tx).await?;
let consumed = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?")
.bind(old.try_get::<String, _>("token_hash")?)
.bind(auth::now())
.execute(&mut *tx)
.await?;
if consumed.rows_affected() != 1 {
return Err(denied());
}
sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)",
)
.bind(auth::digest(&sid))
.bind(id)
.bind(auth::digest(&csrf))
.bind(auth::now() + auth::TTL)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE users SET last_seen_at=CURRENT_TIMESTAMP WHERE id=?")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
crate::auth_audit::login(&s, id, &context, Some("webauthn")).await?;
let preferences = crate::settings::preferences_from(
user.try_get::<Option<String>, _>("preferences")?.as_deref(),
);
let mut response=Json(json!({"message":"authenticated","id":id,"name":user.try_get::<String,_>("name")?,"email":user.try_get::<String,_>("email")?,"oauth_provider":user.try_get::<Option<String>,_>("oauth_provider")?,"is_admin":admin,"preferences":preferences,"appSettings":settings})).into_response();
auth::cookies(&mut response, &sid, &csrf, auth::TTL);
auth::clear_legacy_cookie(&mut response);
Ok(response)
}
async fn list(State(s): State<AppState>, h: HeaderMap) -> ResultResponse {
let user = manage(&s, &h, false).await?;
let mut values = vec![];
for row in rows(&s, user.try_get("id")?).await? {
values.push(json!({"id":row.try_get::<String,_>("id")?,"origin":row.try_get::<String,_>("origin")?,"alias":row.try_get::<Option<String>,_>("alias")?,"aaguid":row.try_get::<Option<String>,_>("aaguid")?,"attestation_format":row.try_get::<String,_>("attestation_format")?,"disabled_at":null}));
}
Ok(Json(json!(values)).into_response())
}
async fn rename(
State(s): State<AppState>,
h: HeaderMap,
Path(id): Path<String>,
Json(v): Json<Value>,
) -> ResultResponse {
let user = manage(&s, &h, true).await?;
let name = v["name"]
.as_str()
.filter(|s| !s.trim().is_empty())
.ok_or_else(invalid)?;
let n=sqlx::query("UPDATE webauthn_credentials SET alias=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND authenticatable_id=? AND authenticatable_type=?").bind(name).bind(id).bind(user.try_get::<i64,_>("id")?).bind(MODEL).execute(&s.db).await?;
if n.rows_affected() == 0 {
return Err(ApiError(StatusCode::NOT_FOUND, "Not Found"));
}
Ok(Json(json!({"name":name})).into_response())
}
async fn delete(State(s): State<AppState>, h: HeaderMap, Path(id): Path<String>) -> ResultResponse {
let user = manage(&s, &h, true).await?;
let user_id: i64 = user.try_get("id")?;
let mut tx = s.db.begin().await?;
sqlx::query("DELETE FROM webauthn_credentials WHERE id=? AND authenticatable_id=? AND authenticatable_type=?").bind(id).bind(user_id).bind(MODEL).execute(&mut *tx).await?;
let remains:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=? AND disabled_at IS NULL)").bind(user_id).bind(MODEL).fetch_one(&mut *tx).await?;
if !remains {
let mut prefs = crate::settings::preferences_from(
user.try_get::<Option<String>, _>("preferences")?.as_deref(),
);
prefs["useWebauthnOnly"] = json!(false);
sqlx::query("UPDATE users SET preferences=?,updated_at=CURRENT_TIMESTAMP WHERE id=?")
.bind(prefs.to_string())
.bind(user_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
async fn recovery_user(s: &AppState, v: &Value) -> Result<SqliteRow, ApiError> {
if std::env::var("IS_DEMO_APP").is_ok_and(|v| matches!(v.as_str(), "true" | "1")) {
return Err(ApiError(StatusCode::FORBIDDEN, "Forbidden"));
}
let email = v["email"]
.as_str()
.filter(|s| s.contains('@'))
.ok_or_else(invalid)?;
let user = sqlx::query("SELECT * FROM users WHERE LOWER(email)=LOWER(?)")
.bind(email)
.fetch_optional(&s.db)
.await?
.ok_or_else(invalid)?;
if crate::settings::all(&s.db).await?["useSsoOnly"] == true
&& !user.try_get::<bool, _>("is_admin")?
{
return Err(ApiError(
StatusCode::FORBIDDEN,
"This authentication method is for administrators only. Users must log in with SSO.",
));
}
Ok(user)
}
async fn lost(State(s): State<AppState>, h: HeaderMap, Json(v): Json<Value>) -> ResultResponse {
session(&s, &h, true).await?;
let user = recovery_user(&s, &v).await?;
let email: String = user.try_get("email")?;
let token = auth::random();
let value = token.clone();
let hash = tokio::task::spawn_blocking(move || bcrypt::hash(value, 12))
.await
.map_err(|_| broken())?
.map_err(|_| broken())?;
let mut url = Url::parse(&format!(
"{}/webauthn/recover",
std::env::var("APP_URL")
.unwrap_or_else(|_| "http://localhost".into())
.trim_end_matches('/')
))
.map_err(|_| broken())?;
url.query_pairs_mut()
.append_pair("email", &email)
.append_pair("token", &token);
let mut tx = s.db.begin().await?;
sqlx::query("UPDATE webauthn_recoveries SET token=token WHERE email=?")
.bind(&email)
.execute(&mut *tx)
.await?;
let recent:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM webauthn_recoveries WHERE email=? AND created_at>datetime('now','-60 seconds'))").bind(&email).fetch_one(&mut *tx).await?;
if recent {
return Err(ApiError(
StatusCode::TOO_MANY_REQUESTS,
"Please wait before retrying.",
));
}
sqlx::query("DELETE FROM webauthn_recoveries WHERE email=?")
.bind(&email)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO webauthn_recoveries(email,token,created_at) VALUES(?,?,CURRENT_TIMESTAMP)",
)
.bind(&email)
.bind(hash)
.execute(&mut *tx)
.await?;
crate::mail::webauthn_recovery(&email, url.as_str())
.await
.map_err(|_| ApiError(StatusCode::SERVICE_UNAVAILABLE, "Mail delivery failed"))?;
tx.commit().await?;
Ok(Json(json!({"message":"Account recovery email sent!"})).into_response())
}
async fn recover(
State(s): State<AppState>,
h: HeaderMap,
peer: Option<Extension<axum::extract::ConnectInfo<std::net::SocketAddr>>>,
Json(v): Json<Value>,
) -> ResultResponse {
let context = crate::auth_audit::Context::from_headers(
&h,
peer.map(|Extension(axum::extract::ConnectInfo(peer))| peer),
);
let old = session(&s, &h, true).await?;
let user = recovery_user(&s, &v).await?;
let email: String = user.try_get("email")?;
let id: i64 = user.try_get("id")?;
if !auth::throttle_limit(&s.db, format!("webauthn-recover:{id}"), 10).await? {
return Err(ApiError(
StatusCode::TOO_MANY_REQUESTS,
"Too Many Attempts.",
));
}
let stored:Option<String>=sqlx::query_scalar("SELECT token FROM webauthn_recoveries WHERE email=? AND created_at>datetime('now','-60 minutes')").bind(&email).fetch_optional(&s.db).await?;
let stored = stored.ok_or_else(invalid)?;
let token = v["token"].as_str().ok_or_else(invalid)?.to_owned();
let hash = stored.clone();
let valid = tokio::task::spawn_blocking(move || auth::verify(token, hash))
.await
.unwrap_or(false);
if !valid {
return Err(invalid());
}
let password = v["password"].as_str().ok_or_else(invalid)?.to_owned();
let hash: String = user.try_get("password")?;
if !tokio::task::spawn_blocking(move || auth::verify(password, hash))
.await
.unwrap_or(false)
{
crate::auth_audit::failed(&s, id, &context, Some("password")).await?;
return Err(denied());
}
let mut prefs = crate::settings::preferences_from(
user.try_get::<Option<String>, _>("preferences")?.as_deref(),
);
prefs["useWebauthnOnly"] = json!(false);
let unique = h
.get("WebAuthn-Unique")
.and_then(|h| h.to_str().ok())
.is_some_and(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on"));
let revoke = unique || v.get("revokeAll").and_then(Value::as_bool).unwrap_or(true);
let sid = auth::random();
let csrf = auth::random();
let mut tx = s.db.begin().await?;
let consumed=sqlx::query("DELETE FROM webauthn_recoveries WHERE email=? AND token=? AND created_at>datetime('now','-60 minutes')").bind(&email).bind(stored).execute(&mut *tx).await?;
if consumed.rows_affected() != 1 {
return Err(invalid());
}
if revoke {
sqlx::query("DELETE FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=?").bind(id).bind(MODEL).execute(&mut *tx).await?;
}
sqlx::query("UPDATE users SET preferences=?,last_seen_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(prefs.to_string()).bind(id).execute(&mut *tx).await?;
let deleted = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?")
.bind(old.try_get::<String, _>("token_hash")?)
.bind(auth::now())
.execute(&mut *tx)
.await?;
if deleted.rows_affected() != 1 {
return Err(denied());
}
sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)",
)
.bind(auth::digest(&sid))
.bind(id)
.bind(auth::digest(&csrf))
.bind(auth::now() + auth::TTL)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM rust_webauthn_challenges WHERE user_id=?")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
crate::auth_audit::login(&s, id, &context, Some("password")).await?;
let mut response = Json(json!({"message":"Webauthn login disabled"})).into_response();
auth::cookies(&mut response, &sid, &csrf, auth::TTL);
auth::clear_legacy_cookie(&mut response);
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::{Body, to_bytes},
http::Request,
};
use openssl::{
hash::MessageDigest,
pkey::{PKey, Private},
sign::Signer,
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tower::ServiceExt;
const SID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const CSRF: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn key() -> PKey<Private> {
PKey::private_key_from_pem(include_bytes!("../tests/fixtures/webauthn-private.pem"))
.unwrap()
}
async fn setup(user: Option<i64>) -> AppState {
let db = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
crate::db::init(&db).await.unwrap();
auth::init(&db).await.unwrap();
crate::users::init(&db).await.unwrap();
init(&db).await.unwrap();
sqlx::query("INSERT INTO users(id,name,email,password,preferences) VALUES(1,'Alice','alice@example.com','unused','{}'),(2,'Bob','bob@example.com','unused','{}')").execute(&db).await.unwrap();
sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)",
)
.bind(auth::digest(SID))
.bind(user)
.bind(auth::digest(CSRF))
.bind(auth::now() + 3600)
.execute(&db)
.await
.unwrap();
AppState {
db,
cipher: Some(Arc::new(
crate::crypto::LaravelCipher::from_app_key(
"base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
)
.unwrap(),
)),
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
}
}
async fn request(s: &AppState, method: &str, url: &str, value: Value) -> (StatusCode, Value) {
let app = routes(s.clone()).with_state(s.clone());
let response = app
.oneshot(
Request::builder()
.method(method)
.uri(url)
.header("cookie", format!("rust_2fauth_session={SID}"))
.header("x-csrf-token", CSRF)
.header("content-type", "application/json")
.body(Body::from(value.to_string()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let data = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
(status, serde_json::from_slice(&data).unwrap_or(Value::Null))
}
async fn import(s: &AppState) {
sqlx::query("INSERT INTO webauthn_credentials(id,authenticatable_type,authenticatable_id,user_id,counter,rp_id,origin,public_key) VALUES(?,?,1,'550e8400-e29b-41d4-a716-446655440000',0,'localhost','http://localhost',?)")
.bind(encoded(b"php-credential")).bind(MODEL).bind(include_str!("../tests/fixtures/webauthn-php-encrypted.txt").trim()).execute(&s.db).await.unwrap();
}
fn assertion(
options: &Value,
id: &[u8],
counter: u32,
origin: &str,
handle: Option<&[u8]>,
) -> Value {
assertion_with_key(options, id, counter, origin, handle, &key())
}
fn assertion_with_key(
options: &Value,
id: &[u8],
counter: u32,
origin: &str,
handle: Option<&[u8]>,
pkey: &PKey<Private>,
) -> Value {
let client=serde_json::to_vec(&json!({"type":"webauthn.get","challenge":options["challenge"],"origin":origin,"crossOrigin":false})).unwrap();
let mut authdata = Sha256::digest(b"localhost").to_vec();
authdata.push(0x05);
authdata.extend(counter.to_be_bytes());
let mut signed = authdata.clone();
signed.extend(Sha256::digest(&client));
let signature = if pkey.id() == openssl::pkey::Id::ED25519 {
Signer::new_without_digest(pkey)
.unwrap()
.sign_oneshot_to_vec(&signed)
.unwrap()
} else {
let mut signer = Signer::new(MessageDigest::sha256(), pkey).unwrap();
signer.update(&signed).unwrap();
signer.sign_to_vec().unwrap()
};
json!({"id":encoded(id),"rawId":STANDARD.encode(id),"type":"public-key","response":{"clientDataJSON":STANDARD.encode(client),"authenticatorData":STANDARD.encode(authdata),"signature":STANDARD.encode(signature),"userHandle":handle.map(|b|STANDARD.encode(b))}})
}
fn registration(options: &Value, id: &[u8]) -> Value {
use serde_cbor_2::Value as C;
use std::collections::BTreeMap;
let ec = key().ec_key().unwrap();
let mut ctx = openssl::bn::BigNumContext::new().unwrap();
let mut x = openssl::bn::BigNum::new().unwrap();
let mut y = openssl::bn::BigNum::new().unwrap();
ec.public_key()
.affine_coordinates_gfp(ec.group(), &mut x, &mut y, &mut ctx)
.unwrap();
let cose = C::Map(BTreeMap::from([
(C::Integer(1), C::Integer(2)),
(C::Integer(3), C::Integer(-7)),
(C::Integer(-1), C::Integer(1)),
(C::Integer(-2), C::Bytes(x.to_vec_padded(32).unwrap())),
(C::Integer(-3), C::Bytes(y.to_vec_padded(32).unwrap())),
]));
registration_with_cose(options, id, cose)
}
fn registration_with_cose(options: &Value, id: &[u8], cose: serde_cbor_2::Value) -> Value {
use serde_cbor_2::Value as C;
use std::collections::BTreeMap;
let mut authdata = Sha256::digest(b"localhost").to_vec();
authdata.push(0x45);
authdata.extend(0_u32.to_be_bytes());
authdata.extend([0_u8; 16]);
authdata.extend((id.len() as u16).to_be_bytes());
authdata.extend(id);
authdata.extend(serde_cbor_2::to_vec(&cose).unwrap());
let attestation = C::Map(BTreeMap::from([
(C::Text("fmt".into()), C::Text("none".into())),
(C::Text("attStmt".into()), C::Map(BTreeMap::new())),
(C::Text("authData".into()), C::Bytes(authdata)),
]));
let client=serde_json::to_vec(&json!({"type":"webauthn.create","challenge":options["challenge"],"origin":"http://localhost","crossOrigin":false})).unwrap();
json!({"id":encoded(id),"rawId":STANDARD.encode(id),"type":"public-key","response":{"clientDataJSON":STANDARD.encode(client),"attestationObject":STANDARD.encode(serde_cbor_2::to_vec(&attestation).unwrap()),"transports":["usb"]}})
}
#[tokio::test]
async fn ed25519_enrolment_and_assertion_are_supported_like_laragear() {
use serde_cbor_2::Value as C;
use std::collections::BTreeMap;
let s = setup(Some(1)).await;
let key = PKey::generate_ed25519().unwrap();
let (_, options) = request(&s, "POST", "/webauthn/register/options", json!({})).await;
assert!(
options["pubKeyCredParams"]
.as_array()
.unwrap()
.iter()
.any(|p| p["alg"] == -8)
);
let cose = C::Map(BTreeMap::from([
(C::Integer(1), C::Integer(1)),
(C::Integer(3), C::Integer(-8)),
(C::Integer(-1), C::Integer(6)),
(C::Integer(-2), C::Bytes(key.raw_public_key().unwrap())),
]));
let registration = registration_with_cose(&options, b"ed25519-credential", cose);
let (status, value) = request(&s, "POST", "/webauthn/register", registration).await;
assert_eq!(status, StatusCode::NO_CONTENT, "{value}");
let (_, options) = request(
&s,
"POST",
"/webauthn/login/options",
json!({"email":"alice@example.com"}),
)
.await;
let mut signed = assertion_with_key(
&options,
b"ed25519-credential",
1,
"http://localhost",
None,
&key,
);
signed["email"] = json!("alice@example.com");
let (status, value) = request(&s, "POST", "/webauthn/login", signed).await;
assert_eq!(status, StatusCode::OK, "{value}");
}
#[tokio::test]
async fn laragear_rsa_and_eddsa_pem_credentials_verify_real_signatures() {
let rsa = PKey::from_rsa(openssl::rsa::Rsa::generate(2048).unwrap()).unwrap();
let ed = PKey::generate_ed25519().unwrap();
for key in [rsa, ed] {
let s = setup(None).await;
import(&s).await;
let pem = String::from_utf8(key.public_key_to_pem().unwrap()).unwrap();
sqlx::query("UPDATE webauthn_credentials SET public_key=?")
.bind(s.cipher.as_ref().unwrap().encrypt(&pem).unwrap())
.execute(&s.db)
.await
.unwrap();
let (_, options) = request(
&s,
"POST",
"/webauthn/login/options",
json!({"email":"alice@example.com"}),
)
.await;
let mut signed = assertion_with_key(
&options,
b"php-credential",
1,
"http://localhost",
None,
&key,
);
signed["email"] = json!("alice@example.com");
let (status, value) = request(&s, "POST", "/webauthn/login", signed).await;
assert_eq!(status, StatusCode::OK, "{value}");
}
}
#[tokio::test]
async fn recovery_requires_token_and_password_consumes_token_and_resets_device_only_mode() {
let s = setup(None).await;
import(&s).await;
let hash = bcrypt::hash("password", 4).unwrap();
sqlx::query(
"UPDATE users SET password=?,preferences='{\"useWebauthnOnly\":true}' WHERE id=1",
)
.bind(hash)
.execute(&s.db)
.await
.unwrap();
sqlx::query("INSERT INTO webauthn_recoveries(email,token,created_at) VALUES('alice@example.com',?,CURRENT_TIMESTAMP)").bind(bcrypt::hash("recovery-token",4).unwrap()).execute(&s.db).await.unwrap();
assert_eq!(
request(
&s,
"POST",
"/webauthn/recover",
json!({"email":"alice@example.com","token":"recovery-token","password":"wrong"})
)
.await
.0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_recoveries")
.fetch_one(&s.db)
.await
.unwrap(),
1
);
let payload =
json!({"email":"alice@example.com","token":"recovery-token","password":"password"});
let (status, v) = request(&s, "POST", "/webauthn/recover", payload.clone()).await;
assert_eq!(status, StatusCode::OK, "{v}");
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_recoveries")
.fetch_one(&s.db)
.await
.unwrap(),
0
);
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_credentials")
.fetch_one(&s.db)
.await
.unwrap(),
0
);
let prefs: String = sqlx::query_scalar("SELECT preferences FROM users WHERE id=1")
.fetch_one(&s.db)
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&prefs).unwrap()["useWebauthnOnly"],
false
);
assert_ne!(
request(&s, "POST", "/webauthn/recover", payload).await.0,
StatusCode::OK
);
}
#[tokio::test]
async fn php_encrypted_pem_migrates_and_valid_signature_authenticates_once() {
let s = setup(None).await;
import(&s).await;
let (status, options) = request(
&s,
"POST",
"/webauthn/login/options",
json!({"email":"ALICE@example.com"}),
)
.await;
assert_eq!(status, StatusCode::OK, "{options}");
let mut response = assertion(&options, b"php-credential", 1, "http://localhost", None);
response["email"] = json!("alice@example.com");
let (status, v) = request(&s, "POST", "/webauthn/login", response.clone()).await;
assert_eq!(status, StatusCode::OK, "{v}");
assert_eq!(v["message"], "authenticated");
assert_eq!(v["id"], 1);
let count: i64 = sqlx::query_scalar("SELECT counter FROM webauthn_credentials")
.fetch_one(&s.db)
.await
.unwrap();
assert_eq!(count, 1);
let method: String =
sqlx::query_scalar("SELECT login_method FROM auth_logs WHERE login_successful=1")
.fetch_one(&s.db)
.await
.unwrap();
assert_eq!(method, "webauthn");
assert_ne!(
request(&s, "POST", "/webauthn/login", response).await.0,
StatusCode::OK
);
}
#[tokio::test]
async fn wrong_origin_user_handle_signature_and_expired_challenge_fail_closed() {
let s = setup(None).await;
import(&s).await;
for variant in 0..4 {
let (_, options) = request(
&s,
"POST",
"/webauthn/login/options",
json!({"email":"alice@example.com"}),
)
.await;
let mut response = assertion(
&options,
b"php-credential",
1,
if variant == 0 {
"https://evil.example"
} else {
"http://localhost"
},
if variant == 1 {
Some(&[0_u8; 16])
} else {
None
},
);
response["email"] = json!("alice@example.com");
if variant == 2 {
response["response"]["signature"] = json!(STANDARD.encode([0_u8; 64]));
}
if variant == 3 {
sqlx::query("UPDATE rust_webauthn_challenges SET expires_at=0")
.execute(&s.db)
.await
.unwrap();
}
assert_ne!(
request(&s, "POST", "/webauthn/login", response).await.0,
StatusCode::OK
);
}
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT counter FROM webauthn_credentials")
.fetch_one(&s.db)
.await
.unwrap(),
0
);
}
#[tokio::test]
async fn real_registration_persists_encrypted_key_manage_routes_are_owner_scoped() {
let s = setup(Some(1)).await;
let (status, options) = request(&s, "POST", "/webauthn/register/options", json!({})).await;
assert_eq!(status, StatusCode::OK, "{options}");
let response = registration(&options, b"new-credential");
let (status, v) = request(&s, "POST", "/webauthn/register", response).await;
assert_eq!(status, StatusCode::NO_CONTENT, "{v}");
let pem: String = sqlx::query_scalar("SELECT public_key FROM webauthn_credentials")
.fetch_one(&s.db)
.await
.unwrap();
assert!(!pem.contains("BEGIN PUBLIC KEY"));
assert!(
s.cipher
.as_ref()
.unwrap()
.decrypt(&pem)
.unwrap()
.contains("BEGIN PUBLIC KEY")
);
let (status, v) = request(&s, "GET", "/webauthn/credentials", json!({})).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v.as_array().unwrap().len(), 1);
assert!(v[0].get("public_key").is_none());
let id = encoded(b"new-credential");
assert_eq!(
request(
&s,
"PATCH",
&format!("/webauthn/credentials/{id}/name"),
json!({"name":"Security Key"})
)
.await
.1["name"],
"Security Key"
);
sqlx::query("UPDATE rust_sessions SET user_id=2")
.execute(&s.db)
.await
.unwrap();
assert_eq!(
request(
&s,
"PATCH",
&format!("/webauthn/credentials/{id}/name"),
json!({"name":"Stolen"})
)
.await
.0,
StatusCode::NOT_FOUND
);
request(
&s,
"DELETE",
&format!("/webauthn/credentials/{id}"),
json!({}),
)
.await;
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_credentials")
.fetch_one(&s.db)
.await
.unwrap(),
1
);
}
}