Add webauthn #8

Merged
pierre merged 10 commits from webauthn into master 2022-04-23 18:25:16 +00:00
21 changed files with 988 additions and 13 deletions

97
Cargo.lock generated
View File

@ -424,10 +424,12 @@ dependencies = [
"actix", "actix",
"actix-identity", "actix-identity",
"actix-web", "actix-web",
"aes-gcm",
"askama", "askama",
"base32", "base32",
"base64", "base64",
"bcrypt", "bcrypt",
"bincode",
"clap", "clap",
"digest 0.10.3", "digest 0.10.3",
"env_logger", "env_logger",
@ -444,8 +446,10 @@ dependencies = [
"serde_yaml", "serde_yaml",
"sha2 0.10.2", "sha2 0.10.2",
"totp_rfc6238", "totp_rfc6238",
"url",
"urlencoding", "urlencoding",
"uuid", "uuid",
"webauthn-rs",
] ]
[[package]] [[package]]
@ -459,6 +463,15 @@ dependencies = [
"getrandom", "getrandom",
] ]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bit_field" name = "bit_field"
version = "0.10.1" version = "0.10.1"
@ -995,6 +1008,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.0.1" version = "1.0.1"
@ -1693,6 +1721,33 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openssl"
version = "0.10.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-sys",
]
[[package]]
name = "openssl-sys"
version = "0.9.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb"
dependencies = [
"autocfg 1.1.0",
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "os_str_bytes" name = "os_str_bytes"
version = "6.0.0" version = "6.0.0"
@ -1831,6 +1886,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "pkg-config"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
[[package]] [[package]]
name = "png" name = "png"
version = "0.17.5" version = "0.17.5"
@ -2101,6 +2162,16 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde_cbor"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
dependencies = [
"half",
"serde",
]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.136" version = "1.0.136"
@ -2539,6 +2610,7 @@ dependencies = [
"idna", "idna",
"matches", "matches",
"percent-encoding", "percent-encoding",
"serde",
] ]
[[package]] [[package]]
@ -2562,6 +2634,12 @@ dependencies = [
"getrandom", "getrandom",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.4" version = "0.9.4"
@ -2644,6 +2722,25 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webauthn-rs"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b266eccb4b32595876f5c73ea443b0516da0b1df72ca07bc08ed9ba7f96ec1"
dependencies = [
"base64",
"nom",
"openssl",
"rand",
"serde",
"serde_cbor",
"serde_derive",
"serde_json",
"thiserror",
"tracing",
"url",
]
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.5" version = "0.1.5"

View File

@ -31,3 +31,7 @@ lazy-regex = "2.3.0"
totp_rfc6238 = "0.5.0" totp_rfc6238 = "0.5.0"
base32 = "0.4.0" base32 = "0.4.0"
qrcode-generator = "4.1.4" qrcode-generator = "4.1.4"
webauthn-rs = "0.3.2"
url = "2.2.2"
aes-gcm = { version = "0.9.4", features = ["aes"] }
bincode = "1.3.3"

View File

@ -26,8 +26,9 @@ Features :
* [x] Bruteforce protection * [x] Bruteforce protection
* [ ] 2 factors authentication * [ ] 2 factors authentication
* [x] TOTP (authenticator app) * [x] TOTP (authenticator app)
* [ ] Using a security key * [x] Using a security key
* [ ] Fully responsive webui * [ ] Fully responsive webui
* [ ] `robots.txt` file to prevent indexing
## Compiling ## Compiling
You will need the Rust toolchain to compile this project. To build it for production, just run: You will need the Rust toolchain to compile this project. To build it for production, just run:

243
assets/js/base64_lib.js Normal file
View File

@ -0,0 +1,243 @@
// From : https://gitlab.com/comunic/comunicconsole/-/raw/master/src/utils/Base64Lib.ts
/*
MIT License
Copyright (c) 2020 Egor Nepomnyaschih
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
// This constant can also be computed with the following algorithm:
const base64abc = [],
A = "A".charCodeAt(0),
a = "a".charCodeAt(0),
n = "0".charCodeAt(0);
for (let i = 0; i < 26; ++i) {
base64abc.push(String.fromCharCode(A + i));
}
for (let i = 0; i < 26; ++i) {
base64abc.push(String.fromCharCode(a + i));
}
for (let i = 0; i < 10; ++i) {
base64abc.push(String.fromCharCode(n + i));
}
base64abc.push("+");
base64abc.push("/");
*/
const base64abc = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"+",
"/",
];
/*
// This constant can also be computed with the following algorithm:
const l = 256, base64codes = new Uint8Array(l);
for (let i = 0; i < l; ++i) {
base64codes[i] = 255; // invalid character
}
base64abc.forEach((char, index) => {
base64codes[char.charCodeAt(0)] = index;
});
base64codes["=".charCodeAt(0)] = 0; // ignored anyway, so we just need to prevent an error
*/
const base64codes = [
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 62, 255,
255, 255, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, 255, 255, 0, 255,
255, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26, 27, 28, 29,
30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
49, 50, 51,
];
function getBase64Code(charCode) {
if (charCode >= base64codes.length) {
throw new Error("Unable to parse base64 string.");
}
const code = base64codes[charCode];
if (code === 255) {
throw new Error("Unable to parse base64 string.");
}
return code;
}
function bytesToBase64(bytes) {
let result = "",
i,
l = bytes.length;
for (i = 2; i < l; i += 3) {
result += base64abc[bytes[i - 2] >> 2];
result += base64abc[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64abc[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)];
result += base64abc[bytes[i] & 0x3f];
}
if (i === l + 1) {
// 1 octet yet to write
result += base64abc[bytes[i - 2] >> 2];
result += base64abc[(bytes[i - 2] & 0x03) << 4];
result += "==";
}
if (i === l) {
// 2 octets yet to write
result += base64abc[bytes[i - 2] >> 2];
result += base64abc[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64abc[(bytes[i - 1] & 0x0f) << 2];
result += "=";
}
return result;
}
function base64ToBytes(str) {
if (str.length % 4 !== 0) {
throw new Error("Unable to parse base64 string.");
}
const index = str.indexOf("=");
if (index !== -1 && index < str.length - 2) {
throw new Error("Unable to parse base64 string.");
}
let missingOctets = str.endsWith("==") ? 2 : str.endsWith("=") ? 1 : 0,
n = str.length,
result = new Uint8Array(3 * (n / 4)),
buffer;
for (let i = 0, j = 0; i < n; i += 4, j += 3) {
buffer =
(getBase64Code(str.charCodeAt(i)) << 18) |
(getBase64Code(str.charCodeAt(i + 1)) << 12) |
(getBase64Code(str.charCodeAt(i + 2)) << 6) |
getBase64Code(str.charCodeAt(i + 3));
result[j] = buffer >> 16;
result[j + 1] = (buffer >> 8) & 0xff;
result[j + 2] = buffer & 0xff;
}
return result.subarray(0, result.length - missingOctets);
}
function base64encode(str, encoder = new TextEncoder()) {
return bytesToBase64(encoder.encode(str));
}
function base64decode(str, decoder = new TextDecoder()) {
return decoder.decode(base64ToBytes(str));
}
// From: https://gitlab.com/comunic/comunicconsole/-/raw/master/src/utils/Base64Utils.ts
/**
* Add padding to base64 string
*
* Based on : https://gist.github.com/catwell/3046205
*
* @param input Input base64, without padding
*/
function base64AddPadding(input) {
const remainder = input.length % 4;
if (remainder === 2) input += "==";
else if (remainder === 3) input += "=";
return input.replaceAll("-", "+").replaceAll("_", "/");
}
/**
* Turn a base64 string without padding into Uint8Array
*
* @param input Input base64 (without padding) string
*/
function base64NoPaddingToUint8Array(input) {
return Uint8Array.from(atob(base64AddPadding(input)), (c) =>
c.charCodeAt(0)
);
}
/**
* Convert a buffer to a base64-encoded string
*
* @param buff Buffer to convert
*/
function ArrayBufferToBase64(buff) {
const arr = new Uint8Array(buff);
return bytesToBase64(arr);
}

View File

@ -57,3 +57,7 @@ pub const OPEN_ID_ACCESS_TOKEN_LEN: usize = 50;
pub const OPEN_ID_ACCESS_TOKEN_TIMEOUT: u64 = 3600; pub const OPEN_ID_ACCESS_TOKEN_TIMEOUT: u64 = 3600;
pub const OPEN_ID_REFRESH_TOKEN_LEN: usize = 120; pub const OPEN_ID_REFRESH_TOKEN_LEN: usize = 120;
pub const OPEN_ID_REFRESH_TOKEN_TIMEOUT: u64 = 360000; pub const OPEN_ID_REFRESH_TOKEN_TIMEOUT: u64 = 360000;
/// Webauthn constants
pub const WEBAUTHN_REGISTER_CHALLENGE_EXPIRE: u64 = 3600;
pub const WEBAUTHN_LOGIN_CHALLENGE_EXPIRE: u64 = 3600;

View File

@ -0,0 +1,33 @@
use actix_identity::Identity;
use actix_web::{HttpResponse, Responder, web};
use webauthn_rs::proto::PublicKeyCredential;
use crate::data::session_identity::{SessionIdentity, SessionStatus};
use crate::data::webauthn_manager::WebAuthManagerReq;
#[derive(serde::Deserialize)]
pub struct AuthWebauthnRequest {
opaque_state: String,
credential: PublicKeyCredential,
}
pub async fn auth_webauthn(id: Identity,
req: web::Json<AuthWebauthnRequest>,
manager: WebAuthManagerReq) -> impl Responder {
if !SessionIdentity(&id).need_2fa_auth() {
return HttpResponse::Unauthorized().json("No 2FA required!");
}
let user_id = SessionIdentity(&id).user_id();
match manager.finish_authentication(&user_id, &req.opaque_state, &req.credential) {
Ok(_) => {
SessionIdentity(&id).set_status(SessionStatus::SignedIn);
HttpResponse::Ok().body("You are authenticated!")
}
Err(e) => {
log::error!("Failed to authenticate user using webauthn! {:?}", e);
HttpResponse::InternalServerError().body("Failed to validate security key!")
}
}
}

View File

@ -12,6 +12,7 @@ use crate::data::login_redirect::LoginRedirect;
use crate::data::remote_ip::RemoteIP; use crate::data::remote_ip::RemoteIP;
use crate::data::session_identity::{SessionIdentity, SessionStatus}; use crate::data::session_identity::{SessionIdentity, SessionStatus};
use crate::data::user::{FactorID, TwoFactor, TwoFactorType, User}; use crate::data::user::{FactorID, TwoFactor, TwoFactorType, User};
use crate::data::webauthn_manager::WebAuthManagerReq;
struct BaseLoginPage<'a> { struct BaseLoginPage<'a> {
danger: Option<String>, danger: Option<String>,
@ -49,6 +50,15 @@ struct LoginWithOTPTemplate<'a> {
factor: &'a TwoFactor, factor: &'a TwoFactor,
} }
#[derive(Template)]
#[template(path = "login/webauthn_input.html")]
struct LoginWithWebauthnTemplate<'a> {
_p: BaseLoginPage<'a>,
factor: &'a TwoFactor,
opaque_state: String,
challenge_json: String,
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct LoginRequestBody { pub struct LoginRequestBody {
@ -328,3 +338,67 @@ pub async fn login_with_otp(id: Identity, query: web::Query<LoginWithOTPQuery>,
factor, factor,
}.render().unwrap()) }.render().unwrap())
} }
#[derive(serde::Deserialize)]
pub struct LoginWithWebauthnQuery {
#[serde(default)]
redirect: LoginRedirect,
id: FactorID,
}
/// Login with Webauthn
pub async fn login_with_webauthn(id: Identity, query: web::Query<LoginWithWebauthnQuery>,
manager: WebAuthManagerReq,
users: web::Data<Addr<UsersActor>>) -> impl Responder {
if !SessionIdentity(&id).need_2fa_auth() {
return redirect_user_for_login(query.redirect.get());
}
let user: User = users.send(users_actor::GetUserRequest(SessionIdentity(&id).user_id()))
.await.unwrap().0.expect("Could not find user!");
let factor = match user.find_factor(&query.id) {
Some(f) => f,
None => return HttpResponse::Ok()
.body(FatalErrorPage { message: "Factor not found!" }.render().unwrap())
};
let key = match &factor.kind {
TwoFactorType::WEBAUTHN(key) => key,
_ => {
return HttpResponse::Ok()
.body(FatalErrorPage { message: "Factor is not a Webauthn key!" }.render().unwrap());
}
};
let challenge = match manager.start_authentication(&user.uid, key) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to generate webauthn challenge! {:?}", e);
return HttpResponse::InternalServerError()
.body(FatalErrorPage { message: "Failed to generate webauthn challenge" }.render().unwrap());
}
};
let challenge_json = match serde_json::to_string(&challenge.login_challenge) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to serialize challenge! {:?}", e);
return HttpResponse::InternalServerError().body("Failed to serialize challenge!");
}
};
HttpResponse::Ok().body(LoginWithWebauthnTemplate {
_p: BaseLoginPage {
danger: None,
success: None,
page_title: "Two-Factor Auth",
app_name: APP_NAME,
redirect_uri: &query.redirect,
},
factor,
opaque_state: challenge.opaque_state,
challenge_json: urlencoding::encode(&challenge_json).to_string(),
}.render().unwrap())
}

View File

@ -1,6 +1,7 @@
pub mod assets_controller; pub mod assets_controller;
pub mod base_controller; pub mod base_controller;
pub mod login_controller; pub mod login_controller;
pub mod login_api;
pub mod settings_controller; pub mod settings_controller;
pub mod admin_controller; pub mod admin_controller;
pub mod admin_api; pub mod admin_api;

View File

@ -1,12 +1,14 @@
use actix::Addr; use actix::Addr;
use actix_web::{HttpResponse, Responder, web}; use actix_web::{HttpResponse, Responder, web};
use uuid::Uuid; use uuid::Uuid;
use webauthn_rs::proto::RegisterPublicKeyCredential;
use crate::actors::users_actor; use crate::actors::users_actor;
use crate::actors::users_actor::UsersActor; use crate::actors::users_actor::UsersActor;
use crate::data::current_user::CurrentUser; use crate::data::current_user::CurrentUser;
use crate::data::totp_key::TotpKey; use crate::data::totp_key::TotpKey;
use crate::data::user::{FactorID, TwoFactor, TwoFactorType, User}; use crate::data::user::{FactorID, TwoFactor, TwoFactorType, User};
use crate::data::webauthn_manager::WebAuthManagerReq;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct AddTOTPRequest { pub struct AddTOTPRequest {
@ -45,6 +47,43 @@ pub async fn save_totp_factor(user: CurrentUser, form: web::Json<AddTOTPRequest>
} }
} }
#[derive(serde::Deserialize)]
pub struct AddWebauthnRequest {
opaque_state: String,
factor_name: String,
credential: RegisterPublicKeyCredential,
}
pub async fn save_webauthn_factor(user: CurrentUser, form: web::Json<AddWebauthnRequest>,
users: web::Data<Addr<UsersActor>>,
manager: WebAuthManagerReq) -> impl Responder {
let key = match manager.finish_registration(
&user,
&form.0.opaque_state,
form.0.credential,
) {
Ok(k) => k,
Err(e) => {
log::error!("Failed to register security key! {:?}", e);
return HttpResponse::InternalServerError().body("Failed to register key!");
}
};
let mut user = User::from(user);
user.add_factor(TwoFactor {
id: FactorID(Uuid::new_v4().to_string()),
name: form.0.factor_name,
kind: TwoFactorType::WEBAUTHN(key),
});
let res = users.send(users_actor::UpdateUserRequest(user)).await.unwrap().0;
if !res {
HttpResponse::InternalServerError().body("Failed to update user information!")
} else {
HttpResponse::Ok().body("Added new factor!")
}
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct DeleteFactorRequest { pub struct DeleteFactorRequest {
id: FactorID, id: FactorID,

View File

@ -9,6 +9,7 @@ use crate::data::app_config::AppConfig;
use crate::data::current_user::CurrentUser; use crate::data::current_user::CurrentUser;
use crate::data::totp_key::TotpKey; use crate::data::totp_key::TotpKey;
use crate::data::user::User; use crate::data::user::User;
use crate::data::webauthn_manager::WebAuthManagerReq;
#[derive(Template)] #[derive(Template)]
#[template(path = "settings/two_factors_page.html")] #[template(path = "settings/two_factors_page.html")]
@ -26,6 +27,13 @@ struct AddTotpPage {
secret_key: String, secret_key: String,
} }
#[derive(Template)]
#[template(path = "settings/add_webauthn_page.html")]
struct AddWebauhtnPage {
_p: BaseSettingsPage,
opaque_state: String,
challenge_json: String,
}
/// Manage two factors authentication methods route /// Manage two factors authentication methods route
pub async fn two_factors_route(user: CurrentUser) -> impl Responder { pub async fn two_factors_route(user: CurrentUser) -> impl Responder {
@ -70,3 +78,34 @@ pub async fn add_totp_factor_route(user: CurrentUser, app_conf: web::Data<AppCon
secret_key: key.get_secret(), secret_key: key.get_secret(),
}.render().unwrap()) }.render().unwrap())
} }
/// Configure a new security key factor
pub async fn add_webauthn_factor_route(user: CurrentUser, manager: WebAuthManagerReq) -> impl Responder {
let registration_request = match manager.start_register(&user) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to request new key! {:?}", e);
return HttpResponse::InternalServerError().body("Failed to generate request for registration!");
}
};
let challenge_json = match serde_json::to_string(&registration_request.creation_challenge) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to serialize challenge! {:?}", e);
return HttpResponse::InternalServerError().body("Failed to serialize challenge!");
}
};
HttpResponse::Ok()
.body(AddWebauhtnPage {
_p: BaseSettingsPage::get(
"New security key",
&user,
None,
None),
opaque_state: registration_request.opaque_state,
challenge_json: urlencoding::encode(&challenge_json).to_string(),
}.render().unwrap())
}

View File

@ -0,0 +1,92 @@
use std::io::ErrorKind;
use aes_gcm::{Aes256Gcm, Key, Nonce};
use aes_gcm::aead::Aead;
use aes_gcm::NewAead;
use rand::Rng;
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::utils::err::Res;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
pub struct CryptoWrapper {
key: Vec<u8>,
}
impl CryptoWrapper {
/// Generate a new memory wrapper
pub fn new_random() -> Self {
Self { key: (0..KEY_LEN).map(|_| { rand::random::<u8>() }).collect() }
}
/// Encrypt some data
pub fn encrypt<T: Serialize + DeserializeOwned>(&self, data: &T) -> Res<String> {
let aes_key = Aes256Gcm::new(Key::from_slice(&self.key));
let nonce_bytes = rand::thread_rng().gen::<[u8; NONCE_LEN]>();
let serialized_data = bincode::serialize(data)?;
let mut enc = aes_key.encrypt(Nonce::from_slice(&nonce_bytes),
serialized_data.as_slice()).unwrap();
enc.extend_from_slice(&nonce_bytes);
Ok(base64::encode(enc))
}
/// Decrypt some data previously encrypted using the [`CryptoWrapper::encrypt`] method
pub fn decrypt<T: DeserializeOwned>(&self, input: &str) -> Res<T> {
let bytes = base64::decode(input)?;
if bytes.len() < NONCE_LEN {
return Err(Box::new(std::io::Error::new(ErrorKind::Other,
"Input string is smaller than nonce!")));
}
let (enc, nonce) = bytes.split_at(bytes.len() - NONCE_LEN);
assert_eq!(nonce.len(), NONCE_LEN);
let aes_key = Aes256Gcm::new(Key::from_slice(&self.key));
let dec = match aes_key.decrypt(Nonce::from_slice(nonce), enc) {
Ok(d) => d,
Err(e) => {
log::error!("Failed to decrypt wrapped data! {:#?}", e);
return Err(Box::new(std::io::Error::new(ErrorKind::Other,
"Failed to decrypt wrapped data!")));
}
};
Ok(bincode::deserialize(&dec)?)
}
}
#[cfg(test)]
mod test {
use crate::data::crypto_wrapper::CryptoWrapper;
#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug)]
struct Message(String);
#[test]
fn encrypt_and_decrypt() {
let wrapper = CryptoWrapper::new_random();
let msg = Message("Pierre was here".to_string());
let enc = wrapper.encrypt(&msg).unwrap();
let dec: Message = wrapper.decrypt(&enc).unwrap();
assert_eq!(dec, msg)
}
#[test]
fn encrypt_and_decrypt_invalid() {
let wrapper_1 = CryptoWrapper::new_random();
let wrapper_2 = CryptoWrapper::new_random();
let msg = Message("Pierre was here".to_string());
let enc = wrapper_1.encrypt(&msg).unwrap();
wrapper_2.decrypt::<Message>(&enc).unwrap_err();
}
}

View File

@ -12,7 +12,7 @@ pub struct IdToken {
/// REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain identifiers for other audiences. In the general case, the aud value is an array of case sensitive strings. In the common special case when there is one audience, the aud value MAY be a single case sensitive string. /// REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain identifiers for other audiences. In the general case, the aud value is an array of case sensitive strings. In the common special case when there is one audience, the aud value MAY be a single case sensitive string.
#[serde(rename = "aud")] #[serde(rename = "aud")]
pub audience: String, pub audience: String,
/// REQUIRED. Expiration time on or after which the ID Token MUST NOT be accepted for processing. The processing of this parameter requires that the current date/time MUST be before the expiration date/time listed in the value. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. See RFC 3339 [RFC3339] for details regarding date/times in general and UTC in particular. /// REQUIRED. Expiration time on or after which the ID Token MUST NOT be accepted for processing. The processing of this parameter requires that the current date/time MUST be before the expiration date/time listed in the value. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. See RFC 3339 for details regarding date/times in general and UTC in particular.
#[serde(rename = "exp")] #[serde(rename = "exp")]
pub expiration_time: u64, pub expiration_time: u64,
/// REQUIRED. Time at which the JWT was issued. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. /// REQUIRED. Time at which the JWT was issued. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.

View File

@ -13,3 +13,5 @@ pub mod open_id_user_info;
pub mod access_token; pub mod access_token;
pub mod totp_key; pub mod totp_key;
pub mod login_redirect; pub mod login_redirect;
pub mod webauthn_manager;
pub mod crypto_wrapper;

View File

@ -34,7 +34,7 @@ impl TotpKey {
/// Get QrCode URL for user /// Get QrCode URL for user
/// ///
/// Based on https://github.com/google/google-authenticator/wiki/Key-Uri-Format /// Based on <https://github.com/google/google-authenticator/wiki/Key-Uri-Format>
pub fn url_for_user(&self, u: &User, conf: &AppConfig) -> String { pub fn url_for_user(&self, u: &User, conf: &AppConfig) -> String {
format!( format!(
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}", "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}",

View File

@ -2,6 +2,7 @@ use crate::data::client::ClientID;
use crate::data::entity_manager::EntityManager; use crate::data::entity_manager::EntityManager;
use crate::data::login_redirect::LoginRedirect; use crate::data::login_redirect::LoginRedirect;
use crate::data::totp_key::TotpKey; use crate::data::totp_key::TotpKey;
use crate::data::webauthn_manager::WebauthnPubKey;
use crate::utils::err::Res; use crate::utils::err::Res;
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
@ -13,7 +14,7 @@ pub struct FactorID(pub String);
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum TwoFactorType { pub enum TwoFactorType {
TOTP(TotpKey), TOTP(TotpKey),
_OTHER, WEBAUTHN(WebauthnPubKey),
} }
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -27,7 +28,7 @@ impl TwoFactor {
pub fn type_str(&self) -> &'static str { pub fn type_str(&self) -> &'static str {
match self.kind { match self.kind {
TwoFactorType::TOTP(_) => "Authenticator app", TwoFactorType::TOTP(_) => "Authenticator app",
_ => unimplemented!() TwoFactorType::WEBAUTHN(_) => "Security key",
} }
} }
@ -35,7 +36,8 @@ impl TwoFactor {
match self.kind { match self.kind {
TwoFactorType::TOTP(_) => format!("/2fa_otp?id={}&redirect={}", TwoFactorType::TOTP(_) => format!("/2fa_otp?id={}&redirect={}",
self.id.0, redirect_uri.get_encoded()), self.id.0, redirect_uri.get_encoded()),
_ => unimplemented!() TwoFactorType::WEBAUTHN(_) => format!("/2fa_webauthn?id={}&redirect={}",
self.id.0, redirect_uri.get_encoded()),
} }
} }
} }

View File

@ -0,0 +1,154 @@
use std::io::ErrorKind;
use std::sync::Arc;
use actix_web::web;
use webauthn_rs::{AuthenticationState, RegistrationState, Webauthn, WebauthnConfig};
use webauthn_rs::proto::{CreationChallengeResponse, Credential, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse};
use crate::constants::{APP_NAME, WEBAUTHN_LOGIN_CHALLENGE_EXPIRE, WEBAUTHN_REGISTER_CHALLENGE_EXPIRE};
use crate::data::app_config::AppConfig;
use crate::data::crypto_wrapper::CryptoWrapper;
use crate::data::user::{User, UserID};
use crate::utils::err::Res;
use crate::utils::time::time;
#[derive(Debug)]
struct WebAuthnAppConfig {
origin: url::Url,
relying_party_id: String,
}
impl WebauthnConfig for WebAuthnAppConfig {
fn get_relying_party_name(&self) -> &str {
APP_NAME
}
fn get_origin(&self) -> &url::Url {
&self.origin
}
fn get_relying_party_id(&self) -> &str {
&self.relying_party_id
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct WebauthnPubKey {
creds: Credential,
}
pub struct RegisterKeyRequest {
pub opaque_state: String,
pub creation_challenge: CreationChallengeResponse,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct RegisterKeyOpaqueData {
registration_state: RegistrationState,
user_id: UserID,
expire: u64,
}
pub struct AuthRequest {
pub opaque_state: String,
pub login_challenge: RequestChallengeResponse,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct AuthStateOpaqueData {
authentication_state: AuthenticationState,
user_id: UserID,
expire: u64,
}
pub type WebAuthManagerReq = web::Data<Arc<WebAuthManager>>;
pub struct WebAuthManager {
core: Webauthn<WebAuthnAppConfig>,
crypto_wrapper: CryptoWrapper,
}
impl WebAuthManager {
pub fn init(conf: &AppConfig) -> Self {
Self {
core: Webauthn::new(WebAuthnAppConfig {
origin: url::Url::parse(&conf.website_origin)
.expect("Failed to parse configuration origin!"),
relying_party_id: conf.domain_name().split_once(':')
.map(|s| s.0)
.unwrap_or_else(|| conf.domain_name())
.to_string(),
}),
crypto_wrapper: CryptoWrapper::new_random(),
}
}
pub fn start_register(&self, user: &User) -> Res<RegisterKeyRequest> {
let (creation_challenge, registration_state) = self.core.generate_challenge_register(
&user.username,
false,
)?;
Ok(RegisterKeyRequest {
opaque_state: self.crypto_wrapper.encrypt(&RegisterKeyOpaqueData {
registration_state,
user_id: user.uid.clone(),
expire: time() + WEBAUTHN_REGISTER_CHALLENGE_EXPIRE,
})?,
creation_challenge,
})
}
pub fn finish_registration(&self, user: &User, opaque_state: &str,
pub_cred: RegisterPublicKeyCredential) -> Res<WebauthnPubKey> {
let state: RegisterKeyOpaqueData = self.crypto_wrapper.decrypt(opaque_state)?;
if state.user_id != user.uid {
return Err(Box::new(
std::io::Error::new(ErrorKind::Other, "Invalid user for pubkey!")));
}
if state.expire < time() {
return Err(Box::new(
std::io::Error::new(ErrorKind::Other, "Challenge has expired!")));
}
let res = self.core
.register_credential(&pub_cred, &state.registration_state, |_| Ok(false))?;
Ok(WebauthnPubKey { creds: res.0 })
}
pub fn start_authentication(&self, user_id: &UserID, key: &WebauthnPubKey) -> Res<AuthRequest> {
let (login_challenge, authentication_state) = self.core.generate_challenge_authenticate(vec![
key.creds.clone()
])?;
Ok(AuthRequest {
opaque_state: self.crypto_wrapper.encrypt(&AuthStateOpaqueData {
authentication_state,
user_id: user_id.clone(),
expire: time() + WEBAUTHN_LOGIN_CHALLENGE_EXPIRE,
})?,
login_challenge,
})
}
pub fn finish_authentication(&self, user_id: &UserID, opaque_state: &str,
pub_cred: &PublicKeyCredential) -> Res {
let state: AuthStateOpaqueData = self.crypto_wrapper.decrypt(opaque_state)?;
if &state.user_id != user_id {
return Err(Box::new(
std::io::Error::new(ErrorKind::Other, "Invalid user for pubkey!")));
}
if state.expire < time() {
return Err(Box::new(
std::io::Error::new(ErrorKind::Other, "Challenge has expired!")));
}
self.core.authenticate_credential(pub_cred, &state.authentication_state)?;
Ok(())
}
}

View File

@ -1,3 +1,5 @@
use std::sync::Arc;
use actix::Actor; use actix::Actor;
use actix_identity::{CookieIdentityPolicy, IdentityService}; use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{App, get, HttpResponse, HttpServer, web}; use actix_web::{App, get, HttpResponse, HttpServer, web};
@ -17,6 +19,7 @@ use basic_oidc::data::client::ClientManager;
use basic_oidc::data::entity_manager::EntityManager; use basic_oidc::data::entity_manager::EntityManager;
use basic_oidc::data::jwt_signer::JWTSigner; use basic_oidc::data::jwt_signer::JWTSigner;
use basic_oidc::data::user::{hash_password, User}; use basic_oidc::data::user::{hash_password, User};
use basic_oidc::data::webauthn_manager::WebAuthManager;
use basic_oidc::middlewares::auth_middleware::AuthMiddleware; use basic_oidc::middlewares::auth_middleware::AuthMiddleware;
#[get("/health")] #[get("/health")]
@ -68,6 +71,7 @@ async fn main() -> std::io::Result<()> {
let openid_sessions_actor = OpenIDSessionsActor::default().start(); let openid_sessions_actor = OpenIDSessionsActor::default().start();
let jwt_signer = JWTSigner::gen_from_memory() let jwt_signer = JWTSigner::gen_from_memory()
.expect("Failed to generate JWKS key"); .expect("Failed to generate JWKS key");
let webauthn_manager = Arc::new(WebAuthManager::init(&config));
log::info!("Server will listen on {}", config.listen_address); log::info!("Server will listen on {}", config.listen_address);
let listen_address = config.listen_address.to_string(); let listen_address = config.listen_address.to_string();
@ -91,6 +95,7 @@ async fn main() -> std::io::Result<()> {
.app_data(web::Data::new(config.clone())) .app_data(web::Data::new(config.clone()))
.app_data(web::Data::new(clients)) .app_data(web::Data::new(clients))
.app_data(web::Data::new(jwt_signer.clone())) .app_data(web::Data::new(jwt_signer.clone()))
.app_data(web::Data::new(webauthn_manager.clone()))
.wrap(Logger::default()) .wrap(Logger::default())
.wrap(AuthMiddleware {}) .wrap(AuthMiddleware {})
@ -106,7 +111,8 @@ async fn main() -> std::io::Result<()> {
// Assets serving // Assets serving
.route("/assets/{path:.*}", web::get().to(assets_route)) .route("/assets/{path:.*}", web::get().to(assets_route))
// Login page // Login pages
.route("/logout", web::get().to(login_controller::logout_route))
.route("/login", web::get().to(login_controller::login_route)) .route("/login", web::get().to(login_controller::login_route))
.route("/login", web::post().to(login_controller::login_route)) .route("/login", web::post().to(login_controller::login_route))
.route("/reset_password", web::get().to(login_controller::reset_password_route)) .route("/reset_password", web::get().to(login_controller::reset_password_route))
@ -114,9 +120,10 @@ async fn main() -> std::io::Result<()> {
.route("/2fa_auth", web::get().to(login_controller::choose_2fa_method)) .route("/2fa_auth", web::get().to(login_controller::choose_2fa_method))
.route("/2fa_otp", web::get().to(login_controller::login_with_otp)) .route("/2fa_otp", web::get().to(login_controller::login_with_otp))
.route("/2fa_otp", web::post().to(login_controller::login_with_otp)) .route("/2fa_otp", web::post().to(login_controller::login_with_otp))
.route("/2fa_webauthn", web::get().to(login_controller::login_with_webauthn))
// Logout page // Login api
.route("/logout", web::get().to(login_controller::logout_route)) .route("/login/api/auth_webauthn", web::post().to(login_api::auth_webauthn))
// Settings routes // Settings routes
.route("/settings", web::get().to(settings_controller::account_settings_details_route)) .route("/settings", web::get().to(settings_controller::account_settings_details_route))
@ -124,9 +131,11 @@ async fn main() -> std::io::Result<()> {
.route("/settings/change_password", web::post().to(settings_controller::change_password_route)) .route("/settings/change_password", web::post().to(settings_controller::change_password_route))
.route("/settings/two_factors", web::get().to(two_factors_controller::two_factors_route)) .route("/settings/two_factors", web::get().to(two_factors_controller::two_factors_route))
.route("/settings/two_factors/add_totp", web::get().to(two_factors_controller::add_totp_factor_route)) .route("/settings/two_factors/add_totp", web::get().to(two_factors_controller::add_totp_factor_route))
.route("/settings/two_factors/add_webauthn", web::get().to(two_factors_controller::add_webauthn_factor_route))
// User API // User API
.route("/settings/api/two_factor/save_totp_factor", web::post().to(two_factor_api::save_totp_factor)) .route("/settings/api/two_factor/save_totp_factor", web::post().to(two_factor_api::save_totp_factor))
.route("/settings/api/two_factor/save_webauthn_factor", web::post().to(two_factor_api::save_webauthn_factor))
.route("/settings/api/two_factor/delete_factor", web::post().to(two_factor_api::delete_factor)) .route("/settings/api/two_factor/delete_factor", web::post().to(two_factor_api::delete_factor))
// Admin routes // Admin routes

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div> <div>
<p>You need to validate a second factor to validate your login.</p> <p>You need to validate a second factor to complete your login.</p>
{% for factor in factors %} {% for factor in factors %}
<p> <p>

View File

@ -0,0 +1,90 @@
{% extends "base_login_page.html" %}
{% block content %}
<p style="color:red" id="err_target"></p>
<div>
<p>Please insert now your security key <i>{{ factor.name }}</i>, and accept authentication request.</p>
</div>
<div style="margin: 10px 0px;">
<input type="button" value="Try again" class="btn btn-primary" onclick="launch_procedure()"/>
</div>
<div style="margin-top: 10px;">
<a href="/2fa_auth?force_display=true&redirect={{ _p.redirect_uri.get_encoded() }}">Sign in using another factor</a><br/>
<a href="/logout">Sign out</a>
</div>
<script src="/assets/js/base64_lib.js"></script>
<script>
const REDIRECT_URI = decodeURIComponent("{{ _p.redirect_uri.get_encoded() }}");
const OPAQUE_STATE = "{{ opaque_state }}";
const AUTH_CHALLENGE = JSON.parse(decodeURIComponent("{{ challenge_json }}"));
// Decode data
AUTH_CHALLENGE.publicKey.challenge = base64NoPaddingToUint8Array(
AUTH_CHALLENGE.publicKey.challenge
);
for (let cred of AUTH_CHALLENGE.publicKey.allowCredentials) {
cred.id = base64NoPaddingToUint8Array(cred.id);
}
function set_error(err) {
const err_target = document.getElementById("err_target");
err_target.innerHTML = err;
}
async function launch_procedure() {
try {
set_error("");
const result = await navigator.credentials.get(AUTH_CHALLENGE);
const creds = {
id: result.id,
rawId: ArrayBufferToBase64(result.rawId),
type: result.type,
response: {
authenticatorData: ArrayBufferToBase64(
result.response.authenticatorData
),
clientDataJSON: ArrayBufferToBase64(
result.response.clientDataJSON
),
signature: ArrayBufferToBase64(result.response.signature),
userHandle: result.response.userHandle,
},
};
const res = await fetch("/login/api/auth_webauthn", {
method: "post",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
opaque_state: OPAQUE_STATE,
credential: creds,
})
});
let text = await res.text();
set_error(text);
if (res.status == 200)
location.href = REDIRECT_URI;
else if(text === "")
set_error("Failed to authenticate you!");
} catch(e) {
console.error(e);
set_error(e);
}
}
window.addEventListener("load", () => launch_procedure())
</script>
{% endblock content %}

View File

@ -0,0 +1,90 @@
{% extends "base_settings_page.html" %}
{% block content %}
<div style="max-width: 700px;">
<p>In order to continue, please click on the "Start Enrollment" button, insert your security key and approve the
registration request.</p>
<div class="form-group">
<label for="inputKeyName" class="form-label mt-4">Key name</label>
<input type="text" class="form-control" id="inputKeyName"
placeholder="Device / Authenticator app name"
value="Security key" minlength="1" required/>
<small class="form-text text-muted">Please give a name to your key to identify it more easily later.</small>
<div class="invalid-feedback">Please give a name to this security key</div>
</div>
<input type="button" class="btn btn-primary" value="Start enrollment" onclick="startEnrollment()"
style="margin-top: 20px;" id="submitButton" />
<script src="/assets/js/base64_lib.js"></script>
<script>
const OPAQUE_STATE = "{{ opaque_state }}";
const REGISTRATION_CHALLENGE = JSON.parse(decodeURIComponent("{{ challenge_json }}"));
// Decode data
REGISTRATION_CHALLENGE.publicKey.challenge = base64NoPaddingToUint8Array(
REGISTRATION_CHALLENGE.publicKey.challenge
);
REGISTRATION_CHALLENGE.publicKey.user.id = base64NoPaddingToUint8Array(
REGISTRATION_CHALLENGE.publicKey.user.id
);
const submitButton = document.getElementById("submitButton");
async function startEnrollment() {
submitButton.disabled = true;
try {
const factorNameInput = document.getElementById("inputKeyName");
factorNameInput.classList.remove("is-invalid");
if (factorNameInput.value.length === 0) {
factorNameInput.classList.add("is-invalid");
return;
}
const cred = await navigator.credentials.create(REGISTRATION_CHALLENGE);
// Encode data that needs to be encoded
const credential_res = {
id: cred.id,
rawId: ArrayBufferToBase64(cred.rawId),
type: cred.type,
response: {
attestationObject: ArrayBufferToBase64(
cred.response.attestationObject
),
clientDataJSON: ArrayBufferToBase64(
cred.response.clientDataJSON
),
},
};
const res = await fetch("/settings/api/two_factor/save_webauthn_factor", {
method: "post",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
opaque_state: OPAQUE_STATE,
factor_name: factorNameInput.value,
credential: credential_res,
})
});
let text = await res.text();
alert(text);
if (res.status == 200)
location.href = "/settings/two_factors";
} catch(e) {
console.error(e);
alert("Failed enrollment, please try again!");
} finally {
submitButton.disabled = false;
}
}
</script>
</div>
{% endblock content %}

View File

@ -12,6 +12,7 @@
<p> <p>
<a href="/settings/two_factors/add_totp" type="button" class="btn btn-primary">Add Authenticator App</a> <a href="/settings/two_factors/add_totp" type="button" class="btn btn-primary">Add Authenticator App</a>
<a href="/settings/two_factors/add_webauthn" type="button" class="btn btn-primary">Add Security Key</a>
</p> </p>
<table class="table table-hover" style="max-width: 800px;" aria-describedby="Factors list"> <table class="table table-hover" style="max-width: 800px;" aria-describedby="Factors list">