From d908586dfae090377e49e89c7741b06511bd14fc Mon Sep 17 00:00:00 2001 From: Matthieu Bessat Date: Sat, 2 Nov 2024 17:37:57 +0100 Subject: [PATCH] WIP: feat: add user details update --- Cargo.lock | 149 ++++++++ Cargo.toml | 9 +- README.md | 5 + TODO.md | 11 + assets/style/app.css | 3 + docs/API.md | 9 + docs/draft.md | 3 + justfile | 3 + locales/en.toml | 0 locales/fr.toml | 0 migrations/all.sql | 4 +- src/controllers/ui/authorize.rs | 11 +- src/controllers/ui/me.rs | 108 +++++- src/middlewares/auth.rs | 2 +- src/models/authorization.rs | 22 ++ src/models/user.rs | 2 +- src/router.rs | 2 + src/server.rs | 3 +- src/services/session.rs | 3 +- src/templates/components/footer.html | 12 +- src/templates/components/header.html | 15 +- src/templates/layouts/base.html | 1 + src/templates/pages/login.html | 2 +- src/templates/pages/me.html | 9 - src/templates/pages/me/details-form.html | 72 ++++ src/templates/pages/me/index.html | 27 ++ tmp/dbs/test.csv | 422 +++++++++++++++++++++++ 27 files changed, 871 insertions(+), 38 deletions(-) create mode 100644 TODO.md create mode 100644 assets/style/app.css create mode 100644 docs/API.md create mode 100644 docs/draft.md create mode 100644 locales/en.toml create mode 100644 locales/fr.toml create mode 100644 src/models/authorization.rs delete mode 100644 src/templates/pages/me.html create mode 100644 src/templates/pages/me/details-form.html create mode 100644 src/templates/pages/me/index.html create mode 100644 tmp/dbs/test.csv diff --git a/Cargo.lock b/Cargo.lock index 14d0de6..912cf72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,6 +209,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -291,6 +292,39 @@ dependencies = [ "thiserror", ] +[[package]] +name = "axum_typed_multipart" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0412547e063ce471a3f5ccf8a5129ae5ff64c63e40ee1bf1079dec3fcede4e7" +dependencies = [ + "anyhow", + "axum", + "axum_typed_multipart_macros", + "bytes", + "chrono", + "futures-core", + "futures-util", + "tempfile", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "axum_typed_multipart_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbb13e6a88be66ca8a226e4cee4d60eea0245bbdd4f22a95dfb90cbcf6be4b3" +dependencies = [ + "darling", + "heck 0.5.0", + "proc-macro-error2", + "quote", + "syn 2.0.79", + "ubyte", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -505,6 +539,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.79", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.79", +] + [[package]] name = "der" version = "0.7.9" @@ -552,6 +621,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "0.1.2" @@ -721,6 +799,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -741,6 +830,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -973,6 +1063,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -1147,6 +1243,8 @@ dependencies = [ "axum-extra", "axum-macros", "axum-template", + "axum_typed_multipart", + "base64 0.22.1", "chrono", "dotenvy", "env_logger", @@ -1212,6 +1310,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -1457,6 +1572,28 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "proc-macro2" version = "1.0.87" @@ -2010,6 +2147,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum_macros" version = "0.26.4" @@ -2331,6 +2474,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" + [[package]] name = "unicase" version = "2.7.0" diff --git a/Cargo.toml b/Cargo.toml index d3ef070..52e0240 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,8 @@ +cargo-features = ["codegen-backend"] + +[profile.dev] +codegen-backend = "cranelift" + [package] name = "minauthator" description = "Identity provider and OAuth2 server for an small-scale organization." @@ -32,7 +37,7 @@ sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "chrono", "uu redis = { version = "0.27.3", default-features = false, features = ["acl"] } # Web -axum = { version = "0.7.7", features = ["json"] } +axum = { version = "0.7.7", features = ["json", "multipart"] } axum-template = { version = "2.4.0", features = ["minijinja"] } minijinja = { version = "2.1", features = ["builtins"] } # to make work the static assets server @@ -48,6 +53,8 @@ dotenvy = "0.15.7" frank_jwt = "3.1.3" jsonwebtoken = "9.3.0" axum-extra = { version = "0.9.4", features = ["cookie"] } +axum_typed_multipart = "0.13.1" +base64 = "0.22.1" [build-dependencies] minijinja-embed = "2.3.1" diff --git a/README.md b/README.md index 55f5c30..cbd1a4d 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,8 @@ - [x] register - [x] login - [ ] authorize + +## Deps + +- + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1b5612e --- /dev/null +++ b/TODO.md @@ -0,0 +1,11 @@ +# TODO + +- [x] Login form +- [x] Register form +- [x] Generate JWT +- Redirect to login form if unauthenticated +- Authorize form + - Select by app client id +- Verify authorize + - Select by app client secret +- Upload picture diff --git a/assets/style/app.css b/assets/style/app.css new file mode 100644 index 0000000..5e95c84 --- /dev/null +++ b/assets/style/app.css @@ -0,0 +1,3 @@ +main { + margin-top: 2rem; +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..e252574 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,9 @@ +# API + +## authorize + +response_type=code +client_id=XXXX +redirect_uri= +scope= +state= diff --git a/docs/draft.md b/docs/draft.md new file mode 100644 index 0000000..9539fa5 --- /dev/null +++ b/docs/draft.md @@ -0,0 +1,3 @@ +# OAuth2 spec + +https://datatracker.ietf.org/doc/html/rfc6749 diff --git a/justfile b/justfile index f029f9e..86a77e0 100644 --- a/justfile +++ b/justfile @@ -16,3 +16,6 @@ docker-init-db: docker-build: docker build -t minauth . +init-db: + sqlite3 -echo tmp/dbs/minauth.db < migrations/all.sql + diff --git a/locales/en.toml b/locales/en.toml new file mode 100644 index 0000000..e69de29 diff --git a/locales/fr.toml b/locales/fr.toml new file mode 100644 index 0000000..e69de29 diff --git a/migrations/all.sql b/migrations/all.sql index dcef575..22d4e83 100644 --- a/migrations/all.sql +++ b/migrations/all.sql @@ -1,9 +1,9 @@ DROP TABLE IF EXISTS users; CREATE TABLE users ( id TEXT PRIMARY KEY, - handle TEXT NOT NULL, + handle TEXT NOT NULL UNIQUE, full_name TEXT, - email TEXT, + email TEXT UNIQUE, website TEXT, picture BLOB, diff --git a/src/controllers/ui/authorize.rs b/src/controllers/ui/authorize.rs index 210c9bb..7454730 100644 --- a/src/controllers/ui/authorize.rs +++ b/src/controllers/ui/authorize.rs @@ -1,14 +1,17 @@ -use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}}; +use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}, Extension}; use minijinja::context; -use crate::server::AppState; +use crate::{server::AppState, services::session::TokenClaims}; pub async fn authorize_form( - State(app_state): State + State(app_state): State, + Extension(token_claims): Extension ) -> impl IntoResponse { - // 1. Verify if login + + // 1. Check if the app is already authorized + // 2. Query the app details Html( app_state.templating_env.get_template("pages/authorize.html").unwrap() diff --git a/src/controllers/ui/me.rs b/src/controllers/ui/me.rs index a1359d6..b649e9b 100644 --- a/src/controllers/ui/me.rs +++ b/src/controllers/ui/me.rs @@ -1,18 +1,118 @@ -use axum::{extract::State, response::{Html, IntoResponse}, Extension}; +use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension}; +use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; +use base64::prelude::{Engine, BASE64_STANDARD}; +use fully_pub::fully_pub; use minijinja::context; -use crate::{server::AppState, services::session::TokenClaims}; +use crate::{models::user::User, server::AppState, services::session::TokenClaims}; pub async fn me_page( State(app_state): State, Extension(token_claims): Extension ) -> impl IntoResponse { + + let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(&token_claims.sub) + .fetch_one(&app_state.db) + .await + .expect("To get user from claim"); + Html( - app_state.templating_env.get_template("pages/me.html").unwrap() + app_state.templating_env.get_template("pages/me/index.html").unwrap() .render(context!( - token_claims => token_claims + user => user_res, + user_picture => user_res.picture.map(|x| BASE64_STANDARD.encode(x)) )) .unwrap() ) } + +pub async fn me_update_details_form( + State(app_state): State, + Extension(token_claims): Extension +) -> impl IntoResponse { + + let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(&token_claims.sub) + .fetch_one(&app_state.db) + .await + .expect("To get user from claim"); + + Html( + app_state.templating_env.get_template("pages/me/details-form.html").unwrap() + .render(context!( + user => user_res + )) + .unwrap() + ) +} + + +#[fully_pub] +#[derive(Debug, TryFromMultipart)] +struct UserDetailsUpdateForm { + handle: String, + email: String, + full_name: String, + website: String, + + #[form_data(limit = "5MiB")] + picture: FieldData +} + + +pub async fn me_perform_update_details( + State(app_state): State, + Extension(token_claims): Extension, + TypedMultipart(details_update): TypedMultipart +) -> impl IntoResponse { + let template = app_state.templating_env.get_template("pages/me/details-form.html").unwrap(); + + let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1") + .bind(&token_claims.sub) + .bind(details_update.handle) + .bind(details_update.email) + .bind(details_update.full_name) + .bind(details_update.website) + .bind(details_update.picture.contents.to_vec()) + .execute(&app_state.db) + .await; + + dbg!(&update_res); + + let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(&token_claims.sub) + .fetch_one(&app_state.db) + .await + .expect("To get user from claim"); + + match update_res { + Ok(_) => { + ( + StatusCode::OK, + Html( + template.render(context!( + success => true, + user => user_res + )) + .unwrap() + ) + ).into_response() + }, + Err(err) => { + dbg!(&err); + ( + StatusCode::BAD_REQUEST, + Html( + template.render(context!( + error => Some("Cannot update user details".to_string()), + user => user_res + )).unwrap() + ) + ).into_response() + } + } + +} + diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index cabea4a..269ff07 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -1,4 +1,4 @@ -use axum::{extract::{Request, State}, http::{HeaderMap, StatusCode}, middleware::Next, response::{Response, Html, IntoResponse}}; +use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response}; use axum_extra::extract::CookieJar; use crate::{server::AppState, services::session::verify_token}; diff --git a/src/models/authorization.rs b/src/models/authorization.rs new file mode 100644 index 0000000..13c409b --- /dev/null +++ b/src/models/authorization.rs @@ -0,0 +1,22 @@ +use fully_pub::fully_pub; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +enum Permissions { + ReadBasics +} + +#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)] +#[fully_pub] +struct Authorization { + /// uuid + id: String, + user_id: String, + app_id: String, + permissions: Vec, + + last_used_at: Option>, + created_at: DateTime +} + diff --git a/src/models/user.rs b/src/models/user.rs index 856bc53..bfcbde6 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -19,7 +19,7 @@ struct User { full_name: Option, email: Option, website: Option, - picture: Option, // embeded blob to store profile pic + picture: Option>, // embeded blob to store profile pic password_hash: Option, // argon2 password hash status: UserStatus, activation_token: Option, diff --git a/src/router.rs b/src/router.rs index 546a341..cb3be72 100644 --- a/src/router.rs +++ b/src/router.rs @@ -13,6 +13,8 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router let user_routes = Router::new() .route("/me", get(ui::me::me_page)) + .route("/me/details-form", get(ui::me::me_update_details_form)) + .route("/me/details-form", post(ui::me::me_perform_update_details)) .route("/logout", get(ui::logout::perform_logout)) .route("/authorize", get(ui::authorize::authorize_form)) .layer(middleware::from_fn_with_state(app_state, auth_middleware)); diff --git a/src/server.rs b/src/server.rs index a3db907..3c5a7f9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,6 +1,5 @@ use fully_pub::fully_pub; -use tower_http::services::ServeDir; -use anyhow::{Result, Context, anyhow}; +use anyhow::{Result, Context}; use log::info; use minijinja::{context, Environment}; use sqlx::{Pool, Sqlite}; diff --git a/src/services/session.rs b/src/services/session.rs index 2b0bbba..b5ee29c 100644 --- a/src/services/session.rs +++ b/src/services/session.rs @@ -9,7 +9,8 @@ use crate::models::{config::AppSecrets, user::User}; #[derive(Serialize, Deserialize, Clone)] #[fully_pub] struct TokenClaims { - sub: String, // user id + /// user id + sub: String, exp: u64 } diff --git a/src/templates/components/footer.html b/src/templates/components/footer.html index f2b511b..1569200 100644 --- a/src/templates/components/footer.html +++ b/src/templates/components/footer.html @@ -1,15 +1,15 @@ diff --git a/src/templates/components/header.html b/src/templates/components/header.html index f928487..794483c 100644 --- a/src/templates/components/header.html +++ b/src/templates/components/header.html @@ -1,21 +1,24 @@