Compare commits
6 commits
5437600b1c
...
388646e7c7
Author | SHA1 | Date | |
---|---|---|---|
Félix Baylac Jacqué | 388646e7c7 | ||
Félix Baylac Jacqué | b50e034b8e | ||
Félix Baylac Jacqué | 1eedcf924e | ||
Félix Baylac Jacqué | 9a85a5cd76 | ||
Félix Baylac Jacqué | 1acb78fdd5 | ||
Félix Baylac Jacqué | 1dc40ec251 |
740
Cargo.lock
generated
740
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
19
Cargo.toml
19
Cargo.toml
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "nix-cache-bucket-gc"
|
||||
name = "nom-nom-gc"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
@ -14,3 +14,20 @@ webauthn-rs = "0.4.8"
|
|||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
handlebars = "4.3.6"
|
||||
deadpool-postgres = "0.11.0"
|
||||
tokio-postgres = { version = "0.7.10", features = ["with-uuid-1", "with-serde_json-1"] }
|
||||
postgres-types = { version = "*", features = ["derive"] }
|
||||
anyhow = "1.0.75"
|
||||
refinery = { version = "0.8.11", features = ["tokio-postgres"] }
|
||||
uuid = { version = "1.4.1", features = ["v4"] }
|
||||
|
||||
[dependencies.heck]
|
||||
version = "0.4.1"
|
||||
|
||||
[[bin]]
|
||||
name = "nom-nom-gc-server"
|
||||
path = "src/server/bin/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "nom-nom-gc-cli"
|
||||
path = "src/cli/bin/main.rs"
|
||||
|
|
|
@ -3,8 +3,12 @@
|
|||
pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "nom-nom-nix-gc";
|
||||
version = "0.0";
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
nativeBuildInputs = [
|
||||
pkgs.pkg-config
|
||||
# Required to run the integration tests.
|
||||
pkgs.postgresql
|
||||
];
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
src = pkgs.lib.cleanSource ./.;
|
||||
cargoHash = "sha256-RLVuWCKDnffILt8XrdiYiPwfXMQpw/f43YOsOrMwX9o=";
|
||||
cargoHash = "sha256-0891xprAumfRvODg0Al6PnQ0EfWNgX+VUINjbutFilg=";
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
40
gdb-run-dev-server
Executable file
40
gdb-run-dev-server
Executable file
|
@ -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 <<EOF > "/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 <<EOF > "${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}"
|
23
migrations/V1__init.sql
Normal file
23
migrations/V1__init.sql
Normal file
|
@ -0,0 +1,23 @@
|
|||
CREATE TABLE Users (
|
||||
id UUID NOT NULL,
|
||||
user_name text NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE Keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
key_dump jsonb NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES Users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE PendingRegistrations (
|
||||
id uuid NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES Users(id),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- We'll mostly querying the Keys using the associated uid.
|
||||
CREATE INDEX idx_keys_uid ON Keys USING HASH (user_id);
|
4
nom-nom-gc
Executable file
4
nom-nom-gc
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
source /tmp/nom-nom-dev-args
|
||||
cargo run --bin nom-nom-gc-cli -- -c "${cfgfile}" $@
|
4
psql
Executable file
4
psql
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
source /tmp/nom-nom-dev-args
|
||||
psql -h "${host}" -p "${port}" -d "${dbname}"
|
39
run-dev-server
Executable file
39
run-dev-server
Executable file
|
@ -0,0 +1,39 @@
|
|||
#!/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 <<EOF > "/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 <<EOF > "${cfgfile}"
|
||||
{
|
||||
"url": "http://localhost:8001",
|
||||
"db_host": "${dbdir}",
|
||||
"db_port": ${port},
|
||||
"db_name": "${dbname}"
|
||||
}
|
||||
EOF
|
||||
|
||||
cargo run --bin nom-nom-gc-server -- --bind "[::1]:8001" --config "${cfgfile}"
|
|
@ -6,6 +6,7 @@ pkgs.mkShell {
|
|||
pkgs.cargo
|
||||
pkgs.rust-analyzer
|
||||
pkgs.pkg-config
|
||||
pkgs.postgresql
|
||||
];
|
||||
buildInputs = [
|
||||
pkgs.openssl
|
||||
|
|
47
src/cli/bin/main.rs
Normal file
47
src/cli/bin/main.rs
Normal file
|
@ -0,0 +1,47 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand, Args};
|
||||
|
||||
use nom_nom_gc::models::{read_config, self, AppState, Configuration, User, UserUuid};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct CLIArgs {
|
||||
#[arg(short, long)]
|
||||
config: Option<String>,
|
||||
#[command(subcommand)]
|
||||
command: Command
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
RegisterUser(RegisterUserArgs)
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct RegisterUserArgs {
|
||||
username: String
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = CLIArgs::parse();
|
||||
let config = read_config(&args.config.unwrap_or("/etc/nom-nom-gc/config.json".to_owned()))
|
||||
.unwrap_or_else(|e| panic!("Cannot read config file: {}", e));
|
||||
// todo: don't consume config in appstate new
|
||||
let state = models::AppState::new(config.clone());
|
||||
match args.command {
|
||||
Command::RegisterUser(args) => register_user(args.username, state, config).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_user(user_name: String, state: AppState<'_>, conf: Configuration) -> Result<()> {
|
||||
let user = User {
|
||||
uuid: UserUuid(Uuid::new_v4()),
|
||||
name: user_name,
|
||||
};
|
||||
state.save_user(&user).await?;
|
||||
let uuid = state.generate_registration_uuid(&user.uuid).await?;
|
||||
println!("Registration link: {}/account/register/{}", &conf.url, &uuid.0.to_string());
|
||||
Ok(())
|
||||
}
|
|
@ -1,7 +1,10 @@
|
|||
use webauthn_rs::prelude::{RegisterPublicKeyCredential, Uuid};
|
||||
use actix_web::{error, HttpResponse, http::header::{ContentType, self}, web, cookie::{Cookie, SameSite}, HttpRequest};
|
||||
use actix_web::{HttpResponse, http::header::ContentType, web};
|
||||
|
||||
use crate::{models::{AppState, User}, templates};
|
||||
use crate::{models::AppState, templates};
|
||||
|
||||
pub mod webauthn;
|
||||
|
||||
pub use webauthn::*;
|
||||
|
||||
pub async fn landing_page (app_state: web::Data<AppState<'_>>) -> HttpResponse {
|
||||
let content: String = templates::landing_page(app_state.hbs.clone()).unwrap();
|
||||
|
@ -9,47 +12,3 @@ pub async fn landing_page (app_state: web::Data<AppState<'_>>) -> HttpResponse {
|
|||
.content_type(ContentType::html())
|
||||
.body(content)
|
||||
}
|
||||
|
||||
pub async fn start_webauthn_registration(app_state: web::Data<AppState<'_>>, user: web::Json<User>) -> HttpResponse {
|
||||
let (creation_challenge_response, passkey_registration) = app_state.webauthn.start_passkey_registration(user.uuid, &user.user_name, &user.display_name, None).unwrap();
|
||||
let uuid_str = user.uuid.to_string();
|
||||
{
|
||||
let mut session = app_state.session.user_registrations.write().await;
|
||||
session.insert(user.clone(), passkey_registration);
|
||||
}
|
||||
{
|
||||
let mut uuid_db = app_state.db.user_uuid_object.write().await;
|
||||
uuid_db.insert(user.uuid, user.into_inner());
|
||||
}
|
||||
let res = serde_json::to_string(&creation_challenge_response).unwrap();
|
||||
let cookie = Cookie::build("uuid", &uuid_str)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish();
|
||||
HttpResponse::Ok()
|
||||
.content_type(ContentType::json())
|
||||
.insert_header((header::SET_COOKIE, cookie.encoded().to_string()))
|
||||
.body(res)
|
||||
}
|
||||
|
||||
pub async fn finish_webauthn_registration(req: HttpRequest, app_state: web::Data<AppState<'_>>, register: web::Json<RegisterPublicKeyCredential>) -> impl actix_web::Responder {
|
||||
let cook = req.cookie("uuid");
|
||||
let uuid = Uuid::parse_str(cook.unwrap().value()).unwrap();
|
||||
let registration_result = {
|
||||
let users = app_state.db.user_uuid_object.read().await;
|
||||
let user = users.get(&uuid).unwrap();
|
||||
let session = app_state.session.user_registrations.read().await;
|
||||
let passkey_registration = session.get(user).unwrap();
|
||||
app_state.webauthn.finish_passkey_registration(®ister, passkey_registration)
|
||||
};
|
||||
let mut user_keys = app_state.db.user_keys.write().await;
|
||||
match registration_result {
|
||||
Ok(passkey) => {
|
||||
user_keys.insert(uuid, passkey);
|
||||
HttpResponse::Ok()
|
||||
.body("ok")
|
||||
},
|
||||
Err(_) =>
|
||||
HttpResponse::from_error(error::ErrorUnauthorized("Webauthn challenge failed"))
|
||||
}
|
||||
}
|
||||
|
|
226
src/handlers/webauthn.rs
Normal file
226
src/handlers/webauthn.rs
Normal file
|
@ -0,0 +1,226 @@
|
|||
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, Passkey, RequestChallengeResponse, PublicKeyCredential};
|
||||
|
||||
use crate::{models::{AppState, RegistrationUuid, Key, LoginUuid, SessionUuid}, templates};
|
||||
|
||||
/// Page you get after clicking a register-user link.
|
||||
///
|
||||
/// The path contains the registration Uuid segment.
|
||||
pub async fn webauthn_registration(app_state: web::Data<AppState<'_>>, path: web::Path<String>) -> HttpResponse {
|
||||
let uuid: RegistrationUuid = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(p) => RegistrationUuid(p),
|
||||
Err(_) => return HttpResponse::from_error(error::ErrorBadRequest("This registration token is invalid: invalid UUID")),
|
||||
};
|
||||
let user = app_state.retrieve_registration_user(&uuid).await;
|
||||
let user = match user {
|
||||
Ok(Some(user)) => user,
|
||||
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.uuid).await {
|
||||
Ok(keys) => keys.into_iter().map(|k| k.name).collect(),
|
||||
Err(e) => {
|
||||
eprintln!("webauthn_registration: error while retrieving keys: {}", e);
|
||||
return HttpResponse::from_error(error::ErrorInternalServerError("Cannot retrieve keys associated to your user."));
|
||||
}
|
||||
};
|
||||
let response = templates::register_user_start(app_state.hbs.clone(), uuid, user.name, keys).unwrap();
|
||||
HttpResponse::Ok()
|
||||
.content_type(ContentType::html())
|
||||
.body(response)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InitRegistrationBody {
|
||||
uuid: RegistrationUuid
|
||||
}
|
||||
|
||||
/// First phase of the webauthn key enrolling.
|
||||
///
|
||||
/// 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<AppState<'_>>, body: web::Json<InitRegistrationBody>) -> HttpResponse {
|
||||
let res = app_state.retrieve_registration_user(&body.uuid).await;
|
||||
let user = match res {
|
||||
Err(e) => {
|
||||
eprintln!("start_webauthn_registration: internal server error {}", e);
|
||||
return HttpResponse::from_error(error::ErrorInternalServerError("Internal server error"))
|
||||
},
|
||||
// Note: there's a constraint set in the DB. We shouldn't end up in this branch.
|
||||
Ok(None) => return HttpResponse::from_error(error::ErrorInternalServerError("Can't find the user attached to this registration.")),
|
||||
Ok(Some(user)) => user
|
||||
};
|
||||
let (creation_challenge_response, passkey_registration) = app_state.webauthn.start_passkey_registration(user.uuid.0, &user.name, &user.name, None).unwrap();
|
||||
{
|
||||
let mut user_registrations = app_state.session.user_registrations.write().await;
|
||||
user_registrations.insert(body.uuid.clone(), passkey_registration);
|
||||
}
|
||||
let res = serde_json::to_string(&creation_challenge_response).unwrap();
|
||||
HttpResponse::Ok()
|
||||
.content_type(ContentType::json())
|
||||
.body(res)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RegisterWebauthnPayload {
|
||||
uuid: RegistrationUuid,
|
||||
keyname: String,
|
||||
credentials: RegisterPublicKeyCredential
|
||||
}
|
||||
|
||||
/// Final phase of the webauthn key enrolling.
|
||||
///
|
||||
/// We verify the key enrolling challenge, then store the new key in the DB.
|
||||
pub async fn finish_webauthn_registration(app_state: web::Data<AppState<'_>>, register: web::Json<RegisterWebauthnPayload>) -> impl actix_web::Responder {
|
||||
let passkey_registration: Option<PasskeyRegistration> = {
|
||||
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 {
|
||||
Ok(Some(usr)) => usr,
|
||||
// Note: there's a constraint set in the DB. We shouldn't end up in this branch.
|
||||
Ok(None) => return HttpResponse::from_error(error::ErrorInternalServerError("Can't find the user attached to this registration.")),
|
||||
Err(e) => {
|
||||
eprintln!("finish_webauthn_registration: internal server error {}", e);
|
||||
return HttpResponse::from_error(error::ErrorInternalServerError("Internal server error"))
|
||||
}
|
||||
};
|
||||
|
||||
match passkey_registration {
|
||||
Some(passkey) => {
|
||||
|
||||
let registration_result = app_state.webauthn.finish_passkey_registration(®ister.credentials, &passkey);
|
||||
match registration_result {
|
||||
Ok(passkey) => {
|
||||
let passkey = Key {
|
||||
name: register.keyname.clone(),
|
||||
key: passkey,
|
||||
};
|
||||
// TODO: add cookie to the answer to auth the
|
||||
// user? Maybe rather later, when we're sure we
|
||||
// have all the keys required.
|
||||
app_state.save_user_key(&user.uuid, &passkey).await.unwrap();
|
||||
HttpResponse::Ok()
|
||||
.body("ok")
|
||||
},
|
||||
Err(_) =>
|
||||
HttpResponse::from_error(error::ErrorUnauthorized("Webauthn challenge failed"))
|
||||
}
|
||||
},
|
||||
None => {
|
||||
HttpResponse::from_error(error::ErrorInternalServerError("Session expired"))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn webauthn_login(app_state: web::Data<AppState<'_>>) -> 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<AppState<'_>>, body: web::Json<InitLoginBody>) -> 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<Passkey> = 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<AppState<'_>>, body: web::Json<FinishLoginBody>) -> 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)
|
||||
}
|
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub mod app;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod templates;
|
36
src/main.rs
36
src/main.rs
|
@ -1,36 +0,0 @@
|
|||
use std::net::SocketAddr;
|
||||
use actix_web::{App, web, HttpServer};
|
||||
use clap::Parser;
|
||||
|
||||
mod app;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod templates;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct CLIArgs {
|
||||
#[arg(short, long)]
|
||||
bind: String
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let args = CLIArgs::parse();
|
||||
let addr: SocketAddr = args.bind.parse().unwrap_or_else(|_| panic!("Cannot bind to {}. Please provide a host and port like [::1]:8000", &args.bind));
|
||||
|
||||
println!("Server listening to {}", &args.bind);
|
||||
|
||||
HttpServer::new(
|
||||
|| {
|
||||
let state = models::AppState::new();
|
||||
App::new().app_data(web::Data::new(state))
|
||||
.route("/", web::get().to(handlers::landing_page))
|
||||
.route("/account/register-init", web::post().to(handlers::start_webauthn_registration))
|
||||
.route("/account/register-finish", web::post().to(handlers::finish_webauthn_registration))
|
||||
})
|
||||
.bind(addr)
|
||||
.unwrap()
|
||||
.run()
|
||||
.await
|
||||
}
|
127
src/models/authentication.rs
Normal file
127
src/models/authentication.rs
Normal file
|
@ -0,0 +1,127 @@
|
|||
use super::{UserUuid, RegistrationUuid, User, Key};
|
||||
use anyhow::Result;
|
||||
use deadpool_postgres::Pool;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::AuthenticationResult;
|
||||
|
||||
/**
|
||||
Generate a new registration uuid for the given `UserUuid` and
|
||||
saves it to the DB.
|
||||
*/
|
||||
pub async fn generate_registration_uuid(db: &Pool, user_id: &UserUuid) -> Result<RegistrationUuid> {
|
||||
let registration_uuid = Uuid::new_v4();
|
||||
let mut conn = db.get().await?;
|
||||
let client = conn.as_mut();
|
||||
let stmt = client.prepare_cached("INSERT INTO PendingRegistrations (id, user_id) VALUES ($1, $2)").await?;
|
||||
client.query(&stmt, &[®istration_uuid, &user_id.0]).await?;
|
||||
Ok(RegistrationUuid(registration_uuid))
|
||||
}
|
||||
|
||||
/**
|
||||
Retrieves the registred `User` attached to the `RegistrationUuid`.
|
||||
|
||||
Returns `Some` `User` if it can find it, `None` if the `RegistrationUuid`
|
||||
doesn't exist in the registration table.
|
||||
*/
|
||||
pub async fn retrieve_registration_user(db: &Pool, registration_id: &RegistrationUuid) -> Result<Option<User>> {
|
||||
let mut conn = db.get().await?;
|
||||
let client = conn.as_mut();
|
||||
let stmt = client.prepare_cached("SELECT u.id, u.user_name FROM Users u INNER JOIN PendingRegistrations r ON r.user_id = u.id WHERE r.id=$1").await?;
|
||||
let row = client.query_one(&stmt, &[®istration_id.0]).await?;
|
||||
let usr = User {
|
||||
uuid: row.get(0),
|
||||
name: row.get(1),
|
||||
};
|
||||
Ok(Some(usr))
|
||||
}
|
||||
|
||||
/**
|
||||
Deletes the registration `Uuid` attached to the `UserUuid` passed in
|
||||
parameter.
|
||||
*/
|
||||
pub async fn delete_registration_link(db: &Pool, user_id: &RegistrationUuid) -> Result<()> {
|
||||
let mut conn = db.get().await?;
|
||||
let client = conn.as_mut();
|
||||
let stmt = client.prepare_cached("DELETE FROM PendingRegistrations WHERE id=$1").await?;
|
||||
client.query(&stmt, &[&user_id.0]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user(db: &Pool, username: &str) -> Result<User> {
|
||||
let mut conn = db.get().await?;
|
||||
let client = conn.as_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(db: &Pool, user: &User) -> Result<()> {
|
||||
let mut conn = db.get().await?;
|
||||
let client = conn.as_mut();
|
||||
let stmt = client.prepare_cached("INSERT INTO Users (id, user_name) VALUES ($1, $2)").await?;
|
||||
let _ = client.query(&stmt, &[&user.uuid.0, &user.name]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_user_key(db: &Pool, user_id: &UserUuid, passkey: &Key) -> Result<()> {
|
||||
let passkey_json: Value = serde_json::to_value(&passkey.key).unwrap();
|
||||
let mut conn = db.get().await?;
|
||||
let client = conn.as_mut();
|
||||
let stmt = client.prepare_cached("INSERT INTO Keys (user_id, name, key_dump) VALUES ($1, $2, $3)").await?;
|
||||
client.query(&stmt, &[&user_id.0, &passkey.name, &passkey_json]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_keys(db: &Pool, user_id: &UserUuid) -> Result<Vec<Key>> {
|
||||
let mut conn = db.get().await?;
|
||||
let client = conn.as_mut();
|
||||
let stmt = client.prepare_cached("SELECT name, key_dump FROM Keys WHERE user_id = $1").await?;
|
||||
let res = client.query(&stmt, &[&user_id.0]).await?;
|
||||
let res = res.iter().map(|row| {
|
||||
let key: Value = row.get(1);
|
||||
Key {
|
||||
name: row.get(0),
|
||||
// We can safely assume the JSON was valid, postgres
|
||||
// would reject it if it wasn't. We can safely unwrap
|
||||
// here.
|
||||
key: serde_json::from_value(key).unwrap()
|
||||
}}).collect();
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/**
|
||||
Updates the keys for the user `UserUuid` according to the last
|
||||
successful login `AuthenticationResult`.
|
||||
*/
|
||||
pub async fn update_user_keys(db: &Pool, user_id: &UserUuid, auth_res: AuthenticationResult) -> Result<()> {
|
||||
let mut conn = db.get().await?;
|
||||
let transaction = conn.as_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<Key> = 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(())
|
||||
}
|
|
@ -1,64 +1,172 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::Arc;
|
||||
use postgres_types::{FromSql, ToSql};
|
||||
use url::Url;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use anyhow::{Result, Context};
|
||||
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
|
||||
use handlebars::Handlebars;
|
||||
use webauthn_rs::prelude::{Uuid, PasskeyRegistration, Passkey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_postgres::NoTls;
|
||||
use webauthn_rs::prelude::{Uuid, PasskeyRegistration, Passkey, PasskeyAuthentication, AuthenticationResult};
|
||||
use webauthn_rs::{Webauthn, WebauthnBuilder};
|
||||
|
||||
mod authentication;
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Configuration {
|
||||
pub url: String,
|
||||
pub db_host: Option<String>,
|
||||
pub db_port: Option<u16>,
|
||||
pub db_name: String
|
||||
}
|
||||
|
||||
|
||||
pub fn read_config(config_path: &str) -> anyhow::Result<Configuration> {
|
||||
let content = fs::read_to_string(config_path)
|
||||
.with_context(|| format!("Cannot read the configuration file at {}.", config_path))?;
|
||||
let res: Configuration = serde_json::from_str(&content)
|
||||
.context("Cannot parse JSON configuration.")?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub type DbField<T> = Arc<RwLock<T>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pub user_keys: DbField<HashMap<Uuid, Passkey>>,
|
||||
pub user_uuid_object: DbField<HashMap<Uuid, User>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TempSession {
|
||||
pub user_registrations: Arc<RwLock<HashMap<User,PasskeyRegistration>>>
|
||||
pub user_registrations: Arc<RwLock<HashMap<RegistrationUuid,PasskeyRegistration>>>,
|
||||
pub user_pending_logins: Arc<RwLock<HashMap<LoginUuid,(PasskeyAuthentication, User)>>>,
|
||||
pub user_sessions: Arc<RwLock<HashMap<SessionUuid,User>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
#[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 {
|
||||
pub uuid: Uuid,
|
||||
pub user_name: String,
|
||||
pub display_name: String,
|
||||
pub uuid: UserUuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppState<'a>{
|
||||
pub webauthn: Arc<Webauthn>,
|
||||
pub db: Db,
|
||||
pub db: Pool,
|
||||
pub hbs: Arc<Handlebars<'a>>,
|
||||
pub session: TempSession
|
||||
}
|
||||
|
||||
mod embedded {
|
||||
use refinery::embed_migrations;
|
||||
embed_migrations!();
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Key {
|
||||
pub name: String,
|
||||
pub key: Passkey
|
||||
}
|
||||
|
||||
impl AppState<'_> {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(conf: Configuration) -> Self {
|
||||
let rp = "localhost";
|
||||
let rp_origin = Url::parse("http://localhost:8000").expect("Invalid URL");
|
||||
let rp_origin = Url::parse(&conf.url).expect("Invalid URL");
|
||||
let builder = WebauthnBuilder::new(rp, &rp_origin).expect("Invalid configuration");
|
||||
let builder = builder.rp_name("LocalHost");
|
||||
let webauthn = Arc::new(builder.build().expect("Invalid configuration"));
|
||||
let db: Db = Db {
|
||||
user_keys: Arc::new(RwLock::new(HashMap::new())),
|
||||
user_uuid_object: Arc::new(RwLock::new(HashMap::new()))
|
||||
|
||||
};
|
||||
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());
|
||||
pg_config.dbname(&conf.db_name);
|
||||
pg_config.port(conf.db_port.unwrap());
|
||||
let mgr_config = ManagerConfig {
|
||||
recycling_method: RecyclingMethod::Fast
|
||||
};
|
||||
let mgr = Manager::from_config(pg_config, NoTls, mgr_config);
|
||||
let pool = Pool::builder(mgr).max_size(16).build().unwrap();
|
||||
|
||||
AppState {
|
||||
webauthn,
|
||||
db,
|
||||
db: pool,
|
||||
hbs,
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Generate a new registration uuid for the given `UserUuid` and
|
||||
saves it to the DB.
|
||||
*/
|
||||
pub async fn generate_registration_uuid(&self, user_id: &UserUuid) -> Result<RegistrationUuid> {
|
||||
authentication::generate_registration_uuid(&self.db, user_id).await
|
||||
}
|
||||
|
||||
/**
|
||||
Retrieves the registred `User` attached to the `RegistrationUuid`.
|
||||
|
||||
Returns `Some` `User` if it can find it, `None` if the `RegistrationUuid`
|
||||
doesn't exist in the registration table.
|
||||
*/
|
||||
pub async fn retrieve_registration_user(&self, registration_id: &RegistrationUuid) -> Result<Option<User>> {
|
||||
authentication::retrieve_registration_user(&self.db, registration_id).await
|
||||
}
|
||||
|
||||
/**
|
||||
Deletes the registration `Uuid` attached to the `UserUuid` passed in
|
||||
parameter.
|
||||
*/
|
||||
pub async fn delete_registration_link(&self, user_id: &RegistrationUuid) -> Result<()> {
|
||||
authentication::delete_registration_link(&self.db, user_id).await
|
||||
}
|
||||
|
||||
pub async fn run_migrations(&self) -> Result<()> {
|
||||
let mut conn = self.db.get().await?;
|
||||
let client = conn.deref_mut().deref_mut();
|
||||
let report = embedded::migrations::runner().run_async(client).await?;
|
||||
println!("{:?}", report);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, username: &str) -> Result<User> {
|
||||
authentication::get_user(&self.db, username).await
|
||||
}
|
||||
|
||||
pub async fn save_user(&self, user: &User) -> Result<()> {
|
||||
authentication::save_user(&self.db, user).await
|
||||
}
|
||||
|
||||
pub async fn save_user_key(&self, user_id: &UserUuid, passkey: &Key) -> Result<()> {
|
||||
authentication::save_user_key(&self.db, user_id, passkey).await
|
||||
}
|
||||
|
||||
pub async fn get_user_keys(&self, user_id: &UserUuid) -> Result<Vec<Key>> {
|
||||
authentication::get_user_keys(&self.db, user_id).await
|
||||
}
|
||||
|
||||
/**
|
||||
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<()> {
|
||||
authentication::update_user_keys(&self.db, user_id, auth_res).await
|
||||
}
|
||||
}
|
||||
|
|
46
src/server/bin/main.rs
Normal file
46
src/server/bin/main.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use actix_web::{App, web, HttpServer};
|
||||
use clap::Parser;
|
||||
|
||||
use nom_nom_gc::handlers;
|
||||
use nom_nom_gc::models::read_config;
|
||||
use nom_nom_gc::models;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct CLIArgs {
|
||||
#[arg(short, long)]
|
||||
bind: String,
|
||||
#[arg(short, long)]
|
||||
config: Option<String>
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let args = CLIArgs::parse();
|
||||
let addr: SocketAddr = args.bind.parse().unwrap_or_else(|_| panic!("Cannot bind to {}. Please provide a host and port like [::1]:8000", &args.bind));
|
||||
let config_path = args.config.unwrap_or("/etc/nom-nom-gc/config.json".to_owned());
|
||||
let config = read_config(&config_path)
|
||||
.unwrap_or_else(|e| panic!("Cannot read config file: {}", e));
|
||||
let state = models::AppState::new(config);
|
||||
println!("Running DB migrations");
|
||||
state.run_migrations().await.unwrap_or_else(|e| panic!("Db migration error: {}", e));
|
||||
|
||||
println!("Server listening to {}", &args.bind);
|
||||
HttpServer::new(
|
||||
move || {
|
||||
App::new().app_data(web::Data::new(state.clone()))
|
||||
.route("/", web::get().to(handlers::landing_page))
|
||||
.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()
|
||||
.run()
|
||||
.await
|
||||
}
|
|
@ -1,29 +1,3 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Nom Nom GC</title>
|
||||
<style>{{> css}}</style>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/js-base64@3.7.4/base64.min.js"
|
||||
integrity="sha384-VkKbwLiG7C18stSGuvcw9W0BHk45Ba7P9LJG5c01Yo4BI6qhFoWSa9TQLNA6EOzI"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><h1>Nom Nom GC</h1></li>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/projects">Projects</a></li>
|
||||
<li><a href="/login">Login</a></li>
|
||||
</nav>
|
||||
<main>
|
||||
<div id="main-container">
|
||||
Hello world
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
{{> js}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{#> template }}
|
||||
<p>Hello world, this is the nom nom S3 GC</p>
|
||||
{{ /template }}
|
||||
|
|
4
src/templates/login.hbs
Normal file
4
src/templates/login.hbs
Normal file
|
@ -0,0 +1,4 @@
|
|||
{{#> template js=js }}
|
||||
<h1>Login</h1>
|
||||
<form id="key-form"><label>Username</label><input id="user-name"/><input type="submit" value="Login" id="login-btn"/></form>
|
||||
{{ /template }}
|
|
@ -4,21 +4,57 @@ use handlebars::Handlebars;
|
|||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use crate::models::RegistrationUuid;
|
||||
|
||||
pub fn new<'a>() -> Result<Handlebars<'a>, RenderError> {
|
||||
let rootpath = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let landing_path = rootpath.join("src/templates/landing.hbs");
|
||||
let template_path = rootpath.join("src/templates/template.hbs");
|
||||
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 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("js", 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)
|
||||
}
|
||||
|
||||
pub fn landing_page(hb: Arc<Handlebars<'_>>) -> Result<String, RenderError> {
|
||||
let data = json!({
|
||||
|
||||
});
|
||||
hb.render("landing", &data)
|
||||
}
|
||||
|
||||
pub fn register_user_start(hb: Arc<Handlebars<'_>>, registration_uuid: RegistrationUuid, username: String, keyids: Vec<String>) -> Result<String, RenderError> {
|
||||
let js_data = json!({
|
||||
"registration-uuid": ®istration_uuid.0.to_string(),
|
||||
});
|
||||
let js = hb.render("webauthn-register-js", &js_data)?;
|
||||
let data = json!({
|
||||
"js": js,
|
||||
"username": &username,
|
||||
"keyids": keyids
|
||||
});
|
||||
hb.render("register-user", &data)
|
||||
}
|
||||
|
||||
|
||||
pub fn login(hb: Arc<Handlebars<'_>>, ) -> Result<String, RenderError> {
|
||||
let js_data = json!({
|
||||
});
|
||||
let js = hb.render("webauthn-login-js", &js_data)?;
|
||||
let data = json!({
|
||||
"js": js,
|
||||
});
|
||||
hb.render("login", &data)
|
||||
}
|
||||
|
|
10
src/templates/register-user.hbs
Normal file
10
src/templates/register-user.hbs
Normal file
|
@ -0,0 +1,10 @@
|
|||
{{#> template js=js }}
|
||||
<h1>New user: {{ username }}</h1>
|
||||
<form id="key-form"><label>Key Name</label><input id="key-name"/><input type="submit" value="Enroll FIDO key"/></form>
|
||||
<table>
|
||||
<tr><th>KeyID</th></tr>
|
||||
{{#each keyids}}
|
||||
<tr><td>{{this}}</td></tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
{{ /template }}
|
29
src/templates/template.hbs
Normal file
29
src/templates/template.hbs
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Nom Nom GC</title>
|
||||
<style>{{> css}}</style>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/js-base64@3.7.4/base64.min.js"
|
||||
integrity="sha384-VkKbwLiG7C18stSGuvcw9W0BHk45Ba7P9LJG5c01Yo4BI6qhFoWSa9TQLNA6EOzI"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><h1>Nom Nom GC</h1></li>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/projects">Projects</a></li>
|
||||
<li><a href="/login">Login</a></li>
|
||||
</nav>
|
||||
<main>
|
||||
<div id="main-container">
|
||||
{{> @partial-block }}
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
{{{ js }}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
68
src/templates/webauthn-login.js
Normal file
68
src/templates/webauthn-login.js
Normal file
|
@ -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();
|
||||
});
|
||||
}) ();
|
|
@ -1,8 +1,6 @@
|
|||
const start_webauthn = async () => {
|
||||
const init_data = {
|
||||
uuid: self.crypto.randomUUID(),
|
||||
user_name: "ninjatrappeur",
|
||||
display_name: "ninjatrappeur"
|
||||
uuid: '{{ registration-uuid }}',
|
||||
};
|
||||
const challenge_resp = await fetch("/account/register-init", {
|
||||
method: 'POST',
|
||||
|
@ -15,48 +13,48 @@ const start_webauthn = async () => {
|
|||
}
|
||||
|
||||
const solve_challenge = async (publicKey) => {
|
||||
const encoder = new TextEncoder();
|
||||
// Decode challenge and userid base64 strings into JS arrays.
|
||||
publicKey.publicKey.challenge = Base64.toUint8Array(publicKey.publicKey.challenge);
|
||||
publicKey.publicKey.user.id = Base64.toUint8Array(publicKey.publicKey.user.id);
|
||||
return navigator.credentials.create(publicKey);
|
||||
}
|
||||
|
||||
const finish_auth = async (solvedChallenge) => {
|
||||
const encodeArray = (array) => Base64.fromUint8Array(new Uint8Array(array), true);
|
||||
const encodeArray2 = (array) => btoa(String.fromCharCode(...new Uint8Array(array)));
|
||||
console.log(solvedChallenge);
|
||||
const uinttobase64 = (array) => Base64.fromUint8Array(new Uint8Array(array), true);
|
||||
const encodedSolvedChallenge = {
|
||||
id: solvedChallenge.id,
|
||||
rawId: encodeArray(solvedChallenge.rawId),
|
||||
rawId: uinttobase64(solvedChallenge.rawId),
|
||||
response: {
|
||||
clientDataJSON: encodeArray(solvedChallenge.response.clientDataJSON),
|
||||
attestationObject: encodeArray(solvedChallenge.response.attestationObject)
|
||||
clientDataJSON: uinttobase64(solvedChallenge.response.clientDataJSON),
|
||||
attestationObject: uinttobase64(solvedChallenge.response.attestationObject)
|
||||
},
|
||||
type: solvedChallenge.type
|
||||
};
|
||||
const encodedSolvedChallenge2 = {
|
||||
id: solvedChallenge.id,
|
||||
rawId: encodeArray2(solvedChallenge.rawId),
|
||||
response: {
|
||||
clientDataJSON: encodeArray2(solvedChallenge.response.clientDataJSON),
|
||||
attestationObject: encodeArray2(solvedChallenge.response.attestationObject)
|
||||
},
|
||||
type: solvedChallenge.type
|
||||
};
|
||||
console.log(encodedSolvedChallenge);
|
||||
console.log(encodedSolvedChallenge2);
|
||||
const name = document.getElementById("key-name").value;
|
||||
const resp = await fetch("/account/register-finish", {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(encodedSolvedChallenge),
|
||||
body: JSON.stringify({
|
||||
'uuid': '{{ registration-uuid }}',
|
||||
'keyname': name,
|
||||
'credentials': encodedSolvedChallenge
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
return resp.json();
|
||||
return resp;
|
||||
}
|
||||
|
||||
const perform_webauthn_dance = async () => {
|
||||
const publicKey = await start_webauthn()
|
||||
let solved = await solve_challenge(publicKey);
|
||||
let finished = await finish_auth(solved);
|
||||
}
|
||||
|
||||
// Main
|
||||
(async () => {
|
||||
const publicKey = await start_webauthn()
|
||||
let solved = await solve_challenge(publicKey);
|
||||
let finished = await finish_auth(solved);
|
||||
console.log(solved);
|
||||
const form = document.getElementById("key-form");
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await perform_webauthn_dance();
|
||||
location.reload();
|
||||
});
|
||||
}) ();
|
71
tests/db.rs
Normal file
71
tests/db.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
use std::{process::{Command, Child}, env::temp_dir, path::PathBuf, fs::remove_dir_all, time::Duration};
|
||||
|
||||
use nom_nom_gc::models::{Configuration, AppState, User, UserUuid};
|
||||
use tokio::time::sleep;
|
||||
use uuid::Uuid;
|
||||
use anyhow::Result;
|
||||
|
||||
|
||||
struct TestDB {
|
||||
path: PathBuf,
|
||||
pid: Child,
|
||||
db_name: String,
|
||||
port: u16
|
||||
}
|
||||
|
||||
async fn setup_db() -> Result<TestDB> {
|
||||
let mut dbdir = temp_dir();
|
||||
let dir_uuid = Uuid::new_v4();
|
||||
let db_name = "nom-nom-integration-test".to_string();
|
||||
let port: u16 = 12345;
|
||||
dbdir.push(dir_uuid.to_string());
|
||||
let dbdir_str = dbdir.to_str().unwrap();
|
||||
Command::new("initdb")
|
||||
.arg(&dbdir)
|
||||
.spawn()?
|
||||
.wait()?;
|
||||
let db_proc_handle = Command::new("postgres")
|
||||
.args(["-D", dbdir_str, "-c", &format!("unix_socket_directories={}", dbdir_str), "-c", "listen_addresses=", "-c", &format!("port={}", &port.to_string())])
|
||||
.spawn()?;
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
Command::new("createdb")
|
||||
.args([ "-h", dbdir_str, "-p", &port.to_string(), &db_name])
|
||||
.spawn()?
|
||||
.wait()?;
|
||||
Ok(TestDB{ path: dbdir, pid: db_proc_handle, db_name, port })
|
||||
}
|
||||
|
||||
fn teardown_db(mut db: TestDB) -> Result <()> {
|
||||
println!("Stopping postgres");
|
||||
db.pid.kill()?;
|
||||
remove_dir_all(db.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_db() {
|
||||
let mdb = setup_db().await;
|
||||
let db = mdb.expect("setup db");
|
||||
let mut dbpath = db.path.to_str().unwrap().to_string();
|
||||
dbpath.push('/');
|
||||
let conf = Configuration {
|
||||
url: "http://localhost:9000".to_string(),
|
||||
db_port: Some(db.port),
|
||||
db_host: Some(dbpath),
|
||||
db_name: db.db_name.clone()
|
||||
};
|
||||
|
||||
let state = AppState::new(conf);
|
||||
let migrations_res = state.run_migrations().await;
|
||||
|
||||
let test_user = User { uuid: UserUuid(Uuid::new_v4()), name: "test-user".to_owned() };
|
||||
state.save_user(&test_user).await.expect("should save user");
|
||||
let reg_uuid = state.generate_registration_uuid(&test_user.uuid).await.expect("should generate registration uuid for test user");
|
||||
let usr2 = state.retrieve_registration_user(®_uuid).await.expect("should retrieve user from reg uuid");
|
||||
let usr2 = usr2.expect("should retrieve user from reg uuid");
|
||||
assert_eq!(test_user, usr2);
|
||||
|
||||
teardown_db(db).expect("Failed to teardown DB.");
|
||||
|
||||
migrations_res.expect("migrations should not fail");
|
||||
}
|
Loading…
Reference in a new issue