1201 lines
48 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|