-
Notifications
You must be signed in to change notification settings - Fork 80
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
627 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,3 +7,4 @@ | |
|
||
* Eric Mark Martin (ericmarkmartin) | ||
* Ben Wishovich (benwis) | ||
* Niklas Pfister (myOmikron) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
[package] | ||
name = "actix_web" | ||
version = "0.1.0" | ||
edition = "2021" | ||
authors = ["Niklas Pfister <git@omikron.dev>"] | ||
|
||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||
|
||
[dependencies] | ||
# Webframework | ||
actix-web = { version = "~4.2" } | ||
# Session framework for actix-web | ||
actix-session = { version = "~0.7", features = ["cookie-session"] } | ||
# Async trait, anyhow, chrono, once_cell and rand are required for the implementation of a | ||
# server-side memory-backed session store. | ||
# Normally, you want to use a database / redis backend as session store, but for the simplicity of this | ||
# tutorial, we implement our own. | ||
async-trait = { version = "~0.1" } | ||
anyhow = { version = "~1.0" } | ||
chrono = { version = "~0.4" } | ||
once_cell = { version = "~1.17" } | ||
rand = { version = "~0.8" } | ||
|
||
# Serve static file. Used to serve wasm | ||
actix-files = { version = "~0.6" } | ||
|
||
# Async runtime | ||
tokio = { version = "~1.24", features = ["macros", "rt-multi-thread"] } | ||
|
||
# Logging framework + facade | ||
env_logger = { version = "~0.10" } | ||
log = { version = "~0.4" } | ||
|
||
# Webauthn framework | ||
webauthn-rs = { path = "../../../webauthn-rs", features = ["danger-allow-state-serialisation"] } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,261 @@ | ||
use actix_session::Session; | ||
use actix_web::web::{Data, Json, Path}; | ||
use actix_web::HttpResponse; | ||
use log::{debug, info}; | ||
use tokio::sync::Mutex; | ||
|
||
use crate::handler::{Error, WebResult}; | ||
use crate::startup::UserData; | ||
|
||
/* | ||
* Webauthn RS auth handlers. | ||
* These files use webauthn to process the data received from each route, and are closely tied to actix_web | ||
*/ | ||
|
||
// 1. Import the prelude - this contains everything needed for the server to function. | ||
use webauthn_rs::prelude::*; | ||
|
||
// 2. The first step a client (user) will carry out is requesting a credential to be | ||
// registered. We need to provide a challenge for this. The work flow will be: | ||
// | ||
// ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ | ||
// │ Authenticator │ │ Browser │ │ Site │ | ||
// └───────────────┘ └───────────────┘ └───────────────┘ | ||
// │ │ │ | ||
// │ │ 1. Start Reg │ | ||
// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│ | ||
// │ │ │ | ||
// │ │ 2. Challenge │ | ||
// │ │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ | ||
// │ │ │ | ||
// │ 3. Select Token │ │ | ||
// ─ ─ ─│◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ | ||
// 4. Verify │ │ │ │ | ||
// │ 4. Yield PubKey │ │ | ||
// └ ─ ─▶│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ │ | ||
// │ │ │ | ||
// │ │ 5. Send Reg Opts │ | ||
// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│─ ─ ─ | ||
// │ │ │ │ 5. Verify | ||
// │ │ │ PubKey | ||
// │ │ │◀─ ─ ┘ | ||
// │ │ │─ ─ ─ | ||
// │ │ │ │ 6. Persist | ||
// │ │ │ Credential | ||
// │ │ │◀─ ─ ┘ | ||
// │ │ │ | ||
// │ │ │ | ||
// | ||
// In this step, we are responding to the start reg(istration) request, and providing | ||
// the challenge to the browser. | ||
pub(crate) async fn start_register( | ||
username: Path<String>, | ||
session: Session, | ||
webauthn_users: Data<Mutex<UserData>>, | ||
webauthn: Data<Webauthn>, | ||
) -> WebResult<Json<CreationChallengeResponse>> { | ||
info!("Start register"); | ||
|
||
// We get the username from the URL, but you could get this via form submission or | ||
// some other process. In some parts of Webauthn, you could also use this as a "display name" | ||
// instead of a username. Generally you should consider that the user *can* and *will* change | ||
// their username at any time. | ||
|
||
// Since a user's username could change at anytime, we need to bind to a unique id. | ||
// We use uuid's for this purpose, and you should generate these randomly. If the | ||
// username does exist and is found, we can match back to our unique id. This is | ||
// important in authentication, where presented credentials may *only* provide | ||
// the unique id, and not the username! | ||
|
||
let user_unique_id = { | ||
let users_guard = webauthn_users.lock().await; | ||
users_guard | ||
.name_to_id | ||
.get(username.as_str()) | ||
.copied() | ||
.unwrap_or_else(Uuid::new_v4) | ||
}; | ||
|
||
// Remove any previous registrations that may have occurred from the session. | ||
session.remove("reg_state"); | ||
|
||
// If the user has any other credentials, we exclude these here so they can't be duplicate registered. | ||
// It also hints to the browser that only new credentials should be "blinked" for interaction. | ||
let exclude_credentials = { | ||
let users_guard = webauthn_users.lock().await; | ||
users_guard | ||
.keys | ||
.get(&user_unique_id) | ||
.map(|keys| keys.iter().map(|sk| sk.cred_id().clone()).collect()) | ||
}; | ||
|
||
let (ccr, reg_state) = webauthn | ||
.start_passkey_registration(user_unique_id, &username, &username, exclude_credentials) | ||
.map_err(|e| { | ||
debug!("challenge_register -> {:?}", e); | ||
Error::Unknown(e) | ||
})?; | ||
|
||
// Note that due to the session store in use being a server side memory store, this is | ||
// safe to store the reg_state into the session since it is not client controlled and | ||
// not open to replay attacks. If this was a cookie store, this would be UNSAFE. | ||
session | ||
.insert("reg_state", (username.as_str(), user_unique_id, reg_state)) | ||
.expect("Failed to insert"); | ||
|
||
info!("Registration Successful!"); | ||
Ok(Json(ccr)) | ||
} | ||
|
||
// 3. The browser has completed it's steps and the user has created a public key | ||
// on their device. Now we have the registration options sent to us, and we need | ||
// to verify these and persist them. | ||
|
||
pub(crate) async fn finish_register( | ||
req: Json<RegisterPublicKeyCredential>, | ||
session: Session, | ||
webauthn_users: Data<Mutex<UserData>>, | ||
webauthn: Data<Webauthn>, | ||
) -> WebResult<HttpResponse> { | ||
let (username, user_unique_id, reg_state) = session.get("reg_state")?.unwrap(); | ||
|
||
session.remove("reg_state"); | ||
|
||
let sk = webauthn | ||
.finish_passkey_registration(&req, ®_state) | ||
.map_err(|e| { | ||
debug!("challenge_register -> {:?}", e); | ||
Error::BadRequest(e) | ||
})?; | ||
|
||
let mut users_guard = webauthn_users.lock().await; | ||
|
||
//TODO: This is where we would store the credential in a db, or persist them in some other way. | ||
|
||
users_guard | ||
.keys | ||
.entry(user_unique_id) | ||
.and_modify(|keys| keys.push(sk.clone())) | ||
.or_insert_with(|| vec![sk.clone()]); | ||
|
||
users_guard.name_to_id.insert(username, user_unique_id); | ||
|
||
Ok(HttpResponse::Ok().finish()) | ||
} | ||
|
||
// 4. Now that our public key has been registered, we can authenticate a user and verify | ||
// that they are the holder of that security token. The work flow is similar to registration. | ||
// | ||
// ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ | ||
// │ Authenticator │ │ Browser │ │ Site │ | ||
// └───────────────┘ └───────────────┘ └───────────────┘ | ||
// │ │ │ | ||
// │ │ 1. Start Auth │ | ||
// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│ | ||
// │ │ │ | ||
// │ │ 2. Challenge │ | ||
// │ │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ | ||
// │ │ │ | ||
// │ 3. Select Token │ │ | ||
// ─ ─ ─│◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ | ||
// 4. Verify │ │ │ │ | ||
// │ 4. Yield Sig │ │ | ||
// └ ─ ─▶│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ │ | ||
// │ │ 5. Send Auth │ | ||
// │ │ Opts │ | ||
// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│─ ─ ─ | ||
// │ │ │ │ 5. Verify | ||
// │ │ │ Sig | ||
// │ │ │◀─ ─ ┘ | ||
// │ │ │ | ||
// │ │ │ | ||
// | ||
// The user indicates the wish to start authentication and we need to provide a challenge. | ||
|
||
pub(crate) async fn start_authentication( | ||
username: Path<String>, | ||
session: Session, | ||
webauthn_users: Data<Mutex<UserData>>, | ||
webauthn: Data<Webauthn>, | ||
) -> WebResult<Json<RequestChallengeResponse>> { | ||
info!("Start Authentication"); | ||
// We get the username from the URL, but you could get this via form submission or | ||
// some other process. | ||
|
||
// Remove any previous authentication that may have occurred from the session. | ||
session.remove("auth_state"); | ||
|
||
// Get the set of keys that the user possesses | ||
let users_guard = webauthn_users.lock().await; | ||
|
||
// Look up their unique id from the username | ||
let user_unique_id = users_guard | ||
.name_to_id | ||
.get(username.as_str()) | ||
.copied() | ||
.ok_or(Error::UserNotFound)?; | ||
|
||
let allow_credentials = users_guard | ||
.keys | ||
.get(&user_unique_id) | ||
.ok_or(Error::UserHasNoCredentials)?; | ||
|
||
let (rcr, auth_state) = webauthn | ||
.start_passkey_authentication(allow_credentials) | ||
.map_err(|e| { | ||
debug!("challenge_authenticate -> {:?}", e); | ||
Error::Unknown(e) | ||
})?; | ||
|
||
// Drop the mutex to allow the mut borrows below to proceed | ||
drop(users_guard); | ||
|
||
// Note that due to the session store in use being a server side memory store, this is | ||
// safe to store the auth_state into the session since it is not client controlled and | ||
// not open to replay attacks. If this was a cookie store, this would be UNSAFE. | ||
session.insert("auth_state", (user_unique_id, auth_state))?; | ||
|
||
Ok(Json(rcr)) | ||
} | ||
|
||
// 5. The browser and user have completed their part of the processing. Only in the | ||
// case that the webauthn authenticate call returns Ok, is authentication considered | ||
// a success. If the browser does not complete this call, or *any* error occurs, | ||
// this is an authentication failure. | ||
|
||
pub(crate) async fn finish_authentication( | ||
auth: Json<PublicKeyCredential>, | ||
session: Session, | ||
webauthn_users: Data<Mutex<UserData>>, | ||
webauthn: Data<Webauthn>, | ||
) -> WebResult<HttpResponse> { | ||
let (user_unique_id, auth_state) = session.get("auth_state")?.ok_or(Error::CorruptSession)?; | ||
|
||
session.remove("auth_state"); | ||
|
||
let auth_result = webauthn | ||
.finish_passkey_authentication(&auth, &auth_state) | ||
.map_err(|e| { | ||
debug!("challenge_register -> {:?}", e); | ||
Error::BadRequest(e) | ||
})?; | ||
|
||
let mut users_guard = webauthn_users.lock().await; | ||
|
||
// Update the credential counter, if possible. | ||
users_guard | ||
.keys | ||
.get_mut(&user_unique_id) | ||
.map(|keys| { | ||
keys.iter_mut().for_each(|sk| { | ||
// This will update the credential if it's the matching | ||
// one. Otherwise it's ignored. That is why it is safe to | ||
// iterate this over the full list. | ||
sk.update_credential(&auth_result); | ||
}) | ||
}) | ||
.ok_or(Error::UserHasNoCredentials)?; | ||
|
||
info!("Authentication Successful!"); | ||
Ok(HttpResponse::Ok().finish()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
use actix_web::HttpResponse; | ||
|
||
pub(crate) async fn index() -> HttpResponse { | ||
HttpResponse::Ok().body( | ||
r#" | ||
<!DOCTYPE html> | ||
<html> | ||
<head> | ||
<meta charset="utf-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1"> | ||
<title>WebAuthn-rs Tutorial</title> | ||
<script type="module"> | ||
import init, { run_app } from './pkg/wasm.js'; | ||
async function main() { | ||
await init('./pkg/wasm_bg.wasm'); | ||
run_app(); | ||
} | ||
main() | ||
</script> | ||
</head> | ||
<body> | ||
<p>Welcome to the WebAuthn Server!</p> | ||
</body> | ||
</html>"#, | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
use actix_session::{SessionGetError, SessionInsertError}; | ||
use std::fmt::{Display, Formatter}; | ||
|
||
use actix_web::http::StatusCode; | ||
use webauthn_rs::prelude::WebauthnError; | ||
|
||
pub(crate) mod auth; | ||
pub(crate) mod index; | ||
pub(crate) mod serve_wasm; | ||
|
||
/** | ||
Type alias for Errors that implement [actix_web::ResponseError] through [Error] | ||
*/ | ||
type WebResult<T> = Result<T, Error>; | ||
|
||
/** | ||
Unified errors for simpler Responses | ||
*/ | ||
#[derive(Debug)] | ||
pub(crate) enum Error { | ||
Unknown(WebauthnError), | ||
SessionGet(SessionGetError), | ||
SessionInsert(SessionInsertError), | ||
CorruptSession, | ||
BadRequest(WebauthnError), | ||
UserNotFound, | ||
UserHasNoCredentials, | ||
} | ||
|
||
impl From<SessionGetError> for Error { | ||
fn from(value: SessionGetError) -> Self { | ||
Self::SessionGet(value) | ||
} | ||
} | ||
|
||
impl From<SessionInsertError> for Error { | ||
fn from(value: SessionInsertError) -> Self { | ||
Self::SessionInsert(value) | ||
} | ||
} | ||
|
||
impl Display for Error { | ||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | ||
match self { | ||
Error::Unknown(_) => write!(f, "Unknown webauthn error"), | ||
Error::SessionGet(_) | Error::SessionInsert(_) => write!(f, "Corrupt session"), | ||
Error::BadRequest(_) => write!(f, "Bad request"), | ||
Error::UserNotFound => write!(f, "User not found"), | ||
Error::UserHasNoCredentials => write!(f, "User has no credentials"), | ||
Error::CorruptSession => write!(f, "Corrupt session"), | ||
} | ||
} | ||
} | ||
|
||
impl actix_web::ResponseError for Error { | ||
fn status_code(&self) -> StatusCode { | ||
StatusCode::INTERNAL_SERVER_ERROR | ||
} | ||
} |
Oops, something went wrong.