diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4e5e7f0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +indent_style = space +insert_final_newline = true +indent_size = 4 +max_line_length = 100 + + diff --git a/.env b/.env new file mode 100644 index 0000000..8c4c540 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +APP_JWT_SECRET=bc1996ea-5464-424a-9a38-5604f2bc865a diff --git a/.gitignore b/.gitignore index ea8c4bf..2be03f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/tmp/dbs/*.db diff --git a/Cargo.lock b/Cargo.lock index feae8ed..14d0de6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,6 +245,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-macros" version = "0.4.2" @@ -289,12 +312,24 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -404,6 +439,17 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -470,6 +516,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -576,6 +631,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -585,6 +655,18 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "frank_jwt" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9febc9f09c7569636ba0e3d98a12addd6b11b3b3bc1d7baad06d52c60c1bbadd" +dependencies = [ + "base64 0.13.1", + "openssl", + "serde", + "serde_json", +] + [[package]] name = "fully_pub" version = "0.1.4" @@ -684,8 +766,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -930,6 +1014,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1030,14 +1144,20 @@ dependencies = [ "argh", "argon2", "axum", + "axum-extra", "axum-macros", "axum-template", "chrono", + "dotenvy", "env_logger", + "frank_jwt", "fully_pub", + "jsonwebtoken", + "jwt", "log", "minijinja", "minijinja-embed", + "rand_core", "redis", "serde", "serde_json", @@ -1129,6 +1249,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1174,6 +1300,44 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -1214,6 +1378,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1268,6 +1442,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1378,6 +1558,21 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.6" @@ -1536,6 +1731,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -1690,7 +1897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", "bytes", @@ -1734,7 +1941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", "chrono", @@ -1889,6 +2096,37 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -2141,6 +2379,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.2" diff --git a/Cargo.toml b/Cargo.toml index 06670e9..d3ef070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,12 @@ tower-http = { version = "0.6.1", features = ["fs"] } totp-rs = "5.6" minijinja-embed = "2.3.1" axum-macros = "0.4.2" +rand_core = { version = "0.6.4", features = ["std"] } +jwt = "0.16.0" +dotenvy = "0.15.7" +frank_jwt = "3.1.3" +jsonwebtoken = "9.3.0" +axum-extra = { version = "0.9.4", features = ["cookie"] } [build-dependencies] minijinja-embed = "2.3.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6df4894 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Minauth + +## Features + +- [x] register +- [x] login +- [ ] authorize diff --git a/assets/.gitignore b/assets/.gitignore new file mode 100644 index 0000000..140fd58 --- /dev/null +++ b/assets/.gitignore @@ -0,0 +1,2 @@ +vendor/ +node_modules/ diff --git a/assets/package-lock.json b/assets/package-lock.json new file mode 100644 index 0000000..307f6f7 --- /dev/null +++ b/assets/package-lock.json @@ -0,0 +1,46 @@ +{ + "name": "assets", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "assets", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "bootstrap": "^5.3.3" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/bootstrap": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", + "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + } + } +} diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..32b45e3 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,15 @@ +{ + "name": "assets", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "bootstrap": "^5.3.3" + } +} diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..70c4d98 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + minijinja_embed::embed_templates!("src/templates"); +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..a371acc --- /dev/null +++ b/config.toml @@ -0,0 +1,9 @@ +[instance] +name = "Example org" +logo_uri = "https://example.org/logo.png" + +[[applications]] +slug = "demo_app" +name = "Demo app" +client_id = "a1785786-8be1-443c-9a6f-35feed703609" +client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a" diff --git a/justfile b/justfile new file mode 100644 index 0000000..f029f9e --- /dev/null +++ b/justfile @@ -0,0 +1,18 @@ +export RUST_BACKTRACE := "1" +export RUST_LOG := "trace" + +watch-run: + cargo-watch -x 'run -- --config ./config.toml --database ./tmp/dbs/minauth.db --static-assets ./assets' + +run: + cargo run -- --database ./tmp/dbs/minauth.db --config ./config.toml --static-assets ./assets + +docker-run: + docker run -p 3085:8080 -v ./tmp/docker/config:/etc/minauth -v ./tmp/docker/db:/var/lib/minauth minauth + +docker-init-db: + docker run -v ./tmp/docker/config:/etc/minauth -v ./tmp/docker/db:/var/lib/minauth autotasker /usr/local/bin/minauth_init_db.sh + +docker-build: + docker build -t minauth . + diff --git a/migrations/all.sql b/migrations/all.sql new file mode 100644 index 0000000..dcef575 --- /dev/null +++ b/migrations/all.sql @@ -0,0 +1,15 @@ +DROP TABLE IF EXISTS users; +CREATE TABLE users ( + id TEXT PRIMARY KEY, + handle TEXT NOT NULL, + full_name TEXT, + email TEXT, + website TEXT, + picture BLOB, + + status TEXT CHECK(status IN ('Active','Disabled')) NOT NULL DEFAULT 'Disabled', + password_hash TEXT, + activation_token TEXT, + last_login_at DATETIME, + created_at DATETIME NOT NULL +); diff --git a/src/cli.rs b/src/cli.rs index 2235eac..639d1b2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,8 +1,8 @@ use argh::FromArgs; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result}; use log::info; -use crate::{get_app_context, server::{start_http_server, ServerConfig}}; +use crate::{get_app_context, server::{start_http_server, ServerConfig}, DEFAULT_ASSETS_PATH}; #[derive(Debug, FromArgs)] /// Autotasker daemon @@ -14,7 +14,7 @@ struct ServerCliFlags { /// path to the Sqlite3 DB file to use #[argh(option)] database: Option, - + /// path to the static assets dir #[argh(option)] static_assets: Option, @@ -27,12 +27,11 @@ struct ServerCliFlags { listen_port: u32 } -const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets"; - +/// handle CLI arguments to start process daemon pub async fn start_server_cli() -> Result<()> { info!("Starting minauth"); let flags: ServerCliFlags = argh::from_env(); - let (config, db_pool) = get_app_context(crate::StartAppConfig { + let (config, secrets, db_pool) = get_app_context(crate::StartAppConfig { config_path: flags.config, database_path: flags.database }).await.context("Getting app context")?; @@ -43,6 +42,7 @@ pub async fn start_server_cli() -> Result<()> { listen_port: flags.listen_port }, config, + secrets, db_pool ).await } diff --git a/src/controllers/ui/authorize.rs b/src/controllers/ui/authorize.rs index e69de29..210c9bb 100644 --- a/src/controllers/ui/authorize.rs +++ b/src/controllers/ui/authorize.rs @@ -0,0 +1,19 @@ +use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}}; +use minijinja::context; + +use crate::server::AppState; + + + +pub async fn authorize_form( + State(app_state): State +) -> impl IntoResponse { + // 1. Verify if login + + Html( + app_state.templating_env.get_template("pages/authorize.html").unwrap() + .render(context!()) + .unwrap() + ) +} + diff --git a/src/controllers/ui/login.rs b/src/controllers/ui/login.rs index 96650e3..8700267 100644 --- a/src/controllers/ui/login.rs +++ b/src/controllers/ui/login.rs @@ -1,26 +1,99 @@ -use axum::{extract::State, response::{Html, IntoResponse}}; +use chrono::{Duration, SecondsFormat, Utc}; +use log::info; +use serde::{Deserialize, Serialize}; +use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form}; +use fully_pub::fully_pub; use minijinja::context; -use crate::server::AppState; +use crate::{ + models::user::{User, UserStatus}, + server::AppState, + services::{password::verify_password_hash, session::create_token} +}; pub async fn login_form( State(app_state): State ) -> impl IntoResponse { Html( - app_state.templating_env.get_template("pages/home.html").unwrap() + app_state.templating_env.get_template("pages/login.html").unwrap() .render(context!()) .unwrap() ) } +#[derive(Debug, Deserialize)] +#[fully_pub] +struct LoginForm { + /// handle or email or user_id + login: String, + password: String +} + +const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7kUIXjWM4g$6XqwuHt/+xl0d5J4BYKuIbg2acBp6udxMCnmJ6QfceY"; + pub async fn perform_login( - State(app_state): State + State(app_state): State, + Form(login): Form ) -> impl IntoResponse { - Html( - app_state.templating_env.get_template("pages/home.html").unwrap() - .render(context!()) - .unwrap() - ) + // get user from db + let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2") + .bind(&login.login) + .bind(&login.login) + .fetch_one(&app_state.db) + .await; + + let password_hash = match &user_res { + Ok(u) => u + .password_hash + .clone() + .unwrap_or(DUMMY_PASSWORD_HASH.into()), + Err(_e) => DUMMY_PASSWORD_HASH.into() + }; + + let templ = app_state.templating_env.get_template("pages/login.html").unwrap(); + + if verify_password_hash(password_hash, login.password).is_err() { + return ( + StatusCode::BAD_REQUEST, + Html( + templ.render(context!( + error => Some("Invalid login or password.".to_string()) + )).unwrap() + ) + ).into_response(); + } + + let user = user_res.expect("Expected User to be found."); + if user.status == UserStatus::Disabled { + return ( + StatusCode::BAD_REQUEST, + Html( + templ.render(context!( + error => Some("This account is disabled.".to_string()) + )).unwrap() + ) + ).into_response(); + } + + info!("User {:?} {:?} logged in", &user.handle, &user.email); + let _result = sqlx::query("UPDATE users SET last_login_at = $2 WHERE id = $1") + .bind(user.id.clone()) + .bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)) + .execute(&app_state.db) + .await.unwrap(); + + let jwt = create_token(&app_state.secrets, &user); + + // TODO: handle keep_session boolean from form and specify cookie max age only if this setting + // is true + let cookie_max_age = Duration::days(7).num_seconds(); + let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}"); + let mut headers = HeaderMap::new(); + headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap()); + headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap()); + + (StatusCode::FOUND, headers, Html( + templ.render(context!()).unwrap() + )).into_response() } - diff --git a/src/controllers/ui/logout.rs b/src/controllers/ui/logout.rs new file mode 100644 index 0000000..149a1de --- /dev/null +++ b/src/controllers/ui/logout.rs @@ -0,0 +1,12 @@ +use axum::{http::StatusCode, response::Redirect}; +use axum_extra::extract::CookieJar; + +pub async fn perform_logout( + cookies: CookieJar +) -> Result<(CookieJar, Redirect), StatusCode> { + Ok(( + cookies.remove("minauth_jwt"), + Redirect::to("/") + )) +} + diff --git a/src/controllers/ui/me.rs b/src/controllers/ui/me.rs new file mode 100644 index 0000000..a1359d6 --- /dev/null +++ b/src/controllers/ui/me.rs @@ -0,0 +1,18 @@ +use axum::{extract::State, response::{Html, IntoResponse}, Extension}; +use minijinja::context; + +use crate::{server::AppState, services::session::TokenClaims}; + +pub async fn me_page( + State(app_state): State, + Extension(token_claims): Extension +) -> impl IntoResponse { + Html( + app_state.templating_env.get_template("pages/me.html").unwrap() + .render(context!( + token_claims => token_claims + )) + .unwrap() + ) +} + diff --git a/src/controllers/ui/mod.rs b/src/controllers/ui/mod.rs index 51ef0a3..5910f89 100644 --- a/src/controllers/ui/mod.rs +++ b/src/controllers/ui/mod.rs @@ -2,3 +2,5 @@ pub mod home; pub mod authorize; pub mod login; pub mod register; +pub mod me; +pub mod logout; diff --git a/src/controllers/ui/register.rs b/src/controllers/ui/register.rs index e69de29..434cb9f 100644 --- a/src/controllers/ui/register.rs +++ b/src/controllers/ui/register.rs @@ -0,0 +1,86 @@ +use axum::{extract::State, response::{Html, IntoResponse}, Form}; +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use minijinja::context; +use fully_pub::fully_pub; +use uuid::Uuid; + +use crate::{models::user::{User, UserStatus}, server::AppState, services::password::get_password_hash}; + +pub async fn register_form( + State(app_state): State +) -> impl IntoResponse { + Html( + app_state.templating_env.get_template("pages/register.html").unwrap() + .render(context!()) + .unwrap() + ) +} + + +#[derive(Debug, Deserialize)] +#[fully_pub] +struct RegisterForm { + handle: String, + email: String, + password: String, +} + +pub async fn perform_register( + State(app_state): State, + Form(register): Form +) -> impl IntoResponse { + let templ = app_state.templating_env.get_template("pages/register.html").unwrap(); + let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2") + .bind(®ister.handle) + .bind(®ister.email) + .fetch_one(&app_state.db) + .await; + if user_res.is_ok() { + // user already exists + return Html( + templ.render(context!( + success => true + )).unwrap() + ); + } + + let password_hash = Some( + get_password_hash(register.password) + .expect("To process password").1 + ); + let user = User { + id: Uuid::new_v4().to_string(), + email: Some(register.email), + handle: register.handle, + full_name: None, + picture: None, + + password_hash, + activation_token: None, + status: UserStatus::Active, + created_at: Utc::now(), + website: None, + last_login_at: None + }; + // save in DB + let _result = sqlx::query("INSERT INTO users (id, handle, email, status, password_hash, created_at) VALUES ($1, $2, $3, $4, $5, $6)") + .bind(user.id) + .bind(user.handle) + .bind(user.email) + .bind(user.status.to_string()) + .bind(user.password_hash) + .bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true)) + .execute(&app_state.db) + .await.unwrap(); + + Html( + templ + .render(context!( + success => true + )) + .unwrap() + ) +} + + diff --git a/src/main.rs b/src/main.rs index f75cadf..53c9c00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,20 +4,21 @@ pub mod router; pub mod server; pub mod database; pub mod cli; +pub mod utils; +pub mod services; +pub mod middlewares; -use std::fs; +use std::{env, fs}; use anyhow::{Result, Context, anyhow}; use database::prepare_database; use log::info; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -use sqlx::{ConnectOptions, Pool, Sqlite}; -use models::config::Config; -use minijinja::{context, Environment}; +use sqlx::{Pool, Sqlite}; +use models::config::{AppSecrets, Config}; -const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db"; -const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets"; -const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml"; +pub const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db"; +pub const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets"; +pub const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml"; fn get_config(path: String) -> Result { let inp_def_yaml = fs::read_to_string(path) @@ -37,15 +38,23 @@ async fn main() -> Result<()> { cli::start_server_cli().await } -async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, Pool)> { +async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, AppSecrets, Pool)> { env_logger::init(); - let pool = prepare_database( - &start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string()) - ).await.context("Could not prepare db")?; + let _ = dotenvy::dotenv(); + + let database_path = &start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string()); + info!("Using database file at {}", database_path); + let pool = prepare_database(&database_path).await.context("Could not prepare db.")?; let config_path = start_app_config.config_path.unwrap_or(DEFAULT_CONFIG_PATH.to_string()); info!("Using config file at {}", &config_path); - let config: Config = get_config(config_path).expect("Cannot get config"); + let config: Config = get_config(config_path) + .expect("Cannot get config."); - Ok((config, pool)) + dotenvy::dotenv().context("loading .env")?; + let secrets = AppSecrets { + jwt_secret: env::var("APP_JWT_SECRET").context("Expecting APP_JWT_SECRET env var.")? + }; + + Ok((config, secrets, pool)) } diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs new file mode 100644 index 0000000..cabea4a --- /dev/null +++ b/src/middlewares/auth.rs @@ -0,0 +1,28 @@ +use axum::{extract::{Request, State}, http::{HeaderMap, StatusCode}, middleware::Next, response::{Response, Html, IntoResponse}}; +use axum_extra::extract::CookieJar; + +use crate::{server::AppState, services::session::verify_token}; + + +pub async fn auth_middleware( + State(app_state): State, + cookies: CookieJar, + mut req: Request, + next: Next, +) -> Result { + let jwt = match cookies.get("minauth_jwt") { + Some(cookie) => cookie.value(), + None => { + // return Err((StatusCode::UNAUTHORIZED, Html("Did not found header"))); + return Err(StatusCode::UNAUTHORIZED); + } + }; + let token_claims = match verify_token(&app_state.secrets, &jwt) { + Ok(val) => val, + Err(_e) => { + return Err(StatusCode::UNAUTHORIZED); + } + }; + req.extensions_mut().insert(token_claims); + Ok(next.run(req).await) +} diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/middlewares/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/models/app.rs b/src/models/app.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/config.rs b/src/models/config.rs index 41ecbe9..8261adb 100644 --- a/src/models/config.rs +++ b/src/models/config.rs @@ -10,15 +10,30 @@ use uuid::Uuid; /// Instance branding/customization config struct InstanceConfig { name: String, - logo_uri: String + logo_uri: Option } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[fully_pub] +struct Application { + slug: String, + name: String, + client_id: String, + client_secret: String +} #[derive(Debug, Clone, Serialize, Deserialize)] #[fully_pub] /// Configuration of this minauthator instance struct Config { /// configure current autotasker instance - instance: Option, + instance: InstanceConfig, + applications: Vec } + +#[derive(Debug, Clone)] +#[fully_pub] +pub struct AppSecrets { + jwt_secret: String +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 67e3929..2c3f277 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,2 @@ -pub mod app; pub mod config; pub mod user; diff --git a/src/models/user.rs b/src/models/user.rs index e69de29..856bc53 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -0,0 +1,30 @@ +use fully_pub::fully_pub; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(strum_macros::Display)] +#[fully_pub] +enum UserStatus { + Active, + Disabled +} + +#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)] +#[fully_pub] +struct User { + /// uuid + id: String, + handle: String, + full_name: Option, + email: Option, + website: Option, + picture: Option, // embeded blob to store profile pic + password_hash: Option, // argon2 password hash + status: UserStatus, + activation_token: Option, + + last_login_at: Option>, + created_at: DateTime +} + diff --git a/src/router.rs b/src/router.rs index 756ee7e..546a341 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,10 +1,27 @@ -use axum::{routing::{get, post}, Router}; +use axum::{middleware, routing::{get, post}, Router}; +use tower_http::services::ServeDir; -use crate::{controllers::ui, server::AppState}; +use crate::{controllers::ui, middlewares::auth::auth_middleware, server::{AppState, ServerConfig}}; -pub fn build_router() -> Router { - Router::new() +pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router { + let public_routes = Router::new() .route("/", get(ui::home::home)) + .route("/register", get(ui::register::register_form)) + .route("/register", post(ui::register::perform_register)) .route("/login", get(ui::login::login_form)) - .route("/login", post(ui::login::perform_login)) + .route("/login", post(ui::login::perform_login)); + + let user_routes = Router::new() + .route("/me", get(ui::me::me_page)) + .route("/logout", get(ui::logout::perform_logout)) + .route("/authorize", get(ui::authorize::authorize_form)) + .layer(middleware::from_fn_with_state(app_state, auth_middleware)); + + Router::new() + .merge(public_routes) + .merge(user_routes) + .nest_service( + "/assets", + ServeDir::new(server_config.assets_path.clone()) + ) } diff --git a/src/server.rs b/src/server.rs index 72b11b1..a3db907 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,23 +4,17 @@ use anyhow::{Result, Context, anyhow}; use log::info; use minijinja::{context, Environment}; use sqlx::{Pool, Sqlite}; -use crate::{models::config::Config, router::build_router}; +use crate::{models::config::{AppSecrets, Config}, router::build_router}; fn build_templating_env(config: &Config) -> Environment<'static> { - let mut templating_env = Environment::new(); + let mut env = Environment::new(); - let _ = templating_env - .add_template("layouts/base.html", include_str!("./templates/layouts/base.html")); - let _ = templating_env - .add_template("pages/home.html", include_str!("./templates/pages/home.html")); + minijinja_embed::load_templates!(&mut env); - // TODO: better loading with embed https://docs.rs/minijinja-embed/latest/minijinja_embed/ - templating_env.add_global("gl", context! { - instance => context! { - version => "1.243".to_string() - } + env.add_global("gl", context! { + instance => config.instance }); - templating_env + env } #[derive(Debug)] @@ -35,25 +29,31 @@ pub struct ServerConfig { #[derive(Debug, Clone)] #[fully_pub] pub struct AppState { + secrets: AppSecrets, config: Config, db: Pool, templating_env: Environment<'static> } -pub async fn start_http_server(server_config: ServerConfig, config: Config, db_pool: Pool) -> Result<()> { +pub async fn start_http_server( + server_config: ServerConfig, + config: Config, + secrets: AppSecrets, + db_pool: Pool +) -> Result<()> { // build state let state = AppState { templating_env: build_templating_env(&config), config, + secrets, db: db_pool }; // build routes - let services = build_router() - .nest_service( - "/assets", - ServeDir::new(server_config.assets_path) - ) + let services = build_router( + &server_config, + state.clone() + ) .with_state(state); let listen_addr = format!("{}:{}", server_config.listen_host, server_config.listen_port); diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..5348a28 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,2 @@ +pub mod password; +pub mod session; diff --git a/src/services/password.rs b/src/services/password.rs new file mode 100644 index 0000000..c9e246b --- /dev/null +++ b/src/services/password.rs @@ -0,0 +1,35 @@ +use anyhow::{anyhow, Result}; +use argon2::{ + password_hash::{ + rand_core::OsRng, + PasswordHash, PasswordHasher, PasswordVerifier, SaltString + }, + Argon2 +}; + +pub fn get_password_hash(password: String) -> Result<(String, String)> { + let salt = SaltString::generate(&mut OsRng); + + // Argon2 with default params (Argon2id v19) + let argon2 = Argon2::default(); + + // Hash password to PHC string ($argon2id$v=19$...) + match argon2.hash_password(password.as_bytes(), &salt) { + Ok(val) => Ok((salt.to_string(), val.to_string())), + Err(_) => Err(anyhow!("Failed to process password.")) + } +} + +pub fn verify_password_hash(password_hash: String, password: String) -> Result<()> { + let parsed_hash = match PasswordHash::new(&password_hash) { + Ok(val) => val, + Err(_) => { + return Err(anyhow!("Failed to parse password hash")); + } + }; + match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(()), + Err(_) => Err(anyhow!("Failed to verify password.")) + } +} + diff --git a/src/services/session.rs b/src/services/session.rs new file mode 100644 index 0000000..2b0bbba --- /dev/null +++ b/src/services/session.rs @@ -0,0 +1,38 @@ +use fully_pub::fully_pub; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use jsonwebtoken::{encode, decode, get_current_timestamp, Header, Algorithm, Validation, EncodingKey, DecodingKey}; + +use crate::models::{config::AppSecrets, user::User}; + + +#[derive(Serialize, Deserialize, Clone)] +#[fully_pub] +struct TokenClaims { + sub: String, // user id + exp: u64 +} + +pub fn create_token(secrets: &AppSecrets, user: &User) -> String { + let claims = TokenClaims { + sub: user.id.clone(), + exp: get_current_timestamp() + 86_400 + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(&secrets.jwt_secret.as_bytes()) + ).expect("Create token"); + + return token; +} + +pub fn verify_token(secrets: &AppSecrets, jwt: &str) -> Result { + let token_data = decode::( + &jwt, + &DecodingKey::from_secret(&secrets.jwt_secret.as_bytes()), + &Validation::new(Algorithm::HS256) + )?; + + Ok(token_data.claims) +} diff --git a/src/templates/components/footer.html b/src/templates/components/footer.html new file mode 100644 index 0000000..f2b511b --- /dev/null +++ b/src/templates/components/footer.html @@ -0,0 +1,15 @@ + diff --git a/src/templates/components/header.html b/src/templates/components/header.html new file mode 100644 index 0000000..f928487 --- /dev/null +++ b/src/templates/components/header.html @@ -0,0 +1,26 @@ +
+ +
diff --git a/src/templates/layouts/base.html b/src/templates/layouts/base.html index 95532cf..6282a31 100644 --- a/src/templates/layouts/base.html +++ b/src/templates/layouts/base.html @@ -4,19 +4,14 @@ Minauth - - + -
- Minauth -
+ {% include "components/header.html" %}
{% block body %}{% endblock %}
-
- Minauth {{ gl.instance.version }} -
+ {% include "components/footer.html" %} diff --git a/src/templates/pages/login.html b/src/templates/pages/login.html index e69de29..1b08125 100644 --- a/src/templates/pages/login.html +++ b/src/templates/pages/login.html @@ -0,0 +1,34 @@ +{% extends "layouts/base.html" %} +{% block body %} +

Login

+ +{% if error %} +
+ Error: {{ error }} +
+{% endif %} +
+
+ + +
+
+ + +
+
+ + +
+ + +
+{% endblock %} diff --git a/src/templates/pages/me.html b/src/templates/pages/me.html new file mode 100644 index 0000000..7b64a8c --- /dev/null +++ b/src/templates/pages/me.html @@ -0,0 +1,9 @@ +{% extends "layouts/base.html" %} +{% block body %} +

Me page

+ +

+ {{ token_claims.sub }} +

+{% endblock %} + diff --git a/src/templates/pages/register.html b/src/templates/pages/register.html new file mode 100644 index 0000000..b9f2881 --- /dev/null +++ b/src/templates/pages/register.html @@ -0,0 +1,47 @@ +{% extends "layouts/base.html" %} +{% block body %} + +

Register

+{% if error %} +
+ Error: {{ error }} +
+{% endif %} +{% if success %} +
+ If all the information you submitted are valid and unique, you're account + has been created and we've sent you a confirmation email. +
+{% endif %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+{% endblock %} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..c9e246b --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,35 @@ +use anyhow::{anyhow, Result}; +use argon2::{ + password_hash::{ + rand_core::OsRng, + PasswordHash, PasswordHasher, PasswordVerifier, SaltString + }, + Argon2 +}; + +pub fn get_password_hash(password: String) -> Result<(String, String)> { + let salt = SaltString::generate(&mut OsRng); + + // Argon2 with default params (Argon2id v19) + let argon2 = Argon2::default(); + + // Hash password to PHC string ($argon2id$v=19$...) + match argon2.hash_password(password.as_bytes(), &salt) { + Ok(val) => Ok((salt.to_string(), val.to_string())), + Err(_) => Err(anyhow!("Failed to process password.")) + } +} + +pub fn verify_password_hash(password_hash: String, password: String) -> Result<()> { + let parsed_hash = match PasswordHash::new(&password_hash) { + Ok(val) => val, + Err(_) => { + return Err(anyhow!("Failed to parse password hash")); + } + }; + match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(()), + Err(_) => Err(anyhow!("Failed to verify password.")) + } +} +