From 302377133448db92bf083dd8f88b48032946554e Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Mon, 18 Apr 2022 19:23:43 +0200 Subject: [PATCH 01/22] Start to work on 2FA --- src/controllers/mod.rs | 3 +- src/controllers/settings_controller.rs | 3 +- src/controllers/two_factors_controller.rs | 48 ++++++++++++++++++++++ src/main.rs | 2 + templates/settings/add_2fa_totp_page.html | 6 +++ templates/settings/base_settings_page.html | 5 +++ templates/settings/two_factors_page.html | 18 ++++++++ 7 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/controllers/two_factors_controller.rs create mode 100644 templates/settings/add_2fa_totp_page.html create mode 100644 templates/settings/two_factors_page.html diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 03adad5..1f13b9d 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -4,4 +4,5 @@ pub mod login_controller; pub mod settings_controller; pub mod admin_controller; pub mod admin_api; -pub mod openid_controller; \ No newline at end of file +pub mod openid_controller; +pub mod two_factors_controller; \ No newline at end of file diff --git a/src/controllers/settings_controller.rs b/src/controllers/settings_controller.rs index d30b1f9..46f6db3 100644 --- a/src/controllers/settings_controller.rs +++ b/src/controllers/settings_controller.rs @@ -108,10 +108,9 @@ pub async fn change_password_route(user: CurrentUser, } } - HttpResponse::Ok() .body(ChangePasswordPage { _p: BaseSettingsPage::get("Change password", &user, danger, success), min_pwd_len: MIN_PASS_LEN, }.render().unwrap()) -} \ No newline at end of file +} diff --git a/src/controllers/two_factors_controller.rs b/src/controllers/two_factors_controller.rs new file mode 100644 index 0000000..117241c --- /dev/null +++ b/src/controllers/two_factors_controller.rs @@ -0,0 +1,48 @@ +use std::ops::Deref; + +use actix_web::{HttpResponse, Responder}; +use askama::Template; + +use crate::controllers::settings_controller::BaseSettingsPage; +use crate::data::current_user::CurrentUser; +use crate::data::user::User; + +#[derive(Template)] +#[template(path = "settings/two_factors_page.html")] +struct TwoFactorsPage<'a> { + _p: BaseSettingsPage, + user: &'a User, +} + +#[derive(Template)] +#[template(path = "settings/add_2fa_totp_page.html")] +struct AddTotpPage { + _p: BaseSettingsPage, +} + + +/// Manage two factors authentication methods route +pub async fn two_factors_route(user: CurrentUser) -> impl Responder { + HttpResponse::Ok() + .body(TwoFactorsPage { + _p: BaseSettingsPage::get( + "Two factors auth", + &user, + None, + None), + user: user.deref(), + }.render().unwrap()) +} + + +/// Configure a new TOTP authentication factor +pub async fn add_totp_factor_route(user: CurrentUser) -> impl Responder { + HttpResponse::Ok() + .body(AddTotpPage { + _p: BaseSettingsPage::get( + "Configure new TOTP factor", + &user, + None, + None), + }.render().unwrap()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index a54ecd7..890b1cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,6 +118,8 @@ async fn main() -> std::io::Result<()> { .route("/settings", web::get().to(settings_controller::account_settings_details_route)) .route("/settings/change_password", web::get().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/add_totp", web::get().to(two_factors_controller::add_totp_factor_route)) // Admin routes .route("/admin", web::get() diff --git a/templates/settings/add_2fa_totp_page.html b/templates/settings/add_2fa_totp_page.html new file mode 100644 index 0000000..fc7d906 --- /dev/null +++ b/templates/settings/add_2fa_totp_page.html @@ -0,0 +1,6 @@ +{% extends "base_settings_page.html" %} +{% block content %} + +TODO : show a form to add a new TOTP password + +{% endblock content %} diff --git a/templates/settings/base_settings_page.html b/templates/settings/base_settings_page.html index 66e08d9..7042eeb 100644 --- a/templates/settings/base_settings_page.html +++ b/templates/settings/base_settings_page.html @@ -26,6 +26,11 @@ Change password +
  • + + Two-factor authentication + +
  • {% if _p.is_admin %}
    diff --git a/templates/settings/two_factors_page.html b/templates/settings/two_factors_page.html new file mode 100644 index 0000000..dc90303 --- /dev/null +++ b/templates/settings/two_factors_page.html @@ -0,0 +1,18 @@ +{% extends "base_settings_page.html" %} +{% block content %} + + +
    +

    Warning!

    +

    Once a new factor has been added to your account, you can not access + your account anymore using only your password. If you remove all your second factors, + 2 Factor Authentication is automatically disabled for your account.

    +
    + + +

    + Add One Time Password (OTP) factor +

    +TODO : show the list of currently registered 2 factors methods + +{% endblock content %} From 38eddc1cf0c8206572a864c581fafea0c000c04b Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 09:56:51 +0200 Subject: [PATCH 02/22] Generate QrCode to enroll Authenticator App --- Cargo.lock | 384 +++++++++++++++++++++- Cargo.toml | 5 +- src/controllers/two_factors_controller.rs | 28 +- src/data/app_config.rs | 6 +- src/data/mod.rs | 3 +- src/data/totp_key.rs | 53 +++ templates/settings/add_2fa_totp_page.html | 18 +- 7 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 src/data/totp_key.rs diff --git a/Cargo.lock b/Cargo.lock index 6349c38..a255b19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aead" version = "0.4.3" @@ -393,6 +399,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.13.0" @@ -413,6 +425,7 @@ dependencies = [ "actix-identity", "actix-web", "askama", + "base32", "base64", "bcrypt", "clap", @@ -424,11 +437,13 @@ dependencies = [ "lazy-regex", "log", "mime_guess", + "qrcode-generator", "rand", "serde", "serde_json", "serde_yaml", "sha2 0.10.2", + "totp_rfc6238", "urlencoding", "uuid", ] @@ -444,6 +459,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "bit_field" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb6dd1c2376d2e096796e234a70e17e94cc2d5d54ff8ce42b28cef1d0d359a4" + [[package]] name = "bitflags" version = "1.3.2" @@ -505,6 +526,12 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899" +[[package]] +name = "bytemuck" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdead85bdec19c194affaeeb670c0e41fe23de31459efd1c174d049269cf02cc" + [[package]] name = "byteorder" version = "1.4.3" @@ -611,6 +638,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "const-oid" version = "0.6.2" @@ -675,6 +708,31 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1145cf131a2c6ba0615079ab6a638f7e1973ac9c2634fcbeaaad6114246efe8c" +dependencies = [ + "autocfg 1.1.0", + "cfg-if", + "crossbeam-utils", + "lazy_static", + "memoffset", + "scopeguard", +] + [[package]] name = "crossbeam-utils" version = "0.8.8" @@ -743,6 +801,15 @@ dependencies = [ "cipher 0.3.0", ] +[[package]] +name = "deflate" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" +dependencies = [ + "adler32", +] + [[package]] name = "der" version = "0.4.5" @@ -818,6 +885,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + [[package]] name = "elliptic-curve" version = "0.11.12" @@ -859,6 +932,22 @@ dependencies = [ "termcolor", ] +[[package]] +name = "exr" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14cc0e06fb5f67e5d6beadf3a382fec9baca1aa751c6d5368fdeee7e5932c215" +dependencies = [ + "bit_field", + "deflate", + "flume", + "half", + "inflate", + "lebe", + "smallvec", + "threadpool", +] + [[package]] name = "ff" version = "0.11.0" @@ -884,7 +973,20 @@ dependencies = [ "cfg-if", "crc32fast", "libc", - "miniz_oxide", + "miniz_oxide 0.4.4", +] + +[[package]] +name = "flume" +version = "0.10.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843c03199d0c0ca54bc1ea90ac0d507274c28abcc4f691ae8b4eaa375087c76a" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "pin-project", + "spin 0.9.3", ] [[package]] @@ -963,8 +1065,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.10.2+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -977,6 +1081,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a7187e78088aead22ceedeee99779455b23fc231fe13ec443f99bb71694e5b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "group" version = "0.11.0" @@ -1007,6 +1121,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" + [[package]] name = "hashbrown" version = "0.11.2" @@ -1080,6 +1200,15 @@ dependencies = [ "digest 0.9.0", ] +[[package]] +name = "html-escape" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e7479fa1ef38eb49fb6a42c426be515df2d063f06cb8efd3e50af073dbc26c" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "0.2.6" @@ -1126,6 +1255,26 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db207d030ae38f1eb6f240d5a1c1c88ff422aa005d10f8c6c6fc5e75286ab30e" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-iter", + "num-rational", + "num-traits", + "png", + "scoped_threadpool", + "tiff", +] + [[package]] name = "include_dir" version = "0.7.2" @@ -1155,6 +1304,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inflate" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff" +dependencies = [ + "adler32", +] + [[package]] name = "inout" version = "0.1.2" @@ -1179,6 +1337,24 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744c24117572563a98a7e9168a5ac1ee4a1ca7f702211258797bbe0ed0346c3c" +dependencies = [ + "rayon", +] + +[[package]] +name = "js-sys" +version = "0.3.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "jwt-simple" version = "0.10.9" @@ -1250,9 +1426,15 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" dependencies = [ - "spin", + "spin 0.5.2", ] +[[package]] +name = "lebe" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efd1d698db0759e6ef11a7cd44407407399a910c774dd804c64c032da7826ff" + [[package]] name = "libc" version = "0.2.121" @@ -1319,6 +1501,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg 1.1.0", +] + [[package]] name = "mime" version = "0.3.16" @@ -1351,6 +1542,15 @@ dependencies = [ "autocfg 1.1.0", ] +[[package]] +name = "miniz_oxide" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +dependencies = [ + "adler", +] + [[package]] name = "mio" version = "0.8.2" @@ -1374,6 +1574,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom", +] + [[package]] name = "nom" version = "7.1.1" @@ -1432,6 +1641,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d41702bd167c2df5520b384281bc111a4b5efcf7fbc4c9c222c815b07e0a6a6a" +dependencies = [ + "autocfg 1.1.0", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.14" @@ -1544,6 +1764,26 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pin-project" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.8" @@ -1591,6 +1831,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "png" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc38c0ad57efb786dd57b9864e5b18bae478c00c824dc55a38bbc9da95dde3ba" +dependencies = [ + "bitflags", + "crc32fast", + "deflate", + "miniz_oxide 0.5.1", +] + [[package]] name = "polyval" version = "0.5.3" @@ -1642,6 +1894,23 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "qrcode-generator" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1c0d1ea2ed9730a4037bf2dbc12b4b5c15679171adca65792657d1bd65ef6f" +dependencies = [ + "html-escape", + "image", + "qrcodegen", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + [[package]] name = "quote" version = "1.0.17" @@ -1681,6 +1950,30 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd249e82c21598a9a426a4e00dd7adc1d640b22445ec8545feef801d1a74c221" +dependencies = [ + "autocfg 1.1.0", + "crossbeam-deque", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "num_cpus", +] + [[package]] name = "redox_syscall" version = "0.2.12" @@ -1718,6 +2011,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + [[package]] name = "rsa" version = "0.5.0" @@ -1753,6 +2061,12 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" +[[package]] +name = "scoped_threadpool" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" + [[package]] name = "scopeguard" version = "1.1.0" @@ -1915,6 +2229,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spin" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c530c2b0d0bf8b69304b39fe2001993e267461948b890cd037d8ad4293fa1a0d" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.4.1" @@ -2004,6 +2327,26 @@ dependencies = [ "syn", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tiff" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cfada0986f446a770eca461e8c6566cb879682f7d687c8348aa0c857bd52286" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.9" @@ -2092,6 +2435,15 @@ dependencies = [ "serde", ] +[[package]] +name = "totp_rfc6238" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e7d63d8bc3098dd14e5f1a107979a38e06b3263f1230a3cd717615fab4e615e" +dependencies = [ + "ring", +] + [[package]] name = "tracing" version = "0.1.32" @@ -2171,6 +2523,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "url" version = "2.2.2" @@ -2189,6 +2547,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821" +[[package]] +name = "utf8-width" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" + [[package]] name = "uuid" version = "0.8.2" @@ -2270,6 +2634,22 @@ version = "0.2.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744" +[[package]] +name = "web-sys" +version = "0.3.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b17e741662c70c8bd24ac5c5b18de314a2c26c32bf8346ee1e6f53de919c283" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b77fdfd5a253be4ab714e4ffa3c49caf146b4de743e97510c0656cf90f1e8e" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 760ec49..3bd98c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,4 +27,7 @@ base64 = "0.13.0" jwt-simple = "0.10.9" digest = "0.10.3" sha2 = "0.10.2" -lazy-regex = "2.3.0" \ No newline at end of file +lazy-regex = "2.3.0" +totp_rfc6238 = "0.5.0" +base32 = "0.4.0" +qrcode-generator = "4.1.4" \ No newline at end of file diff --git a/src/controllers/two_factors_controller.rs b/src/controllers/two_factors_controller.rs index 117241c..c33bf88 100644 --- a/src/controllers/two_factors_controller.rs +++ b/src/controllers/two_factors_controller.rs @@ -1,10 +1,13 @@ use std::ops::Deref; -use actix_web::{HttpResponse, Responder}; +use actix_web::{HttpResponse, Responder, web}; use askama::Template; +use qrcode_generator::QrCodeEcc; use crate::controllers::settings_controller::BaseSettingsPage; +use crate::data::app_config::AppConfig; use crate::data::current_user::CurrentUser; +use crate::data::totp_key::TotpKey; use crate::data::user::User; #[derive(Template)] @@ -18,6 +21,9 @@ struct TwoFactorsPage<'a> { #[template(path = "settings/add_2fa_totp_page.html")] struct AddTotpPage { _p: BaseSettingsPage, + qr_code: String, + account_name: String, + secret_key: String, } @@ -36,7 +42,22 @@ pub async fn two_factors_route(user: CurrentUser) -> impl Responder { /// Configure a new TOTP authentication factor -pub async fn add_totp_factor_route(user: CurrentUser) -> impl Responder { +pub async fn add_totp_factor_route(user: CurrentUser, app_conf: web::Data) -> impl Responder { + let key = TotpKey::new_random(); + + let qr_code = qrcode_generator::to_png_to_vec( + key.url_for_user(&user, &app_conf), + QrCodeEcc::Low, + 1024, + ); + let qr_code = match qr_code { + Ok(q) => q, + Err(e) => { + log::error!("Failed to generate QrCode! {:?}", e); + return HttpResponse::InternalServerError().body("Failed to generate QrCode!"); + } + }; + HttpResponse::Ok() .body(AddTotpPage { _p: BaseSettingsPage::get( @@ -44,5 +65,8 @@ pub async fn add_totp_factor_route(user: CurrentUser) -> impl Responder { &user, None, None), + qr_code: base64::encode(qr_code), + account_name: key.account_name(&user, &app_conf), + secret_key: key.get_secret(), }.render().unwrap()) } \ No newline at end of file diff --git a/src/data/app_config.rs b/src/data/app_config.rs index 87a7d22..a822e05 100644 --- a/src/data/app_config.rs +++ b/src/data/app_config.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use clap::Parser; -use crate::constants::{CLIENTS_LIST_FILE, USERS_LIST_FILE}; +use crate::constants::{APP_NAME, CLIENTS_LIST_FILE, USERS_LIST_FILE}; /// Basic OIDC provider #[derive(Parser, Debug, Clone)] @@ -53,4 +53,8 @@ impl AppConfig { format!("{}/{}", self.website_origin, uri) } } + + pub fn domain_name(&self) -> &str { + self.website_origin.split('/').skip(2).next().unwrap_or(APP_NAME) + } } diff --git a/src/data/mod.rs b/src/data/mod.rs index b158b61..edf61a6 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -10,4 +10,5 @@ pub mod jwt_signer; pub mod id_token; pub mod code_challenge; pub mod open_id_user_info; -pub mod access_token; \ No newline at end of file +pub mod access_token; +pub mod totp_key; \ No newline at end of file diff --git a/src/data/totp_key.rs b/src/data/totp_key.rs new file mode 100644 index 0000000..cc61608 --- /dev/null +++ b/src/data/totp_key.rs @@ -0,0 +1,53 @@ +use base32::Alphabet; +use rand::Rng; + +use crate::data::app_config::AppConfig; +use crate::data::user::User; + +const BASE32_ALPHABET: Alphabet = Alphabet::RFC4648 { padding: true }; +const NUM_DIGITS: i32 = 6; +const PERIOD: i32 = 30; + +#[derive(serde::Serialize, serde::Deserialize, Debug)] +pub struct TotpKey { + encoded: String, +} + +impl TotpKey { + /// Generate a new TOTP key + pub fn new_random() -> Self { + let random_bytes = rand::thread_rng().gen::<[u8; 10]>(); + TotpKey { + encoded: base32::encode(BASE32_ALPHABET, &random_bytes) + } + } + + /// Get QrCode URL for user + /// + /// Based on https://github.com/google/google-authenticator/wiki/Key-Uri-Format + pub fn url_for_user(&self, u: &User, conf: &AppConfig) -> String { + format!( + "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}", + urlencoding::encode(&conf.domain_name()), + urlencoding::encode(&u.username), + self.encoded, + urlencoding::encode(&conf.domain_name()), + NUM_DIGITS, + PERIOD, + ) + } + + /// Get account name + pub fn account_name(&self, u: &User, conf: &AppConfig) -> String { + format!( + "{}:{}", + urlencoding::encode(conf.domain_name()), + urlencoding::encode(&u.username) + ) + } + + /// Get current secret in base32 format + pub fn get_secret(&self) -> String { + self.encoded.to_string() + } +} \ No newline at end of file diff --git a/templates/settings/add_2fa_totp_page.html b/templates/settings/add_2fa_totp_page.html index fc7d906..1401201 100644 --- a/templates/settings/add_2fa_totp_page.html +++ b/templates/settings/add_2fa_totp_page.html @@ -1,6 +1,22 @@ {% extends "base_settings_page.html" %} {% block content %} -TODO : show a form to add a new TOTP password +

    On this page you can configure a new Authenticator app. Please use the authenticator app to scan the QR code.

    + +

    Note: if you have not an authenticator app yet, you might want to use + FreeOTP + Authenticator for example.

    + + + +

    If you can't scan the QrCode, please use the following parameters instead:

    +
      +
    • Account name: {{ account_name }}
    • +
    • Secret key: {{ secret_key }}
    • +
    + +

    Once you have scanned the QrCode, please generate a first QrCode and type it below:

    + +TODO : add form {% endblock content %} From 18353f0639eca1add857adb5fe938c3096b7f62b Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 10:10:05 +0200 Subject: [PATCH 03/22] Add form to authenticator page --- assets/js/clipboard_utils.js | 12 ++++++ src/controllers/two_factors_controller.rs | 2 +- templates/settings/add_2fa_totp_page.html | 45 +++++++++++++++++------ templates/settings/two_factors_page.html | 2 +- 4 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 assets/js/clipboard_utils.js diff --git a/assets/js/clipboard_utils.js b/assets/js/clipboard_utils.js new file mode 100644 index 0000000..3677be5 --- /dev/null +++ b/assets/js/clipboard_utils.js @@ -0,0 +1,12 @@ +function copyToClipboard(str) { + const input = document.createElement("input"); + input.value = str; + + document.body.appendChild(input); + + input.select(); + input.setSelectionRange(0, str.length); + document.execCommand("copy"); + + input.remove(); +} diff --git a/src/controllers/two_factors_controller.rs b/src/controllers/two_factors_controller.rs index c33bf88..127185b 100644 --- a/src/controllers/two_factors_controller.rs +++ b/src/controllers/two_factors_controller.rs @@ -61,7 +61,7 @@ pub async fn add_totp_factor_route(user: CurrentUser, app_conf: web::Data -

    On this page you can configure a new Authenticator app. Please use the authenticator app to scan the QR code.

    +

    On this page you can configure a new Authenticator app. Please use an authenticator app to scan the QR code.

    -

    Note: if you have not an authenticator app yet, you might want to use - FreeOTP - Authenticator for example.

    +

    Note: if you have not an authenticator app yet, you might want to use + FreeOTP + Authenticator for example.

    - + -

    If you can't scan the QrCode, please use the following parameters instead:

    -
      -
    • Account name: {{ account_name }}
    • -
    • Secret key: {{ secret_key }}
    • -
    +

    If you can't scan the QrCode, please use the following parameters instead:

    + -

    Once you have scanned the QrCode, please generate a first QrCode and type it below:

    +

    Once you have scanned the QrCode, please generate a code and type it below:

    -TODO : add form +
    + + + Please give a name to your device to identity it more easily later. +
    + +
    + + + Check that your authenticator app is working correctly by typing a first + code. +
    + + + + {% endblock content %} diff --git a/templates/settings/two_factors_page.html b/templates/settings/two_factors_page.html index dc90303..cc7de08 100644 --- a/templates/settings/two_factors_page.html +++ b/templates/settings/two_factors_page.html @@ -11,7 +11,7 @@

    - Add One Time Password (OTP) factor + Add Authenticator App

    TODO : show the list of currently registered 2 factors methods From 65b5c812b1a09775b10d13fdfb95fb55a62cff18 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 11:01:31 +0200 Subject: [PATCH 04/22] Can register Authenticator app --- src/controllers/mod.rs | 3 +- src/controllers/two_factors_api.rs | 41 +++++++++++ src/data/totp_key.rs | 52 ++++++++++++-- src/data/user.rs | 18 +++++ src/main.rs | 5 +- templates/settings/add_2fa_totp_page.html | 86 +++++++++++++++++++---- 6 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 src/controllers/two_factors_api.rs diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 1f13b9d..8efd15e 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -5,4 +5,5 @@ pub mod settings_controller; pub mod admin_controller; pub mod admin_api; pub mod openid_controller; -pub mod two_factors_controller; \ No newline at end of file +pub mod two_factors_controller; +pub mod two_factors_api; \ No newline at end of file diff --git a/src/controllers/two_factors_api.rs b/src/controllers/two_factors_api.rs new file mode 100644 index 0000000..b899873 --- /dev/null +++ b/src/controllers/two_factors_api.rs @@ -0,0 +1,41 @@ +use actix::Addr; +use actix_web::{HttpResponse, Responder, web}; + +use crate::actors::users_actor; +use crate::actors::users_actor::UsersActor; +use crate::data::current_user::CurrentUser; +use crate::data::totp_key::TotpKey; +use crate::data::user::{SecondFactor, User}; + +#[derive(serde::Deserialize)] +pub struct Request { + factor_name: String, + secret: String, + first_code: String, +} + +pub async fn save_totp_key(user: CurrentUser, form: web::Json, + users: web::Data>) -> impl Responder { + let key = TotpKey::from_encoded_secret(&form.secret); + + if !key.check_code(&form.first_code).unwrap_or(false) { + return HttpResponse::BadRequest() + .body(format!("Given code is invalid (expected {} or {})!", + key.current_code().unwrap_or_default(), + key.previous_code().unwrap_or_default())); + } + + if form.factor_name.is_empty() { + return HttpResponse::BadRequest().body("Please give a name to the factor!"); + } + + let mut user = User::from(user); + user.add_factor(SecondFactor::TOTP(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!") + } +} \ No newline at end of file diff --git a/src/data/totp_key.rs b/src/data/totp_key.rs index cc61608..809fb60 100644 --- a/src/data/totp_key.rs +++ b/src/data/totp_key.rs @@ -1,14 +1,19 @@ +use std::io::ErrorKind; + use base32::Alphabet; use rand::Rng; +use totp_rfc6238::{HashAlgorithm, TotpGenerator}; use crate::data::app_config::AppConfig; use crate::data::user::User; +use crate::utils::err::Res; +use crate::utils::time::time; const BASE32_ALPHABET: Alphabet = Alphabet::RFC4648 { padding: true }; -const NUM_DIGITS: i32 = 6; -const PERIOD: i32 = 30; +const NUM_DIGITS: usize = 6; +const PERIOD: u64 = 30; -#[derive(serde::Serialize, serde::Deserialize, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct TotpKey { encoded: String, } @@ -17,11 +22,16 @@ impl TotpKey { /// Generate a new TOTP key pub fn new_random() -> Self { let random_bytes = rand::thread_rng().gen::<[u8; 10]>(); - TotpKey { + Self { encoded: base32::encode(BASE32_ALPHABET, &random_bytes) } } + /// Get a key from an encoded secret + pub fn from_encoded_secret(s: &str) -> Self { + Self { encoded: s.to_string() } + } + /// Get QrCode URL for user /// /// Based on https://github.com/google/google-authenticator/wiki/Key-Uri-Format @@ -50,4 +60,38 @@ impl TotpKey { pub fn get_secret(&self) -> String { self.encoded.to_string() } + + /// Get current code + pub fn current_code(&self) -> Res { + self.get_code_at(|| time()) + } + + /// Get previous code + pub fn previous_code(&self) -> Res { + self.get_code_at(|| time() - PERIOD) + } + + /// Get the code at a specific time + fn get_code_at u64>(&self, get_time: F) -> Res { + let gen = TotpGenerator::new() + .set_digit(NUM_DIGITS).unwrap() + .set_step(PERIOD).unwrap() + .set_hash_algorithm(HashAlgorithm::SHA1) + .build(); + + let key = match base32::decode(BASE32_ALPHABET, &self.encoded) { + None => { + return Err(Box::new( + std::io::Error::new(ErrorKind::Other, "Failed to decode base32 secret!"))); + } + Some(k) => k, + }; + + Ok(gen.get_code_with(&key, get_time)) + } + + /// Check a code's validity + pub fn check_code(&self, code: &str) -> Res { + Ok(self.current_code()?.eq(code) || self.previous_code()?.eq(code)) + } } \ No newline at end of file diff --git a/src/data/user.rs b/src/data/user.rs index 70a33ad..eed6534 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -1,9 +1,15 @@ use crate::data::client::ClientID; use crate::data::entity_manager::EntityManager; +use crate::data::totp_key::TotpKey; use crate::utils::err::Res; pub type UserID = String; +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum SecondFactor { + TOTP(TotpKey) +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct User { pub uid: UserID, @@ -16,6 +22,9 @@ pub struct User { pub enabled: bool, pub admin: bool, + /// 2FA + pub second_factors: Option>, + /// None = all services /// Some([]) = no service pub authorized_clients: Option>, @@ -36,6 +45,14 @@ impl User { pub fn verify_password>(&self, pass: P) -> bool { verify_password(pass, &self.password) } + + pub fn add_factor(&mut self, factor: SecondFactor) { + if self.second_factors.is_none() { + self.second_factors = Some(vec![]); + } + + self.second_factors.as_mut().unwrap().push(factor); + } } impl PartialEq for User { @@ -58,6 +75,7 @@ impl Default for User { need_reset_password: false, enabled: true, admin: false, + second_factors: Some(vec![]), authorized_clients: Some(Vec::new()), } } diff --git a/src/main.rs b/src/main.rs index 890b1cb..44eea7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,7 +119,10 @@ async fn main() -> std::io::Result<()> { .route("/settings/change_password", web::get().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/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)) + + // User API + .route("/settings/api/two_factors/save_totp_key", web::post().to(two_factors_api::save_totp_key)) // Admin routes .route("/admin", web::get() diff --git a/templates/settings/add_2fa_totp_page.html b/templates/settings/add_2fa_totp_page.html index 5de61a3..aee2445 100644 --- a/templates/settings/add_2fa_totp_page.html +++ b/templates/settings/add_2fa_totp_page.html @@ -21,23 +21,83 @@

    Once you have scanned the QrCode, please generate a code and type it below:

    -
    - - - Please give a name to your device to identity it more easily later. -
    +
    + -
    - - - Check that your authenticator app is working correctly by typing a first - code. -
    +
    + + + Please give a name to your device to identity it more easily + later. +
    Please give a name to this authenticator app
    +
    + +
    + + + Check that your authenticator app is working correctly by typing a first + code. +
    Please enter a first code (must have 6 digits)
    +
    + + +
    + {% endblock content %} From b5a2f1abcb36babbe256b5fb288b83c428617c55 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 11:03:10 +0200 Subject: [PATCH 05/22] cargo clippy --- src/data/app_config.rs | 2 +- src/data/totp_key.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/data/app_config.rs b/src/data/app_config.rs index a822e05..30dff4e 100644 --- a/src/data/app_config.rs +++ b/src/data/app_config.rs @@ -55,6 +55,6 @@ impl AppConfig { } pub fn domain_name(&self) -> &str { - self.website_origin.split('/').skip(2).next().unwrap_or(APP_NAME) + self.website_origin.split('/').nth(2).unwrap_or(APP_NAME) } } diff --git a/src/data/totp_key.rs b/src/data/totp_key.rs index 809fb60..fc2298c 100644 --- a/src/data/totp_key.rs +++ b/src/data/totp_key.rs @@ -38,10 +38,10 @@ impl TotpKey { pub fn url_for_user(&self, u: &User, conf: &AppConfig) -> String { format!( "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}", - urlencoding::encode(&conf.domain_name()), + urlencoding::encode(conf.domain_name()), urlencoding::encode(&u.username), self.encoded, - urlencoding::encode(&conf.domain_name()), + urlencoding::encode(conf.domain_name()), NUM_DIGITS, PERIOD, ) @@ -63,7 +63,7 @@ impl TotpKey { /// Get current code pub fn current_code(&self) -> Res { - self.get_code_at(|| time()) + self.get_code_at(time) } /// Get previous code From 8cdb47a139acb74e680f6fc46ef94187bd711321 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 11:05:41 +0200 Subject: [PATCH 06/22] Rename route --- src/controllers/two_factors_api.rs | 4 ++-- src/main.rs | 2 +- templates/settings/add_2fa_totp_page.html | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/controllers/two_factors_api.rs b/src/controllers/two_factors_api.rs index b899873..17376cb 100644 --- a/src/controllers/two_factors_api.rs +++ b/src/controllers/two_factors_api.rs @@ -14,8 +14,8 @@ pub struct Request { first_code: String, } -pub async fn save_totp_key(user: CurrentUser, form: web::Json, - users: web::Data>) -> impl Responder { +pub async fn save_totp_factor(user: CurrentUser, form: web::Json, + users: web::Data>) -> impl Responder { let key = TotpKey::from_encoded_secret(&form.secret); if !key.check_code(&form.first_code).unwrap_or(false) { diff --git a/src/main.rs b/src/main.rs index 44eea7b..e4eb9ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,7 +122,7 @@ async fn main() -> std::io::Result<()> { .route("/settings/two_factors/add_totp", web::get().to(two_factors_controller::add_totp_factor_route)) // User API - .route("/settings/api/two_factors/save_totp_key", web::post().to(two_factors_api::save_totp_key)) + .route("/settings/api/two_factors/save_totp_factor", web::post().to(two_factors_api::save_totp_factor)) // Admin routes .route("/admin", web::get() diff --git a/templates/settings/add_2fa_totp_page.html b/templates/settings/add_2fa_totp_page.html index aee2445..0ecb0c9 100644 --- a/templates/settings/add_2fa_totp_page.html +++ b/templates/settings/add_2fa_totp_page.html @@ -75,7 +75,7 @@ return; try { - const res = await fetch("/settings/api/two_factors/save_totp_key", { + const res = await fetch("/settings/api/two_factors/save_totp_factor", { method: "post", headers: { 'Content-Type': 'application/json', From deb00c572d5bfe814518d047d039cc878e9d0305 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 16:01:16 +0200 Subject: [PATCH 07/22] Prevent crash from occuring --- src/data/current_user.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/data/current_user.rs b/src/data/current_user.rs index 5fa455a..d457fc6 100644 --- a/src/data/current_user.rs +++ b/src/data/current_user.rs @@ -6,6 +6,7 @@ use actix::Addr; use actix_identity::Identity; use actix_web::{Error, FromRequest, HttpRequest, web}; use actix_web::dev::Payload; +use actix_web::error::ErrorInternalServerError; use crate::actors::users_actor; use crate::actors::users_actor::UsersActor; @@ -41,9 +42,14 @@ impl FromRequest for CurrentUser { Box::pin(async move { - let user: User = user_actor.send( + let user = match user_actor.send( users_actor::GetUserRequest(user_id) - ).await.unwrap().0.unwrap(); + ).await.unwrap().0 { + Some(u) => u, + None => { + return Err(ErrorInternalServerError("Could not extract user information!")); + } + }; Ok(CurrentUser(user)) }) From 630ebe2dddd1a6f8a4ecbd83953bcc269dbf2b53 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 16:17:58 +0200 Subject: [PATCH 08/22] Can remove created factors --- src/controllers/mod.rs | 2 +- .../{two_factors_api.rs => two_factor_api.rs} | 32 +++++++++++-- src/data/user.rs | 26 +++++++++- src/main.rs | 3 +- templates/settings/add_2fa_totp_page.html | 2 +- templates/settings/two_factors_page.html | 48 ++++++++++++++++++- 6 files changed, 104 insertions(+), 9 deletions(-) rename src/controllers/{two_factors_api.rs => two_factor_api.rs} (58%) diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 8efd15e..a94576d 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -6,4 +6,4 @@ pub mod admin_controller; pub mod admin_api; pub mod openid_controller; pub mod two_factors_controller; -pub mod two_factors_api; \ No newline at end of file +pub mod two_factor_api; \ No newline at end of file diff --git a/src/controllers/two_factors_api.rs b/src/controllers/two_factor_api.rs similarity index 58% rename from src/controllers/two_factors_api.rs rename to src/controllers/two_factor_api.rs index 17376cb..158634b 100644 --- a/src/controllers/two_factors_api.rs +++ b/src/controllers/two_factor_api.rs @@ -1,20 +1,21 @@ use actix::Addr; use actix_web::{HttpResponse, Responder, web}; +use uuid::Uuid; use crate::actors::users_actor; use crate::actors::users_actor::UsersActor; use crate::data::current_user::CurrentUser; use crate::data::totp_key::TotpKey; -use crate::data::user::{SecondFactor, User}; +use crate::data::user::{FactorID, SecondFactor, SecondFactorType, User}; #[derive(serde::Deserialize)] -pub struct Request { +pub struct AddTOTPRequest { factor_name: String, secret: String, first_code: String, } -pub async fn save_totp_factor(user: CurrentUser, form: web::Json, +pub async fn save_totp_factor(user: CurrentUser, form: web::Json, users: web::Data>) -> impl Responder { let key = TotpKey::from_encoded_secret(&form.secret); @@ -30,7 +31,11 @@ pub async fn save_totp_factor(user: CurrentUser, form: web::Json, } let mut user = User::from(user); - user.add_factor(SecondFactor::TOTP(key)); + user.add_factor(SecondFactor { + id: FactorID(Uuid::new_v4().to_string()), + name: form.0.factor_name, + kind: SecondFactorType::TOTP(key), + }); let res = users.send(users_actor::UpdateUserRequest(user)).await.unwrap().0; if !res { @@ -38,4 +43,23 @@ pub async fn save_totp_factor(user: CurrentUser, form: web::Json, } else { HttpResponse::Ok().body("Added new factor!") } +} + +#[derive(serde::Deserialize)] +pub struct DeleteFactorRequest { + id: FactorID, +} + +pub async fn delete_factor(user: CurrentUser, form: web::Json, + users: web::Data>) -> impl Responder { + let mut user = User::from(user); + user.remove_factor(form.0.id); + + 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("Removed factor!") + } } \ No newline at end of file diff --git a/src/data/user.rs b/src/data/user.rs index eed6534..a0f0edf 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -5,11 +5,29 @@ use crate::utils::err::Res; pub type UserID = String; +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FactorID(pub String); + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum SecondFactor { +pub enum SecondFactorType { TOTP(TotpKey) } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SecondFactor { + pub id: FactorID, + pub name: String, + pub kind: SecondFactorType, +} + +impl SecondFactor { + pub fn type_str(&self) -> &'static str { + match self.kind { + SecondFactorType::TOTP(_) => "Authenticator app" + } + } +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct User { pub uid: UserID, @@ -53,6 +71,12 @@ impl User { self.second_factors.as_mut().unwrap().push(factor); } + + pub fn remove_factor(&mut self, factor_id: FactorID) { + if let Some(f) = self.second_factors.as_mut() { + f.retain(|f| f.id != factor_id); + } + } } impl PartialEq for User { diff --git a/src/main.rs b/src/main.rs index e4eb9ec..b6f3c9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,7 +122,8 @@ async fn main() -> std::io::Result<()> { .route("/settings/two_factors/add_totp", web::get().to(two_factors_controller::add_totp_factor_route)) // User API - .route("/settings/api/two_factors/save_totp_factor", web::post().to(two_factors_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/delete_factor", web::post().to(two_factor_api::delete_factor)) // Admin routes .route("/admin", web::get() diff --git a/templates/settings/add_2fa_totp_page.html b/templates/settings/add_2fa_totp_page.html index 0ecb0c9..3d15c6f 100644 --- a/templates/settings/add_2fa_totp_page.html +++ b/templates/settings/add_2fa_totp_page.html @@ -75,7 +75,7 @@ return; try { - const res = await fetch("/settings/api/two_factors/save_totp_factor", { + const res = await fetch("/settings/api/two_factor/save_totp_factor", { method: "post", headers: { 'Content-Type': 'application/json', diff --git a/templates/settings/two_factors_page.html b/templates/settings/two_factors_page.html index cc7de08..69a6a47 100644 --- a/templates/settings/two_factors_page.html +++ b/templates/settings/two_factors_page.html @@ -13,6 +13,52 @@

    Add Authenticator App

    -TODO : show the list of currently registered 2 factors methods + + + + + + + + + + {% for f in user.second_factors.as_deref().unwrap_or_default() %} + + + + + + {% endfor %} + +
    Factor typeNameActions
    {{ f.type_str() }}{{ f.name }}Delete
    + + {% endblock content %} From 78d70af510d9db17e1891ad430c221e5054f121c Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 17:14:05 +0200 Subject: [PATCH 09/22] Administrators can remove two factor authentication --- assets/css/base_settings_page.css | 1 + src/controllers/admin_controller.rs | 6 +++++ src/data/user.rs | 16 ++++++++----- templates/settings/edit_user.html | 29 +++++++++++++++++++++--- templates/settings/two_factors_page.html | 2 +- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/assets/css/base_settings_page.css b/assets/css/base_settings_page.css index 7546218..81f58eb 100644 --- a/assets/css/base_settings_page.css +++ b/assets/css/base_settings_page.css @@ -11,4 +11,5 @@ body { .page_body { padding: 3rem; + overflow-y: scroll; } \ No newline at end of file diff --git a/src/controllers/admin_controller.rs b/src/controllers/admin_controller.rs index 4413646..793b73f 100644 --- a/src/controllers/admin_controller.rs +++ b/src/controllers/admin_controller.rs @@ -60,6 +60,7 @@ pub struct UpdateUserQuery { admin: Option, grant_type: String, granted_clients: String, + two_factor: String, } pub async fn users_route(user: CurrentUser, users: web::Data>, update_query: Option>) -> impl Responder { @@ -80,6 +81,11 @@ pub async fn users_route(user: CurrentUser, users: web::Data>, user.enabled = update.0.enabled.is_some(); user.admin = update.0.admin.is_some(); + if let Some(factors) = user.two_factor.as_mut() { + let factors_to_keep = update.0.two_factor.split(';').collect::>(); + factors.retain(|f| factors_to_keep.contains(&f.id.0.as_str())); + } + user.authorized_clients = match update.0.grant_type.as_str() { "all_clients" => None, "custom_clients" => Some(update.0.granted_clients.split(',') diff --git a/src/data/user.rs b/src/data/user.rs index a0f0edf..d965341 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -41,7 +41,7 @@ pub struct User { pub admin: bool, /// 2FA - pub second_factors: Option>, + pub two_factor: Option>, /// None = all services /// Some([]) = no service @@ -64,16 +64,20 @@ impl User { verify_password(pass, &self.password) } + pub fn has_two_factor(&self) -> bool { + self.two_factor.as_ref().map(|f| !f.is_empty()).unwrap_or(false) + } + pub fn add_factor(&mut self, factor: SecondFactor) { - if self.second_factors.is_none() { - self.second_factors = Some(vec![]); + if self.two_factor.is_none() { + self.two_factor = Some(vec![]); } - self.second_factors.as_mut().unwrap().push(factor); + self.two_factor.as_mut().unwrap().push(factor); } pub fn remove_factor(&mut self, factor_id: FactorID) { - if let Some(f) = self.second_factors.as_mut() { + if let Some(f) = self.two_factor.as_mut() { f.retain(|f| f.id != factor_id); } } @@ -99,7 +103,7 @@ impl Default for User { need_reset_password: false, enabled: true, admin: false, - second_factors: Some(vec![]), + two_factor: Some(vec![]), authorized_clients: Some(Vec::new()), } } diff --git a/templates/settings/edit_user.html b/templates/settings/edit_user.html index 261291b..a253e6d 100644 --- a/templates/settings/edit_user.html +++ b/templates/settings/edit_user.html @@ -68,6 +68,25 @@ + + + {% if u.has_two_factor() %} +
    + Two factor authentication + If you uncheck a factor, it will be DELETED + {% for f in u.two_factor.as_deref().unwrap_or_default() %} +
    + +
    + {% endfor %} +
    + {% endif %} +
    Granted clients @@ -161,11 +180,15 @@ document.querySelector("input[name=granted_clients]").value = authorized_clients; + const factors_to_keep = [...document.querySelectorAll(".two-fact-checkbox")] + .filter(e => e.checked) + .map(e => e.value) + .join(";") + + document.querySelector("input[name=two_factor]").value = factors_to_keep; + form.submit(); }); - - - {% endblock content %} \ No newline at end of file diff --git a/templates/settings/two_factors_page.html b/templates/settings/two_factors_page.html index 69a6a47..a9257a3 100644 --- a/templates/settings/two_factors_page.html +++ b/templates/settings/two_factors_page.html @@ -23,7 +23,7 @@ - {% for f in user.second_factors.as_deref().unwrap_or_default() %} + {% for f in user.two_factor.as_deref().unwrap_or_default() %} {{ f.type_str() }} {{ f.name }} From 5903ec2e8c48430761662feab543ea64526b2131 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 17:49:57 +0200 Subject: [PATCH 10/22] Refactor login flow --- src/controllers/login_controller.rs | 145 +++++++++++++++------------ src/data/login_redirect_query.rs | 21 ++++ src/data/mod.rs | 3 +- src/main.rs | 9 +- templates/login/base_login_page.html | 8 +- templates/login/password_reset.html | 7 +- 6 files changed, 117 insertions(+), 76 deletions(-) create mode 100644 src/data/login_redirect_query.rs diff --git a/src/controllers/login_controller.rs b/src/controllers/login_controller.rs index 1d91410..65f77f8 100644 --- a/src/controllers/login_controller.rs +++ b/src/controllers/login_controller.rs @@ -7,13 +7,14 @@ use crate::actors::{bruteforce_actor, users_actor}; use crate::actors::bruteforce_actor::BruteForceActor; use crate::actors::users_actor::{ChangePasswordResult, LoginResult, UsersActor}; use crate::constants::{APP_NAME, MAX_FAILED_LOGIN_ATTEMPTS, MIN_PASS_LEN}; -use crate::controllers::base_controller::{FatalErrorPage, redirect_user}; +use crate::controllers::base_controller::{FatalErrorPage, redirect_user, redirect_user_for_login}; +use crate::data::login_redirect_query::LoginRedirectQuery; use crate::data::remote_ip::RemoteIP; use crate::data::session_identity::{SessionIdentity, SessionStatus}; struct BaseLoginPage { - danger: String, - success: String, + danger: Option, + success: Option, page_title: &'static str, app_name: &'static str, redirect_uri: String, @@ -42,7 +43,8 @@ pub struct LoginRequestBody { #[derive(serde::Deserialize)] pub struct LoginRequestQuery { logout: Option, - redirect: Option, + #[serde(default)] + redirect: LoginRedirectQuery, } /// Authenticate user @@ -54,11 +56,10 @@ pub async fn login_route( req: Option>, id: Identity, ) -> impl Responder { - let mut danger = String::new(); - let mut success = String::new(); + let mut danger = None; + let mut success = None; let mut login = String::new(); - let failed_attempts = bruteforce.send(bruteforce_actor::CountFailedAttempt { ip: remote_ip.into() }) .await.unwrap(); @@ -70,49 +71,24 @@ pub async fn login_route( ); } - let redirect_uri = match query.redirect.as_deref() { - None => "/", - Some(s) => match s.starts_with('/') && !s.starts_with("//") { - true => s, - false => "/", - }, - }; - // Check if user session must be closed if let Some(true) = query.logout { id.forget(); - success = "Goodbye!".to_string(); + success = Some("Goodbye!".to_string()); } // Check if user is already authenticated if SessionIdentity(&id).is_authenticated() { - return redirect_user(redirect_uri); + return redirect_user(query.redirect.get()); } - // Check if user is setting a new password - if let (Some(req), true) = (&req, SessionIdentity(&id).need_new_password()) { - if req.password.len() < MIN_PASS_LEN { - danger = "Password is too short!".to_string(); - } else { - let res: ChangePasswordResult = users - .send(users_actor::ChangePasswordRequest { - user_id: SessionIdentity(&id).user_id(), - new_password: req.password.clone(), - temporary: false, - }) - .await - .unwrap(); - - if !res.0 { - danger = "Failed to change password!".to_string(); - } else { - SessionIdentity(&id).set_status(SessionStatus::SignedIn); - return redirect_user(redirect_uri); - } - } + // Check if the password of the user has to be changed + if SessionIdentity(&id).need_new_password() { + return redirect_user(&format!("/reset_password?redirect={}", query.redirect.get_encoded())); } + // Try to authenticate user - else if let Some(req) = &req { + if let Some(req) = &req { login = req.login.clone(); let response: LoginResult = users .send(users_actor::LoginRequest { @@ -126,45 +102,28 @@ pub async fn login_route( LoginResult::Success(user) => { SessionIdentity(&id).set_user(&user); - if user.need_reset_password { + return if user.need_reset_password { SessionIdentity(&id).set_status(SessionStatus::NeedNewPassword); + redirect_user(&format!("/reset_password?redirect={}", query.redirect.get_encoded())) } else { - return redirect_user(redirect_uri); - } + redirect_user(query.redirect.get()) + }; } LoginResult::AccountDisabled => { log::warn!("Failed login for username {} : account is disabled", login); - danger = "Your account is disabled!".to_string(); + danger = Some("Your account is disabled!".to_string()); } c => { log::warn!("Failed login for ip {:?} / username {}: {:?}", remote_ip, login, c); - danger = "Login failed.".to_string(); + danger = Some("Login failed.".to_string()); bruteforce.send(bruteforce_actor::RecordFailedAttempt { ip: remote_ip.into() }).await.unwrap(); } } } - // Display password reset form if it is appropriate - if SessionIdentity(&id).need_new_password() { - return HttpResponse::Ok().content_type("text/html").body( - PasswordResetTemplate { - _p: BaseLoginPage { - page_title: "Password reset", - danger, - success, - app_name: APP_NAME, - redirect_uri: urlencoding::encode(redirect_uri).to_string(), - }, - min_pass_len: MIN_PASS_LEN, - } - .render() - .unwrap(), - ); - } - HttpResponse::Ok().content_type("text/html").body( LoginTemplate { _p: BaseLoginPage { @@ -172,7 +131,7 @@ pub async fn login_route( danger, success, app_name: APP_NAME, - redirect_uri: urlencoding::encode(redirect_uri).to_string(), + redirect_uri: query.redirect.get_encoded(), }, login, } @@ -185,3 +144,63 @@ pub async fn login_route( pub async fn logout_route() -> impl Responder { redirect_user("/login?logout=true") } + +#[derive(serde::Deserialize)] +pub struct ChangePasswordRequestBody { + password: String, +} + +#[derive(serde::Deserialize)] +pub struct PasswordResetQuery { + #[serde(default)] + redirect: LoginRedirectQuery, +} + +/// Reset user password route +pub async fn reset_password_route(id: Identity, query: web::Query, + req: Option>, + users: web::Data>, ) -> impl Responder { + let mut danger = None; + + if !SessionIdentity(&id).need_new_password() { + return redirect_user_for_login(query.redirect.get()); + } + + // Check if user is setting a new password + if let Some(req) = &req { + if req.password.len() < MIN_PASS_LEN { + danger = Some("Password is too short!".to_string()); + } else { + let res: ChangePasswordResult = users + .send(users_actor::ChangePasswordRequest { + user_id: SessionIdentity(&id).user_id(), + new_password: req.password.clone(), + temporary: false, + }) + .await + .unwrap(); + + if !res.0 { + danger = Some("Failed to change password!".to_string()); + } else { + SessionIdentity(&id).set_status(SessionStatus::SignedIn); + return redirect_user(query.redirect.get()); + } + } + } + + HttpResponse::Ok().content_type("text/html").body( + PasswordResetTemplate { + _p: BaseLoginPage { + page_title: "Password reset", + danger, + success: None, + app_name: APP_NAME, + redirect_uri: query.redirect.get_encoded(), + }, + min_pass_len: MIN_PASS_LEN, + } + .render() + .unwrap(), + ) +} \ No newline at end of file diff --git a/src/data/login_redirect_query.rs b/src/data/login_redirect_query.rs new file mode 100644 index 0000000..b8a46d0 --- /dev/null +++ b/src/data/login_redirect_query.rs @@ -0,0 +1,21 @@ +#[derive(serde::Serialize, serde::Deserialize, Debug, Eq, PartialEq, Clone)] +pub struct LoginRedirectQuery(String); + +impl LoginRedirectQuery { + pub fn get(&self) -> &str { + match self.0.starts_with('/') && !self.0.starts_with("//") { + true => self.0.as_str(), + false => "/", + } + } + + pub fn get_encoded(&self) -> String { + urlencoding::encode(self.get()).to_string() + } +} + +impl Default for LoginRedirectQuery { + fn default() -> Self { + Self("/".to_string()) + } +} \ No newline at end of file diff --git a/src/data/mod.rs b/src/data/mod.rs index edf61a6..52f6514 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -11,4 +11,5 @@ pub mod id_token; pub mod code_challenge; pub mod open_id_user_info; pub mod access_token; -pub mod totp_key; \ No newline at end of file +pub mod totp_key; +pub mod login_redirect_query; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index b6f3c9a..ee0430f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,6 @@ use basic_oidc::actors::users_actor::UsersActor; use basic_oidc::constants::*; use basic_oidc::controllers::*; use basic_oidc::controllers::assets_controller::assets_route; -use basic_oidc::controllers::login_controller::{login_route, logout_route}; use basic_oidc::data::app_config::AppConfig; use basic_oidc::data::client::ClientManager; use basic_oidc::data::entity_manager::EntityManager; @@ -108,11 +107,13 @@ async fn main() -> std::io::Result<()> { .route("/assets/{path:.*}", web::get().to(assets_route)) // Login page - .route("/login", web::get().to(login_route)) - .route("/login", web::post().to(login_route)) + .route("/login", web::get().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::post().to(login_controller::reset_password_route)) // Logout page - .route("/logout", web::get().to(logout_route)) + .route("/logout", web::get().to(login_controller::logout_route)) // Settings routes .route("/settings", web::get().to(settings_controller::account_settings_details_route)) diff --git a/templates/login/base_login_page.html b/templates/login/base_login_page.html index 76bcd79..bd71b5f 100644 --- a/templates/login/base_login_page.html +++ b/templates/login/base_login_page.html @@ -43,13 +43,17 @@

    {{ _p.page_title }}

    + {% if let Some(danger) = _p.danger %} + {% endif %} + {% if let Some(success) = _p.success %} + {% endif %} {% block content %} TO_REPLACE diff --git a/templates/login/password_reset.html b/templates/login/password_reset.html index bef4e3b..0edd289 100644 --- a/templates/login/password_reset.html +++ b/templates/login/password_reset.html @@ -1,14 +1,11 @@ {% extends "base_login_page.html" %} {% block content %} -
    +

    You need to configure a new password:

    - - -
    @@ -46,8 +43,6 @@ else form.submit(); }) - - From 9ff4392afbdfaec672835fd31571a43925e2d277 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 19 Apr 2022 17:56:53 +0200 Subject: [PATCH 11/22] Replace `Option` with `SecondFactor` --- src/controllers/admin_controller.rs | 7 +++---- src/data/user.rs | 17 ++++++----------- templates/settings/edit_user.html | 2 +- templates/settings/two_factors_page.html | 2 +- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/controllers/admin_controller.rs b/src/controllers/admin_controller.rs index 793b73f..b15e900 100644 --- a/src/controllers/admin_controller.rs +++ b/src/controllers/admin_controller.rs @@ -81,10 +81,9 @@ pub async fn users_route(user: CurrentUser, users: web::Data>, user.enabled = update.0.enabled.is_some(); user.admin = update.0.admin.is_some(); - if let Some(factors) = user.two_factor.as_mut() { - let factors_to_keep = update.0.two_factor.split(';').collect::>(); - factors.retain(|f| factors_to_keep.contains(&f.id.0.as_str())); - } + let factors_to_keep = update.0.two_factor.split(';').collect::>(); + user.two_factor.retain(|f| factors_to_keep.contains(&f.id.0.as_str())); + user.authorized_clients = match update.0.grant_type.as_str() { "all_clients" => None, diff --git a/src/data/user.rs b/src/data/user.rs index d965341..1eab655 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -41,7 +41,8 @@ pub struct User { pub admin: bool, /// 2FA - pub two_factor: Option>, + #[serde(default)] + pub two_factor: Vec, /// None = all services /// Some([]) = no service @@ -65,21 +66,15 @@ impl User { } pub fn has_two_factor(&self) -> bool { - self.two_factor.as_ref().map(|f| !f.is_empty()).unwrap_or(false) + !self.two_factor.is_empty() } pub fn add_factor(&mut self, factor: SecondFactor) { - if self.two_factor.is_none() { - self.two_factor = Some(vec![]); - } - - self.two_factor.as_mut().unwrap().push(factor); + self.two_factor.push(factor); } pub fn remove_factor(&mut self, factor_id: FactorID) { - if let Some(f) = self.two_factor.as_mut() { - f.retain(|f| f.id != factor_id); - } + self.two_factor.retain(|f| f.id != factor_id); } } @@ -103,7 +98,7 @@ impl Default for User { need_reset_password: false, enabled: true, admin: false, - two_factor: Some(vec![]), + two_factor: vec![], authorized_clients: Some(Vec::new()), } } diff --git a/templates/settings/edit_user.html b/templates/settings/edit_user.html index a253e6d..9f0ee09 100644 --- a/templates/settings/edit_user.html +++ b/templates/settings/edit_user.html @@ -74,7 +74,7 @@
    Two factor authentication If you uncheck a factor, it will be DELETED - {% for f in u.two_factor.as_deref().unwrap_or_default() %} + {% for f in u.two_factor %}