From b50e034b8edcd7247662798eedfe77fd5f951026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Baylac=20Jacqu=C3=A9?= Date: Thu, 23 Nov 2023 22:08:13 +0100 Subject: [PATCH] Login: full scenario --- flake.lock | 6 +- gdb-run-dev-server | 40 ++++++ src/handlers/webauthn.rs | 127 ++++++++++++++++-- src/models/mod.rs | 63 ++++++++- src/server/bin/main.rs | 3 + src/templates/login.hbs | 4 + src/templates/mod.rs | 22 ++- src/templates/register-user.hbs | 2 +- src/templates/webauthn-login.js | 68 ++++++++++ .../{webauthn.js => webauthn-register.js} | 12 +- 10 files changed, 319 insertions(+), 28 deletions(-) create mode 100755 gdb-run-dev-server create mode 100644 src/templates/login.hbs create mode 100644 src/templates/webauthn-login.js rename src/templates/{webauthn.js => webauthn-register.js} (89%) diff --git a/flake.lock b/flake.lock index b712a2b..31b3924 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1671417167, - "narHash": "sha256-JkHam6WQOwZN1t2C2sbp1TqMv3TVRjzrdoejqfefwrM=", + "lastModified": 1700786208, + "narHash": "sha256-vP0WI7qNkg3teQJN5xjFcxgnBNiKCbkgw3X9HcAxWJY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "bb31220cca6d044baa6dc2715b07497a2a7c4bc7", + "rev": "8b8c9407844599546393146bfac901290e0ab96b", "type": "github" }, "original": { diff --git a/gdb-run-dev-server b/gdb-run-dev-server new file mode 100755 index 0000000..10a7fd5 --- /dev/null +++ b/gdb-run-dev-server @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -eau + +dbname="nomnomdev" +port="12345" + +dbdir="$(mktemp -d)" +cfgfile="${dbdir}/config.json" +trap 'rm -rf ${dbdir}' EXIT + +initdb "$dbdir" +postgres -D "${dbdir}" -c unix_socket_directories="${dbdir}" -c listen_addresses= -c port="${port}" & +pgpid=$! +# Trick to help the "./psql" script to find the DB dir & co +cat < "/tmp/nom-nom-dev-args" +export host="$dbdir" +export port="$port" +export dbname="$dbname" +export cfgfile="$cfgfile" +EOF + +trap 'rm -rf ${dbdir} && rm /tmp/nom-nom-dev-args && kill ${pgpid}' EXIT + +# Yeah, this is very meh. We need to wait for the server to be ready +#to receive requests to create the DB. +sleep 2 +createdb -h "${dbdir}" -p "${port}" "${dbname}" + +cat < "${cfgfile}" +{ + "url": "http://localhost:8001", + "db_host": "${dbdir}", + "db_port": ${port}, + "db_name": "${dbname}" +} +EOF + +cargo build +rust-gdb --args ./target/debug/nom-nom-gc-server --bind "[::1]:8001" --config "${cfgfile}" diff --git a/src/handlers/webauthn.rs b/src/handlers/webauthn.rs index 1c1f7d4..f30ceb7 100644 --- a/src/handlers/webauthn.rs +++ b/src/handlers/webauthn.rs @@ -1,10 +1,9 @@ - -use actix_web::{web, error, HttpResponse, http::header::ContentType}; -use serde::Deserialize; +use actix_web::{web, error, HttpResponse, http::header::ContentType, cookie::{Cookie, SameSite}}; +use serde::{Deserialize, Serialize}; use uuid::Uuid; -use webauthn_rs::prelude::{RegisterPublicKeyCredential, PasskeyRegistration}; +use webauthn_rs::prelude::{RegisterPublicKeyCredential, PasskeyRegistration, Passkey, RequestChallengeResponse, PublicKeyCredential}; -use crate::{models::{AppState, RegistrationUuid, Key}, templates}; +use crate::{models::{AppState, RegistrationUuid, Key, LoginUuid, SessionUuid}, templates}; /// Page you get after clicking a register-user link. /// @@ -20,7 +19,7 @@ pub async fn webauthn_registration(app_state: web::Data>, path: web Ok(None) => return HttpResponse::from_error(error::ErrorBadRequest("This registration token is invalid: cannot find it in the database")), Err(e) => return HttpResponse::from_error(error::ErrorInternalServerError(e.to_string())), }; - let keys = match app_state.get_user_keys(&user).await { + let keys = match app_state.get_user_keys(&user.uuid).await { Ok(keys) => keys.into_iter().map(|k| k.name).collect(), Err(e) => { eprintln!("webauthn_registration: error while retrieving keys: {}", e); @@ -34,7 +33,7 @@ pub async fn webauthn_registration(app_state: web::Data>, path: web } #[derive(Debug, Deserialize)] -pub struct InitBody { +pub struct InitRegistrationBody { uuid: RegistrationUuid } @@ -43,7 +42,7 @@ pub struct InitBody { /// For now, we don't save anything to the DB. We just generate the /// challenge, the UUID and store everything in the session hashmap /// server-side, in the response and cookie on the client-side. -pub async fn start_webauthn_registration(app_state: web::Data>, body: web::Json) -> HttpResponse { +pub async fn start_webauthn_registration(app_state: web::Data>, body: web::Json) -> HttpResponse { let res = app_state.retrieve_registration_user(&body.uuid).await; let user = match res { Err(e) => { @@ -77,8 +76,10 @@ pub struct RegisterWebauthnPayload { /// We verify the key enrolling challenge, then store the new key in the DB. pub async fn finish_webauthn_registration(app_state: web::Data>, register: web::Json) -> impl actix_web::Responder { let passkey_registration: Option = { - let user_registrations = app_state.session.user_registrations.read().await; - user_registrations.get(®ister.uuid).cloned() + let mut user_registrations = app_state.session.user_registrations.write().await; + let pr = user_registrations.get(®ister.uuid).cloned(); + user_registrations.remove(®ister.uuid); + pr }; let db_usr = app_state.retrieve_registration_user(®ister.uuid).await; let user = match db_usr { @@ -105,7 +106,6 @@ pub async fn finish_webauthn_registration(app_state: web::Data>, re // user? Maybe rather later, when we're sure we // have all the keys required. app_state.save_user_key(&user.uuid, &passkey).await.unwrap(); -// app_state.delete_registration_link(®ister.uuid.clone()).await.unwrap(); HttpResponse::Ok() .body("ok") }, @@ -119,3 +119,108 @@ pub async fn finish_webauthn_registration(app_state: web::Data>, re } } + + +pub async fn webauthn_login(app_state: web::Data>) -> impl actix_web::Responder { + let response = templates::login(app_state.hbs.clone()).unwrap(); + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(response) +} + + +#[derive(Deserialize, Debug)] +pub struct InitLoginBody { + username: String +} + +#[derive(Serialize, Debug)] +pub struct InitLoginResp { + uuid: LoginUuid, + challenge: RequestChallengeResponse +} + +pub async fn webauthn_login_init(app_state: web::Data>, body: web::Json) -> impl actix_web::Responder { + let Ok(user) = app_state.get_user(&body.username).await else { + return HttpResponse::from_error(error::ErrorBadRequest("Invalid username")) + }; + let Ok(keys) = app_state.get_user_keys(&user.uuid).await else { + return HttpResponse::from_error(error::ErrorInternalServerError("Cannot find keys attached to user")); + }; + let keys: Vec = keys.into_iter().map(|k| k.key).collect(); + let (challenge, passkey_auth) = match app_state.webauthn.start_passkey_authentication(keys.as_slice()) { + Ok((c, p)) => (c, p), + Err(e) => { + eprintln!("webauthn_login_init: webauthn error: {}", e); + return HttpResponse::from_error(error::ErrorInternalServerError("Webauthn error")); + } + }; + let uuid = LoginUuid(Uuid::new_v4()); + { + let mut user_logins = app_state.session.user_pending_logins.write().await; + user_logins.insert(uuid.clone(), (passkey_auth, user)); + } + let res = InitLoginResp { + uuid, + challenge, + }; + let res = serde_json::to_string(&res).unwrap(); + HttpResponse::Ok() + .content_type(ContentType::json()) + .body(res) +} + +#[derive(Deserialize, Debug)] +pub struct FinishLoginBody { + challenge: PublicKeyCredential, + uuid: LoginUuid +} + +pub async fn webauthn_login_finish(app_state: web::Data>, body: web::Json) -> impl actix_web::Responder { + let passkey_auth = { + let mut user_logins = app_state.session.user_pending_logins.write().await; + let pk = user_logins.get(&body.uuid).cloned(); + user_logins.remove(&body.uuid); + pk + }; + let Some((passkey_auth,user)) = passkey_auth else { + eprintln!("webauthn_login_finish: cannot find login state"); + return HttpResponse::from_error(error::ErrorBadRequest("Cannot find login state in session")); + }; + let res = match app_state.webauthn.finish_passkey_authentication(&body.challenge, &passkey_auth) { + Ok(res) => res, + Err(e) => { + eprintln!("webauthn_login_finish: webauthn error: {}", e); + return HttpResponse::from_error(error::ErrorForbidden("Failed webauthn auth")); + }, + }; + let session_uuid = { + let mut user_sessions = app_state.session.user_sessions.write().await; + let session_uuid = SessionUuid(Uuid::new_v4()); + user_sessions.insert(session_uuid, user.clone()); + session_uuid + }; + if res.needs_update() { + match app_state.update_user_keys(&user.uuid, res).await { + Ok(()) => (), + Err(e) => { + eprintln!("Error while updating the key states: {}", e); + return HttpResponse::from_error(error::ErrorInternalServerError("Error while updating the key states.")); + } + } + } + let response = templates::login(app_state.hbs.clone()).unwrap(); + #[cfg(debug_assertions)] + let secure = false; + #[cfg(not(debug_assertions))] + let secure = true; + let auth_cookie = Cookie::build("auth-uuid", session_uuid.0.to_string()) + .http_only(true) + .same_site(SameSite::Strict) + .secure(secure) + .finish(); + HttpResponse::Ok() + .content_type(ContentType::html()) + .cookie(auth_cookie) + .body(response) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 91d1de1..3073d97 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{self, Value}; use tokio::sync::RwLock; use tokio_postgres::NoTls; -use webauthn_rs::prelude::{Uuid, PasskeyRegistration, Passkey}; +use webauthn_rs::prelude::{Uuid, PasskeyRegistration, Passkey, PasskeyAuthentication, AuthenticationResult}; use webauthn_rs::{Webauthn, WebauthnBuilder}; #[derive(Deserialize, Debug, Clone)] @@ -36,16 +36,23 @@ pub type DbField = Arc>; #[derive(Clone, Debug)] pub struct TempSession { - pub user_registrations: Arc>> + pub user_registrations: Arc>>, + pub user_pending_logins: Arc>>, + pub user_sessions: Arc>>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct RegistrationUuid(pub Uuid); +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct LoginUuid(pub Uuid); + #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Hash, FromSql, ToSql)] #[postgres(transparent)] pub struct UserUuid(pub Uuid); +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct SessionUuid(pub Uuid); #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct User { @@ -81,7 +88,9 @@ impl AppState<'_> { let webauthn = Arc::new(builder.build().expect("Invalid configuration")); let hbs = Arc::new(crate::templates::new().unwrap()); let session: TempSession = TempSession { - user_registrations: Arc::new(RwLock::new(HashMap::new())) + user_registrations: Arc::new(RwLock::new(HashMap::new())), + user_pending_logins: Arc::new(RwLock::new(HashMap::new())), + user_sessions: Arc::new(RwLock::new(HashMap::new())) }; let mut pg_config = tokio_postgres::Config::new(); pg_config.host_path(conf.db_host.unwrap().clone()); @@ -153,6 +162,14 @@ impl AppState<'_> { } + pub async fn get_user(&self, username: &str) -> Result { + let mut conn = self.db.get().await?; + let client = conn.deref_mut(); + let stmt = client.prepare_cached("SELECT id, user_name FROM Users WHERE user_name = $1").await?; + let row = client.query_one(&stmt, &[&username]).await?; + Ok(User{ uuid: row.get(0), name: row.get(1) }) + } + pub async fn save_user(&self, user: &User) -> Result<()> { let mut conn = self.db.get().await?; let client = conn.deref_mut(); @@ -170,11 +187,11 @@ impl AppState<'_> { Ok(()) } - pub async fn get_user_keys(&self, user: &User) -> Result> { + pub async fn get_user_keys(&self, user_id: &UserUuid) -> Result> { let mut conn = self.db.get().await?; let client = conn.deref_mut(); let stmt = client.prepare_cached("SELECT name, key_dump FROM Keys WHERE user_id = $1").await?; - let res = client.query(&stmt, &[&user.uuid.0]).await?; + let res = client.query(&stmt, &[&user_id.0]).await?; let res = res.iter().map(|row| { let key: Value = row.get(1); Key { @@ -186,4 +203,40 @@ impl AppState<'_> { }}).collect(); Ok(res) } + + /** + Updates the keys for the user `UserUuid` according to the last + successful login `AuthenticationResult`. + */ + pub async fn update_user_keys(&self, user_id: &UserUuid, auth_res: AuthenticationResult) -> Result<()> { + let mut conn = self.db.get().await?; + let transaction = conn.deref_mut().transaction().await?; + + // Retrieve user keys + let stmt = transaction.prepare_cached("SELECT name, key_dump FROM Keys WHERE user_id = $1").await?; + let res = transaction.query(&stmt, &[&user_id.0]).await?; + let mut keys: Vec = res.iter().map(|row| { + let key = row.get(1); + Key { name: row.get(0), key: serde_json::from_value(key).unwrap() } + }).collect(); + + // Update keys + keys.iter_mut().for_each( + |key| { + key.key.update_credential(&auth_res); + } + ); + + // Delete current keys, save updated keys + let stmt = transaction.prepare_cached("DELETE FROM Keys WHERE user_id = $1").await?; + let _ = transaction.execute(&stmt, &[&user_id.0]).await?; + let stmt = transaction.prepare_cached("INSERT INTO Keys (user_id, name, key_dump) VALUES ($1,$2,$3)").await?; + for key in keys { + let passkey_val: Value = serde_json::to_value(&key.key).unwrap(); + let _ = transaction.execute(&stmt, &[&user_id.0, &key.name, &passkey_val]).await?; + } + + transaction.commit().await?; + Ok(()) + } } diff --git a/src/server/bin/main.rs b/src/server/bin/main.rs index 6ca188f..dba1362 100644 --- a/src/server/bin/main.rs +++ b/src/server/bin/main.rs @@ -35,6 +35,9 @@ async fn main() -> std::io::Result<()> { .route("/account/register/{uuid}", web::get().to(handlers::webauthn_registration)) .route("/account/register-init", web::post().to(handlers::start_webauthn_registration)) .route("/account/register-finish", web::post().to(handlers::finish_webauthn_registration)) + .route("/login", web::get().to(handlers::webauthn_login)) + .route("/login/init", web::post().to(handlers::webauthn_login_init)) + .route("/login/finish", web::post().to(handlers::webauthn_login_finish)) }) .bind(addr) .unwrap() diff --git a/src/templates/login.hbs b/src/templates/login.hbs new file mode 100644 index 0000000..fd73b24 --- /dev/null +++ b/src/templates/login.hbs @@ -0,0 +1,4 @@ +{{#> template js=js }} +

Login

+
+{{ /template }} diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 8c9244f..9ed3827 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -13,13 +13,18 @@ pub fn new<'a>() -> Result, RenderError> { let register_user = rootpath.join("src/templates/register-user.hbs"); let mut hbs = handlebars::Handlebars::new(); let css = rootpath.join("src/templates/main.css"); - let webauthn_js = rootpath.join("src/templates/webauthn.js"); + let webauthn_register_js = rootpath.join("src/templates/webauthn-register.js"); + let webauthn_login_js = rootpath.join("src/templates/webauthn-login.js"); + let login = rootpath.join("src/templates/login.hbs"); hbs.register_template_file("landing", landing_path.to_str().unwrap())?; hbs.register_template_file("template", template_path.to_str().unwrap())?; hbs.register_template_file("css", css.to_str().unwrap())?; - hbs.register_template_file("webauthn-js", webauthn_js.to_str().unwrap())?; + hbs.register_template_file("webauthn-register-js", webauthn_register_js.to_str().unwrap())?; + hbs.register_template_file("webauthn-login-js", webauthn_login_js.to_str().unwrap())?; hbs.register_template_file("register-user", register_user.to_str().unwrap())?; + hbs.register_template_file("login", login.to_str().unwrap())?; + Ok(hbs) } @@ -34,7 +39,7 @@ pub fn register_user_start(hb: Arc>, registration_uuid: Registrat let js_data = json!({ "registration-uuid": ®istration_uuid.0.to_string(), }); - let js = hb.render("webauthn-js", &js_data)?; + let js = hb.render("webauthn-register-js", &js_data)?; let data = json!({ "js": js, "username": &username, @@ -42,3 +47,14 @@ pub fn register_user_start(hb: Arc>, registration_uuid: Registrat }); hb.render("register-user", &data) } + + +pub fn login(hb: Arc>, ) -> Result { + let js_data = json!({ + }); + let js = hb.render("webauthn-login-js", &js_data)?; + let data = json!({ + "js": js, + }); + hb.render("login", &data) +} diff --git a/src/templates/register-user.hbs b/src/templates/register-user.hbs index 5d6efaa..3b27c1e 100644 --- a/src/templates/register-user.hbs +++ b/src/templates/register-user.hbs @@ -1,6 +1,6 @@ {{#> template js=js }}

New user: {{ username }}

-
+
{{#each keyids}} diff --git a/src/templates/webauthn-login.js b/src/templates/webauthn-login.js new file mode 100644 index 0000000..c9b1bbf --- /dev/null +++ b/src/templates/webauthn-login.js @@ -0,0 +1,68 @@ +async function fetch_challenge () { + const username = document.getElementById("user-name").value; + const init_data = { + username: username, + }; + const challenge = await fetch("/login/init", { + method: 'POST', + body: JSON.stringify(init_data), + headers: { + 'Content-Type': 'application/json' + } + }); + return await challenge.json(); +} + +async function solve_challenge (challenge) { + // Decode challenge and credential ID base64 strings into JS arrays. + challenge.publicKey.challenge = Base64.toUint8Array(challenge.publicKey.challenge); + challenge.publicKey.allowCredentials = challenge.publicKey.allowCredentials.map((cred) => { + return Object.assign({}, cred, { + id: Base64.toUint8Array(cred.id) + }); + }); + return navigator.credentials.get(challenge); +} + +function encodeSolvedChallenge(solvedChallenge) { + const uinttobase64 = (array) => Base64.fromUint8Array(new Uint8Array(array), true); + return { + id: solvedChallenge.id, + rawId: uinttobase64(solvedChallenge.rawId), + response: { + authenticatorData: uinttobase64(solvedChallenge.response.authenticatorData), + clientDataJSON: uinttobase64(solvedChallenge.response.clientDataJSON), + signature: uinttobase64(solvedChallenge.response.signature), + userHandle: solvedChallenge.response.userHandle + }, + type: solvedChallenge.type + }; +} + +async function finish_auth (challengeUuid, solvedChallenge) { + // Encode challenge response + console.log(solvedChallenge); + const encodedSolvedChallenge = encodeSolvedChallenge(solvedChallenge); + console.log(encodedSolvedChallenge); + const resp = await fetch("/login/finish", { + method: 'POST', + body: JSON.stringify({uuid: challengeUuid, challenge: encodedSolvedChallenge}), + headers: { 'Content-Type': 'application/json' } + }); + return resp; +} + +async function perform_webauthn_dance () { + const challenge = await fetch_challenge() + let solved = await solve_challenge(challenge.challenge); + let finished = await finish_auth(challenge.uuid, solved); +} + +// Main +(async () => { + const button = document.getElementById("key-form"); + button.addEventListener("submit", async (e) => { + e.preventDefault(); + await perform_webauthn_dance(); + }); +}) (); diff --git a/src/templates/webauthn.js b/src/templates/webauthn-register.js similarity index 89% rename from src/templates/webauthn.js rename to src/templates/webauthn-register.js index 7e8df61..619bad5 100644 --- a/src/templates/webauthn.js +++ b/src/templates/webauthn-register.js @@ -40,19 +40,21 @@ const finish_auth = async (solvedChallenge) => { }), headers: { 'Content-Type': 'application/json' } }); - console.log(resp); - return resp.json(); + return resp; } const perform_webauthn_dance = async () => { const publicKey = await start_webauthn() - console.log(publicKey); let solved = await solve_challenge(publicKey); let finished = await finish_auth(solved); } // Main (async () => { - const button = document.getElementById("enroll-key-btn"); - button.addEventListener("click", perform_webauthn_dance); + const form = document.getElementById("key-form"); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + await perform_webauthn_dance(); + location.reload(); + }); }) ();
KeyID