feat: basic register and login

This commit is contained in:
Matthieu Bessat 2024-10-21 00:05:20 +02:00
parent 98be8dd574
commit 327f0cd5b9
39 changed files with 990 additions and 66 deletions

12
.editorconfig Normal file
View file

@ -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

1
.env Normal file
View file

@ -0,0 +1 @@
APP_JWT_SECRET=bc1996ea-5464-424a-9a38-5604f2bc865a

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target /target
/tmp/dbs/*.db

248
Cargo.lock generated
View file

@ -245,6 +245,29 @@ dependencies = [
"tracing", "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]] [[package]]
name = "axum-macros" name = "axum-macros"
version = "0.4.2" version = "0.4.2"
@ -289,12 +312,24 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.6.0" version = "1.6.0"
@ -404,6 +439,17 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" 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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@ -470,6 +516,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -576,6 +631,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.1" version = "1.2.1"
@ -585,6 +655,18 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "fully_pub" name = "fully_pub"
version = "0.1.4" version = "0.1.4"
@ -684,8 +766,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -930,6 +1014,36 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1030,14 +1144,20 @@ dependencies = [
"argh", "argh",
"argon2", "argon2",
"axum", "axum",
"axum-extra",
"axum-macros", "axum-macros",
"axum-template", "axum-template",
"chrono", "chrono",
"dotenvy",
"env_logger", "env_logger",
"frank_jwt",
"fully_pub", "fully_pub",
"jsonwebtoken",
"jwt",
"log", "log",
"minijinja", "minijinja",
"minijinja-embed", "minijinja-embed",
"rand_core",
"redis", "redis",
"serde", "serde",
"serde_json", "serde_json",
@ -1129,6 +1249,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@ -1174,6 +1300,44 @@ version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 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]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.3" version = "0.12.3"
@ -1214,6 +1378,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -1268,6 +1442,12 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.20" version = "0.2.20"
@ -1378,6 +1558,21 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.6" version = "0.9.6"
@ -1536,6 +1731,18 @@ dependencies = [
"rand_core", "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]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -1690,7 +1897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.21.7",
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
@ -1734,7 +1941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.21.7",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono", "chrono",
@ -1889,6 +2096,37 @@ dependencies = [
"syn 2.0.79", "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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.8.0" version = "1.8.0"
@ -2141,6 +2379,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.2" version = "2.5.2"

View file

@ -42,6 +42,12 @@ tower-http = { version = "0.6.1", features = ["fs"] }
totp-rs = "5.6" totp-rs = "5.6"
minijinja-embed = "2.3.1" minijinja-embed = "2.3.1"
axum-macros = "0.4.2" 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] [build-dependencies]
minijinja-embed = "2.3.1" minijinja-embed = "2.3.1"

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Minauth
## Features
- [x] register
- [x] login
- [ ] authorize

2
assets/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
vendor/
node_modules/

46
assets/package-lock.json generated Normal file
View file

@ -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"
}
}
}
}

15
assets/package.json Normal file
View file

@ -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"
}
}

3
build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
minijinja_embed::embed_templates!("src/templates");
}

9
config.toml Normal file
View file

@ -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"

18
justfile Normal file
View file

@ -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 .

15
migrations/all.sql Normal file
View file

@ -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
);

View file

@ -1,8 +1,8 @@
use argh::FromArgs; use argh::FromArgs;
use anyhow::{anyhow, Context, Result}; use anyhow::{Context, Result};
use log::info; 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)] #[derive(Debug, FromArgs)]
/// Autotasker daemon /// Autotasker daemon
@ -27,12 +27,11 @@ struct ServerCliFlags {
listen_port: u32 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<()> { pub async fn start_server_cli() -> Result<()> {
info!("Starting minauth"); info!("Starting minauth");
let flags: ServerCliFlags = argh::from_env(); 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, config_path: flags.config,
database_path: flags.database database_path: flags.database
}).await.context("Getting app context")?; }).await.context("Getting app context")?;
@ -43,6 +42,7 @@ pub async fn start_server_cli() -> Result<()> {
listen_port: flags.listen_port listen_port: flags.listen_port
}, },
config, config,
secrets,
db_pool db_pool
).await ).await
} }

View file

@ -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<AppState>
) -> impl IntoResponse {
// 1. Verify if login
Html(
app_state.templating_env.get_template("pages/authorize.html").unwrap()
.render(context!())
.unwrap()
)
}

View file

@ -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 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( pub async fn login_form(
State(app_state): State<AppState> State(app_state): State<AppState>
) -> impl IntoResponse { ) -> impl IntoResponse {
Html( Html(
app_state.templating_env.get_template("pages/home.html").unwrap() app_state.templating_env.get_template("pages/login.html").unwrap()
.render(context!()) .render(context!())
.unwrap() .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( pub async fn perform_login(
State(app_state): State<AppState> State(app_state): State<AppState>,
Form(login): Form<LoginForm>
) -> impl IntoResponse { ) -> impl IntoResponse {
// 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( Html(
app_state.templating_env.get_template("pages/home.html").unwrap() templ.render(context!(
.render(context!()) error => Some("Invalid login or password.".to_string())
.unwrap() )).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()
}

View file

@ -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("/")
))
}

18
src/controllers/ui/me.rs Normal file
View file

@ -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<AppState>,
Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse {
Html(
app_state.templating_env.get_template("pages/me.html").unwrap()
.render(context!(
token_claims => token_claims
))
.unwrap()
)
}

View file

@ -2,3 +2,5 @@ pub mod home;
pub mod authorize; pub mod authorize;
pub mod login; pub mod login;
pub mod register; pub mod register;
pub mod me;
pub mod logout;

View file

@ -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<AppState>
) -> 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<AppState>,
Form(register): Form<RegisterForm>
) -> 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(&register.handle)
.bind(&register.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()
)
}

View file

@ -4,20 +4,21 @@ pub mod router;
pub mod server; pub mod server;
pub mod database; pub mod database;
pub mod cli; 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 anyhow::{Result, Context, anyhow};
use database::prepare_database; use database::prepare_database;
use log::info; use log::info;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Pool, Sqlite};
use sqlx::{ConnectOptions, Pool, Sqlite}; use models::config::{AppSecrets, Config};
use models::config::Config;
use minijinja::{context, Environment};
const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db"; pub const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db";
const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets"; pub const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets";
const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml"; pub const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml";
fn get_config(path: String) -> Result<Config> { fn get_config(path: String) -> Result<Config> {
let inp_def_yaml = fs::read_to_string(path) let inp_def_yaml = fs::read_to_string(path)
@ -37,15 +38,23 @@ async fn main() -> Result<()> {
cli::start_server_cli().await cli::start_server_cli().await
} }
async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, Pool<Sqlite>)> { async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, AppSecrets, Pool<Sqlite>)> {
env_logger::init(); env_logger::init();
let pool = prepare_database( let _ = dotenvy::dotenv();
&start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string())
).await.context("Could not prepare db")?; 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()); let config_path = start_app_config.config_path.unwrap_or(DEFAULT_CONFIG_PATH.to_string());
info!("Using config file at {}", &config_path); 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))
} }

28
src/middlewares/auth.rs Normal file
View file

@ -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<AppState>,
cookies: CookieJar,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
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)
}

1
src/middlewares/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod auth;

View file

View file

@ -10,15 +10,30 @@ use uuid::Uuid;
/// Instance branding/customization config /// Instance branding/customization config
struct InstanceConfig { struct InstanceConfig {
name: String, name: String,
logo_uri: String logo_uri: Option<String>
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[fully_pub]
struct Application {
slug: String,
name: String,
client_id: String,
client_secret: String
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[fully_pub] #[fully_pub]
/// Configuration of this minauthator instance /// Configuration of this minauthator instance
struct Config { struct Config {
/// configure current autotasker instance /// configure current autotasker instance
instance: Option<InstanceConfig>, instance: InstanceConfig,
applications: Vec<Application>
} }
#[derive(Debug, Clone)]
#[fully_pub]
pub struct AppSecrets {
jwt_secret: String
}

View file

@ -1,3 +1,2 @@
pub mod app;
pub mod config; pub mod config;
pub mod user; pub mod user;

View file

@ -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<String>,
email: Option<String>,
website: Option<String>,
picture: Option<String>, // embeded blob to store profile pic
password_hash: Option<String>, // argon2 password hash
status: UserStatus,
activation_token: Option<String>,
last_login_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>
}

View file

@ -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<AppState> { pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
Router::new() let public_routes = Router::new()
.route("/", get(ui::home::home)) .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", 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())
)
} }

View file

@ -4,23 +4,17 @@ use anyhow::{Result, Context, anyhow};
use log::info; use log::info;
use minijinja::{context, Environment}; use minijinja::{context, Environment};
use sqlx::{Pool, Sqlite}; 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> { fn build_templating_env(config: &Config) -> Environment<'static> {
let mut templating_env = Environment::new(); let mut env = Environment::new();
let _ = templating_env minijinja_embed::load_templates!(&mut 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"));
// TODO: better loading with embed https://docs.rs/minijinja-embed/latest/minijinja_embed/ env.add_global("gl", context! {
templating_env.add_global("gl", context! { instance => config.instance
instance => context! {
version => "1.243".to_string()
}
}); });
templating_env env
} }
#[derive(Debug)] #[derive(Debug)]
@ -35,24 +29,30 @@ pub struct ServerConfig {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[fully_pub] #[fully_pub]
pub struct AppState { pub struct AppState {
secrets: AppSecrets,
config: Config, config: Config,
db: Pool<Sqlite>, db: Pool<Sqlite>,
templating_env: Environment<'static> templating_env: Environment<'static>
} }
pub async fn start_http_server(server_config: ServerConfig, config: Config, db_pool: Pool<Sqlite>) -> Result<()> { pub async fn start_http_server(
server_config: ServerConfig,
config: Config,
secrets: AppSecrets,
db_pool: Pool<Sqlite>
) -> Result<()> {
// build state // build state
let state = AppState { let state = AppState {
templating_env: build_templating_env(&config), templating_env: build_templating_env(&config),
config, config,
secrets,
db: db_pool db: db_pool
}; };
// build routes // build routes
let services = build_router() let services = build_router(
.nest_service( &server_config,
"/assets", state.clone()
ServeDir::new(server_config.assets_path)
) )
.with_state(state); .with_state(state);

2
src/services/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod password;
pub mod session;

35
src/services/password.rs Normal file
View file

@ -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."))
}
}

38
src/services/session.rs Normal file
View file

@ -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<TokenClaims> {
let token_data = decode::<TokenClaims>(
&jwt,
&DecodingKey::from_secret(&secrets.jwt_secret.as_bytes()),
&Validation::new(Algorithm::HS256)
)?;
Ok(token_data.claims)
}

View file

@ -0,0 +1,15 @@
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
<p class="col-md-4 mb-0 text-muted">© 2022 Company, Inc</p>
<a href="/" class="col-md-4 d-flex align-items-center justify-content-center mb-3 mb-md-0 me-md-auto link-dark text-decoration-none">
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"></use></svg>
</a>
<ul class="nav col-md-4 justify-content-end">
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Home</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Features</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Pricing</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">FAQs</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">About</a></li>
</ul>
</footer>

View file

@ -0,0 +1,26 @@
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Minauth</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/register">Register</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
</header>

View file

@ -4,19 +4,14 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minauth</title> <title>Minauth</title>
<link href="/assets/styles/simple.css" rel="stylesheet"> <link href="/assets/node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/styles/app.css" rel="stylesheet">
</head> </head>
<body> <body>
<header> {% include "components/header.html" %}
Minauth
</header>
<main class="container"> <main class="container">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</main> </main>
<footer> {% include "components/footer.html" %}
Minauth {{ gl.instance.version }}
</footer>
</body> </body>
</html> </html>

View file

@ -0,0 +1,34 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Login</h1>
<!-- Login form -->
{% if error %}
<div>
Error: {{ error }}
</div>
{% endif %}
<form id="login-form" method="post">
<div class="mb-3">
<label for="login" class="form-label">Email or username</label>
<input
id="login" name="login" type="text"
required
class="form-control"
/>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input
id="password" name="password" type="password"
required
class="form-control"
/>
</div>
<div class="mb-3 form-check">
<input id="keep_session" type="checkbox" class="form-check-input">
<label class="form-check-label" for="keep_session">Check me out</label>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Me page</h1>
<p>
{{ token_claims.sub }}
</p>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "layouts/base.html" %}
{% block body %}
<!-- Register form -->
<h1>Register</h1>
{% if error %}
<div class="alert alert-danger">
Error: {{ error }}
</div>
{% endif %}
{% if success %}
<div class="alert alert-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.
</div>
{% endif %}
<form id="register-form" method="post">
<div class="mb-3">
<label for="handle" class="form-label">Handle</label>
<input
id="handle" name="handle" type="text"
minlength="2"
maxlength="255"
required
class="form-control"
/>
</div>
<div>
<label for="email">Email</label>
<input
id="email" name="email" type="email"
required
class="form-control"
/>
</div>
<div>
<label for="password">Password</label>
<input
id="password" name="password" type="password"
required
class="form-control"
/>
</div>
<button type="submit" class="btn btn-primary">
Register
</button>
</form>
{% endblock %}

35
src/utils.rs Normal file
View file

@ -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."))
}
}