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/README.md b/README.md index 0b6cf35..86287da 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Features : * [x] Client authentication using secrets * [x] Bruteforce protection * [ ] 2 factors authentication - * [ ] TOTP + * [x] TOTP (authenticator app) * [ ] Using a security key * [ ] Fully responsive webui 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/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/actors/openid_sessions_actor.rs b/src/actors/openid_sessions_actor.rs index e611a15..577868c 100644 --- a/src/actors/openid_sessions_actor.rs +++ b/src/actors/openid_sessions_actor.rs @@ -46,7 +46,7 @@ impl Session { jwt_signer: &JWTSigner) -> Res { let access_token = AccessToken { issuer: app_config.website_origin.to_string(), - subject_identifier: self.user.clone(), + subject_identifier: self.user.clone().0, issued_at: time(), exp_time: time() + OPEN_ID_ACCESS_TOKEN_TIMEOUT, rand_val: rand_str(OPEN_ID_ACCESS_TOKEN_LEN), diff --git a/src/controllers/admin_api.rs b/src/controllers/admin_api.rs index cacbc52..039ba62 100644 --- a/src/controllers/admin_api.rs +++ b/src/controllers/admin_api.rs @@ -18,7 +18,7 @@ struct FindUserResult { pub async fn find_username(req: web::Form, users: web::Data>) -> impl Responder { let res = users.send(FindUserByUsername(req.0.username)).await.unwrap(); HttpResponse::Ok().json(FindUserResult { - user_id: res.0.map(|r| r.uid) + user_id: res.0.map(|r| r.uid.0) }) } diff --git a/src/controllers/admin_controller.rs b/src/controllers/admin_controller.rs index 4413646..b15e900 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,10 @@ pub async fn users_route(user: CurrentUser, users: web::Data>, user.enabled = update.0.enabled.is_some(); user.admin = update.0.admin.is_some(); + 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, "custom_clients" => Some(update.0.granted_clients.split(',') diff --git a/src/controllers/login_controller.rs b/src/controllers/login_controller.rs index 1d91410..48cf0a3 100644 --- a/src/controllers/login_controller.rs +++ b/src/controllers/login_controller.rs @@ -7,32 +7,49 @@ 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::LoginRedirect; use crate::data::remote_ip::RemoteIP; use crate::data::session_identity::{SessionIdentity, SessionStatus}; +use crate::data::user::{FactorID, TwoFactor, TwoFactorType, User}; -struct BaseLoginPage { - danger: String, - success: String, +struct BaseLoginPage<'a> { + danger: Option, + success: Option, page_title: &'static str, app_name: &'static str, - redirect_uri: String, + redirect_uri: &'a LoginRedirect, } #[derive(Template)] #[template(path = "login/login.html")] -struct LoginTemplate { - _p: BaseLoginPage, +struct LoginTemplate<'a> { + _p: BaseLoginPage<'a>, login: String, } #[derive(Template)] #[template(path = "login/password_reset.html")] -struct PasswordResetTemplate { - _p: BaseLoginPage, +struct PasswordResetTemplate<'a> { + _p: BaseLoginPage<'a>, min_pass_len: usize, } +#[derive(Template)] +#[template(path = "login/choose_second_factor.html")] +struct ChooseSecondFactorTemplate<'a> { + _p: BaseLoginPage<'a>, + factors: &'a [TwoFactor], +} + +#[derive(Template)] +#[template(path = "login/opt_input.html")] +struct LoginWithOTPTemplate<'a> { + _p: BaseLoginPage<'a>, + factor: &'a TwoFactor, +} + + #[derive(serde::Deserialize)] pub struct LoginRequestBody { login: String, @@ -42,7 +59,8 @@ pub struct LoginRequestBody { #[derive(serde::Deserialize)] pub struct LoginRequestQuery { logout: Option, - redirect: Option, + #[serde(default)] + redirect: LoginRedirect, } /// Authenticate user @@ -54,11 +72,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 +87,29 @@ 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())); } + + // Check if the user has to valide a second factor + if SessionIdentity(&id).need_2fa_auth() { + return redirect_user(&format!("/2fa_auth?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 +123,31 @@ 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 if user.has_two_factor() { + SessionIdentity(&id).set_status(SessionStatus::Need2FA); + redirect_user(&format!("/2fa_auth?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 +155,7 @@ pub async fn login_route( danger, success, app_name: APP_NAME, - redirect_uri: urlencoding::encode(redirect_uri).to_string(), + redirect_uri: &query.redirect, }, login, } @@ -185,3 +168,156 @@ 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: LoginRedirect, +} + +/// 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, + }, + min_pass_len: MIN_PASS_LEN, + } + .render() + .unwrap(), + ) +} + +#[derive(serde::Deserialize)] +pub struct ChooseSecondFactorQuery { + #[serde(default)] + redirect: LoginRedirect, +} + +/// Let the user select the factor to use to authenticate +pub async fn choose_2fa_method(id: Identity, query: web::Query, + users: web::Data>) -> 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!"); + + HttpResponse::Ok().content_type("text/html").body( + ChooseSecondFactorTemplate { + _p: BaseLoginPage { + page_title: "Two factor authentication", + danger: None, + success: None, + app_name: APP_NAME, + redirect_uri: &query.redirect, + }, + factors: &user.two_factor, + } + .render() + .unwrap(), + ) +} + +#[derive(serde::Deserialize)] +pub struct LoginWithOTPQuery { + #[serde(default)] + redirect: LoginRedirect, + id: FactorID, +} + +#[derive(serde::Deserialize)] +pub struct LoginWithOTPForm { + code: String, +} + + +/// Login with OTP +pub async fn login_with_otp(id: Identity, query: web::Query, + form: Option>, + users: web::Data>) -> impl Responder { + let mut danger = None; + + 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::TOTP(key) => key, + _ => { + return HttpResponse::Ok() + .body(FatalErrorPage { message: "Factor is not a TOTP key!" }.render().unwrap()); + } + }; + + if let Some(form) = form { + if !key.check_code(&form.code).unwrap_or(false) { + danger = Some("Specified code is invalid!".to_string()); + } else { + SessionIdentity(&id).set_status(SessionStatus::SignedIn); + return redirect_user(query.redirect.get()); + } + } + + HttpResponse::Ok().body(LoginWithOTPTemplate { + _p: BaseLoginPage { + danger, + success: None, + page_title: "Two-Factor Auth", + app_name: APP_NAME, + redirect_uri: &query.redirect, + }, + factor, + }.render().unwrap()) +} \ No newline at end of file diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 03adad5..a94576d 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -4,4 +4,6 @@ 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; +pub mod two_factor_api; \ No newline at end of file diff --git a/src/controllers/openid_controller.rs b/src/controllers/openid_controller.rs index 1ea6fc2..625884d 100644 --- a/src/controllers/openid_controller.rs +++ b/src/controllers/openid_controller.rs @@ -341,7 +341,7 @@ pub async fn token(req: HttpRequest, // Generate id token let id_token = IdToken { issuer: app_config.website_origin.to_string(), - subject_identifier: session.user, + subject_identifier: session.user.0, audience: session.client.0.to_string(), expiration_time: session.access_token_expire_at, issued_at: time(), @@ -499,7 +499,7 @@ async fn user_info(req: HttpRequest, token: Option, HttpResponse::Ok() .json(OpenIDUserInfo { name: user.full_name(), - sub: user.uid, + sub: user.uid.0, given_name: user.first_name, family_name: user.last_name, preferred_username: user.username, 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_factor_api.rs b/src/controllers/two_factor_api.rs new file mode 100644 index 0000000..c8874b6 --- /dev/null +++ b/src/controllers/two_factor_api.rs @@ -0,0 +1,65 @@ +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::{FactorID, TwoFactor, TwoFactorType, User}; + +#[derive(serde::Deserialize)] +pub struct AddTOTPRequest { + factor_name: String, + secret: String, + first_code: String, +} + +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) { + 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(TwoFactor { + id: FactorID(Uuid::new_v4().to_string()), + name: form.0.factor_name, + kind: TwoFactorType::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!") + } +} + +#[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/controllers/two_factors_controller.rs b/src/controllers/two_factors_controller.rs new file mode 100644 index 0000000..127185b --- /dev/null +++ b/src/controllers/two_factors_controller.rs @@ -0,0 +1,72 @@ +use std::ops::Deref; + +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)] +#[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, + qr_code: String, + account_name: String, + secret_key: String, +} + + +/// 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, 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( + "New authenticator app", + &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..30dff4e 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('/').nth(2).unwrap_or(APP_NAME) + } } 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)) }) diff --git a/src/data/login_redirect.rs b/src/data/login_redirect.rs new file mode 100644 index 0000000..e2f288e --- /dev/null +++ b/src/data/login_redirect.rs @@ -0,0 +1,21 @@ +#[derive(serde::Serialize, serde::Deserialize, Debug, Eq, PartialEq, Clone)] +pub struct LoginRedirect(String); + +impl LoginRedirect { + 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 LoginRedirect { + 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 b158b61..b78ab80 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -10,4 +10,6 @@ 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; +pub mod login_redirect; \ No newline at end of file diff --git a/src/data/session_identity.rs b/src/data/session_identity.rs index c982774..547824e 100644 --- a/src/data/session_identity.rs +++ b/src/data/session_identity.rs @@ -9,7 +9,7 @@ pub enum SessionStatus { Invalid, SignedIn, NeedNewPassword, - NeedMFA, + Need2FA, } impl Default for SessionStatus { @@ -20,7 +20,7 @@ impl Default for SessionStatus { #[derive(Debug, Serialize, Deserialize, Default)] pub struct SessionIdentityData { - pub id: UserID, + pub id: Option, pub is_admin: bool, pub auth_time: u64, pub status: SessionStatus, @@ -48,9 +48,7 @@ impl<'a> SessionIdentity<'a> { // Check if session is valid if let Some(sess) = &res { - if sess.id.is_empty() { - return None; - } + sess.id.as_ref()?; } res @@ -64,7 +62,7 @@ impl<'a> SessionIdentity<'a> { pub fn set_user(&self, user: &User) { self.set_session_data(&SessionIdentityData { - id: user.uid.clone(), + id: Some(user.uid.clone()), is_admin: user.admin, auth_time: time(), status: SessionStatus::SignedIn, @@ -89,12 +87,19 @@ impl<'a> SessionIdentity<'a> { .unwrap_or(false) } + pub fn need_2fa_auth(&self) -> bool { + self.get_session_data() + .map(|s| s.status == SessionStatus::Need2FA) + .unwrap_or(false) + } + pub fn is_admin(&self) -> bool { self.get_session_data().unwrap_or_default().is_admin } pub fn user_id(&self) -> UserID { self.get_session_data().unwrap_or_default().id + .expect("UserID should never be null here!") } pub fn auth_time(&self) -> u64 { diff --git a/src/data/totp_key.rs b/src/data/totp_key.rs new file mode 100644 index 0000000..fc2298c --- /dev/null +++ b/src/data/totp_key.rs @@ -0,0 +1,97 @@ +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: usize = 6; +const PERIOD: u64 = 30; + +#[derive(Clone, 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]>(); + 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 + 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() + } + + /// 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..8ded161 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -1,8 +1,44 @@ use crate::data::client::ClientID; use crate::data::entity_manager::EntityManager; +use crate::data::login_redirect::LoginRedirect; +use crate::data::totp_key::TotpKey; use crate::utils::err::Res; -pub type UserID = String; +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct UserID(pub String); + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FactorID(pub String); + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum TwoFactorType { + TOTP(TotpKey), + _OTHER, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct TwoFactor { + pub id: FactorID, + pub name: String, + pub kind: TwoFactorType, +} + +impl TwoFactor { + pub fn type_str(&self) -> &'static str { + match self.kind { + TwoFactorType::TOTP(_) => "Authenticator app", + _ => unimplemented!() + } + } + + pub fn login_url(&self, redirect_uri: &LoginRedirect) -> String { + match self.kind { + TwoFactorType::TOTP(_) => format!("/2fa_otp?id={}&redirect={}", + self.id.0, redirect_uri.get_encoded()), + _ => unimplemented!() + } + } +} #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct User { @@ -16,6 +52,10 @@ pub struct User { pub enabled: bool, pub admin: bool, + /// 2FA + #[serde(default)] + pub two_factor: Vec, + /// None = all services /// Some([]) = no service pub authorized_clients: Option>, @@ -36,6 +76,22 @@ impl User { pub fn verify_password>(&self, pass: P) -> bool { verify_password(pass, &self.password) } + + pub fn has_two_factor(&self) -> bool { + !self.two_factor.is_empty() + } + + pub fn add_factor(&mut self, factor: TwoFactor) { + self.two_factor.push(factor); + } + + pub fn remove_factor(&mut self, factor_id: FactorID) { + self.two_factor.retain(|f| f.id != factor_id); + } + + pub fn find_factor(&self, factor_id: &FactorID) -> Option<&TwoFactor> { + self.two_factor.iter().find(|f| f.id.eq(factor_id)) + } } impl PartialEq for User { @@ -49,7 +105,7 @@ impl Eq for User {} impl Default for User { fn default() -> Self { Self { - uid: uuid::Uuid::new_v4().to_string(), + uid: UserID(uuid::Uuid::new_v4().to_string()), first_name: "".to_string(), last_name: "".to_string(), username: "".to_string(), @@ -58,6 +114,7 @@ impl Default for User { need_reset_password: false, enabled: true, admin: false, + two_factor: vec![], authorized_clients: Some(Vec::new()), } } diff --git a/src/main.rs b/src/main.rs index a54ecd7..84ab1af 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,16 +107,27 @@ 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)) + .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::post().to(login_controller::login_with_otp)) // 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)) .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)) + + // User API + .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/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/choose_second_factor.html b/templates/login/choose_second_factor.html new file mode 100644 index 0000000..c351656 --- /dev/null +++ b/templates/login/choose_second_factor.html @@ -0,0 +1,23 @@ +{% extends "base_login_page.html" %} +{% block content %} + +
+

You need to validate a second factor to validate your login.

+ + {% for factor in factors %} +

+ + {{ factor.name }}
+ {{ factor.type_str() }} +
+

+ {% endfor %} +
+ + + + + +{% endblock content %} \ No newline at end of file diff --git a/templates/login/login.html b/templates/login/login.html index d066157..890cd5a 100644 --- a/templates/login/login.html +++ b/templates/login/login.html @@ -1,10 +1,10 @@ {% extends "base_login_page.html" %} {% block content %} -
+
+ value="{{ login }}" autofocus>
diff --git a/templates/login/opt_input.html b/templates/login/opt_input.html new file mode 100644 index 0000000..39d449e --- /dev/null +++ b/templates/login/opt_input.html @@ -0,0 +1,24 @@ +{% extends "base_login_page.html" %} +{% block content %} + +
+

Please go to your authenticator app {{ factor.name }}, generate a new code and enter it here:

+ + +
+ + +
+ + + +
+ + + + +{% endblock content %} \ No newline at end of file diff --git a/templates/login/password_reset.html b/templates/login/password_reset.html index bef4e3b..69bfad8 100644 --- a/templates/login/password_reset.html +++ b/templates/login/password_reset.html @@ -1,17 +1,14 @@ {% extends "base_login_page.html" %} {% block content %} -
+

You need to configure a new password:

- - -
+ placeholder="Password" autofocus/>
@@ -46,8 +43,6 @@ else form.submit(); }) - - diff --git a/templates/settings/account_details.html b/templates/settings/account_details.html index b60b3ab..f6bb246 100644 --- a/templates/settings/account_details.html +++ b/templates/settings/account_details.html @@ -5,7 +5,7 @@ User ID - {{ u.uid }} + {{ u.uid.0 }} First name diff --git a/templates/settings/add_2fa_totp_page.html b/templates/settings/add_2fa_totp_page.html new file mode 100644 index 0000000..3d15c6f --- /dev/null +++ b/templates/settings/add_2fa_totp_page.html @@ -0,0 +1,103 @@ +{% extends "base_settings_page.html" %} +{% block content %} +
+ +

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.

+ + + +

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

+ + +

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. +
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 %} 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/edit_user.html b/templates/settings/edit_user.html index 261291b..094916b 100644 --- a/templates/settings/edit_user.html +++ b/templates/settings/edit_user.html @@ -6,7 +6,7 @@
    + name="uid" value="{{ u.uid.0 }}"/>
    @@ -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 %} +
    + +
    + {% endfor %} +
    + {% endif %} +
    Granted clients @@ -126,7 +145,7 @@ return; const userID = await find_username(usernameEl.value); - usernameEl.classList.add((userID === null || userID === "{{ u.uid }}") ? "is-valid" : "is-invalid"); + usernameEl.classList.add((userID === null || userID === "{{ u.uid.0 }}") ? "is-valid" : "is-invalid"); } catch(e) { console.error(e); @@ -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 new file mode 100644 index 0000000..1b740fc --- /dev/null +++ b/templates/settings/two_factors_page.html @@ -0,0 +1,64 @@ +{% 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 Authenticator App +

    + + + + + + + + + + + {% for f in user.two_factor %} + + + + + + {% endfor %} + +
    Factor typeNameActions
    {{ f.type_str() }}{{ f.name }}Delete
    + + +{% endblock content %} diff --git a/templates/settings/users_list.html b/templates/settings/users_list.html index f91aa45..aae775f 100644 --- a/templates/settings/users_list.html +++ b/templates/settings/users_list.html @@ -19,7 +19,7 @@ {% for u in users %} - + {{ u.username }} {{ u.first_name }} {{ u.last_name }} @@ -27,8 +27,8 @@ {% if u.admin %}Admin{% else %}Regular user{% endif %} {% if u.enabled %}Enabled{% else %}Disabled{% endif %} - Edit - Delete + Edit + Delete {% endfor %}