From f9907080528c17f65a04a8d5ff61336b7c71de55 Mon Sep 17 00:00:00 2001 From: Matthieu Bessat Date: Mon, 11 Nov 2024 14:49:17 +0100 Subject: [PATCH] feat(oauth2): get access token route and read basic user info --- Cargo.lock | 7 ++ Cargo.toml | 1 + README.md | 2 +- TODO.md | 11 +- http_integration_tests/.curl-cookies | 5 - http_integration_tests/.gitignore | 1 + .../access_token_request.sh | 7 ++ http_integration_tests/authorize.sh | 7 +- http_integration_tests/get_user_info.sh | 5 + http_integration_tests/login.sh | 2 +- src/controllers/api/mod.rs | 3 +- src/controllers/api/oauth2/access_token.rs | 91 ++++++++++++++ src/controllers/api/oauth2/mod.rs | 1 + src/controllers/api/read_user.rs | 37 ++++++ src/controllers/api/verify_authorization.rs | 0 src/controllers/ui/authorize.rs | 37 ++++-- src/controllers/ui/login.rs | 4 +- src/controllers/ui/me.rs | 14 ++- src/middlewares/app_auth.rs | 112 ++++++++++++++++++ src/middlewares/mod.rs | 3 +- src/middlewares/renderer.rs | 4 +- src/middlewares/{auth.rs => user_auth.rs} | 12 +- src/models/authorization.rs | 9 +- src/models/mod.rs | 1 + src/models/token_claims.rs | 49 ++++++++ src/renderer.rs | 4 +- src/router.rs | 29 ++++- src/services/app_session.rs | 12 ++ src/services/mod.rs | 1 + src/services/oauth2.rs | 15 ++- src/services/session.rs | 24 +--- src/utils.rs | 22 +++- 32 files changed, 465 insertions(+), 67 deletions(-) delete mode 100644 http_integration_tests/.curl-cookies create mode 100644 http_integration_tests/.gitignore create mode 100755 http_integration_tests/access_token_request.sh create mode 100755 http_integration_tests/get_user_info.sh create mode 100644 src/controllers/api/oauth2/access_token.rs create mode 100644 src/controllers/api/oauth2/mod.rs create mode 100644 src/controllers/api/read_user.rs delete mode 100644 src/controllers/api/verify_authorization.rs create mode 100644 src/middlewares/app_auth.rs rename src/middlewares/{auth.rs => user_auth.rs} (81%) create mode 100644 src/models/token_claims.rs create mode 100644 src/services/app_session.rs diff --git a/Cargo.lock b/Cargo.lock index a1d4a20..afbbd80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1407,6 +1407,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "strum", "strum_macros", "tokio", "toml", @@ -2307,6 +2308,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + [[package]] name = "strum_macros" version = "0.26.4" diff --git a/Cargo.toml b/Cargo.toml index 97d3b58..1bc5080 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ base64 = "0.22.1" rand = "0.8.5" rand_core = { version = "0.6.4", features = ["std"] } url = "2.5.3" +strum = "0.26.3" [build-dependencies] minijinja-embed = "2.3.1" diff --git a/README.md b/README.md index 3b81684..56d36a8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Minauthator -Auth provider supporting [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749) +Auth provider supporting [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749). ## Features diff --git a/TODO.md b/TODO.md index ecb287b..9c01889 100644 --- a/TODO.md +++ b/TODO.md @@ -3,11 +3,10 @@ - [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 +- [ ] Redirect to login form if unauthenticated +- [x] Authorize form +- [x] Verify authorize +- [x] Upload picture +- [x] Get access token - [ ] Support error responses by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 diff --git a/http_integration_tests/.curl-cookies b/http_integration_tests/.curl-cookies deleted file mode 100644 index a2cff5b..0000000 --- a/http_integration_tests/.curl-cookies +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -localhost FALSE / FALSE 1731769020 minauth_jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiZDQzM2Y1MS1kOWViLTRlNjAtODM1OC03NTMyYzJjMGY0NmIiLCJleHAiOjE3MzEyNTA2MjB9.KOPkOd-c-UlBZ4JwWT8XeZKCbgNzSM0Hu2udTzb-rIY diff --git a/http_integration_tests/.gitignore b/http_integration_tests/.gitignore new file mode 100644 index 0000000..a9a5aec --- /dev/null +++ b/http_integration_tests/.gitignore @@ -0,0 +1 @@ +tmp diff --git a/http_integration_tests/access_token_request.sh b/http_integration_tests/access_token_request.sh new file mode 100755 index 0000000..5d7543f --- /dev/null +++ b/http_integration_tests/access_token_request.sh @@ -0,0 +1,7 @@ +#!/usr/bin/sh + +curl -v http://localhost:8085/api/token \ + -u "a1785786-8be1-443c-9a6f-35feed703609":"49c6c16a-0a8a-4981-a60d-5cb96582cc1a" \ + -d grant_type="authorization_code" \ + -d code="$(cat tmp/authorize_code.txt)" \ + -d redirect_uri="http://localhost:9090/authorize" > tmp/access_token.json diff --git a/http_integration_tests/authorize.sh b/http_integration_tests/authorize.sh index 2a54a1e..da31f3f 100755 --- a/http_integration_tests/authorize.sh +++ b/http_integration_tests/authorize.sh @@ -2,9 +2,14 @@ curl -v http://localhost:8085/authorize \ -G \ - --cookie ".curl-cookies" \ + -D "tmp/headers.txt" \ + --cookie "tmp/.curl-cookies" \ -d client_id="a1785786-8be1-443c-9a6f-35feed703609" \ -d response_type="code" \ -d redirect_uri="http://localhost:9090/authorize" \ -d scope="read_basics" \ -d state="qxYAfk4kf6pbZkms78jM" + +code="$(cat tmp/headers.txt | grep -i "location" | awk -F ": " '{print $2}' | trurl -f - -g "{query:code}")" + +echo "$code" > tmp/authorize_code.txt diff --git a/http_integration_tests/get_user_info.sh b/http_integration_tests/get_user_info.sh new file mode 100755 index 0000000..437449f --- /dev/null +++ b/http_integration_tests/get_user_info.sh @@ -0,0 +1,5 @@ +#!/usr/bin/sh + +curl -v http://localhost:8085/api/user \ + -u "a1785786-8be1-443c-9a6f-35feed703609":"49c6c16a-0a8a-4981-a60d-5cb96582cc1a" \ + -H "Authorization: JWT $(jq -r .access_token tmp/access_token.json)" diff --git a/http_integration_tests/login.sh b/http_integration_tests/login.sh index 5e5ed12..25916ac 100755 --- a/http_integration_tests/login.sh +++ b/http_integration_tests/login.sh @@ -1,6 +1,6 @@ #!/usr/bin/sh curl -v http://localhost:8085/login \ - --cookie-jar ".curl-cookies" \ + --cookie-jar "tmp/.curl-cookies" \ -d login="test" \ -d password="test" diff --git a/src/controllers/api/mod.rs b/src/controllers/api/mod.rs index 65ddbc3..d0916d8 100644 --- a/src/controllers/api/mod.rs +++ b/src/controllers/api/mod.rs @@ -1 +1,2 @@ -pub mod verify_authorization; +pub mod oauth2; +pub mod read_user; diff --git a/src/controllers/api/oauth2/access_token.rs b/src/controllers/api/oauth2/access_token.rs new file mode 100644 index 0000000..ab239ba --- /dev/null +++ b/src/controllers/api/oauth2/access_token.rs @@ -0,0 +1,91 @@ +use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form, Json}; +use chrono::{Duration, Utc}; +use fully_pub::fully_pub; +use log::error; +use serde::{Deserialize, Serialize}; + +use crate::{ + models::{authorization::Authorization, token_claims::AppUserTokenClaims}, + server::AppState, + services::{app_session::AppClientSession, session::create_token} +}; + +const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120; + +#[derive(Serialize, Deserialize)] +#[fully_pub] +struct AccessTokenRequestParams { + grant_type: String, + code: String, + redirect_uri: String, +} + +#[derive(Serialize, Deserialize)] +#[fully_pub] +struct AccessTokenResponse { + access_token: String, + token_type: String, + expires_in: u64 +} + +// implement Client -> Auth Server request for RFC6749 Authorization Code Grant +pub async fn get_access_token( + State(app_state): State, + Extension(app_client_session): Extension, + Form(form): Form +) -> impl IntoResponse { + // 1. This handler require client authentification + // login the client with client_id and client_secret + + // 2. Get authorization from DB and validate code + let authorizations_res = sqlx::query_as::<_, Authorization>( + "SELECT * FROM authorizations WHERE code = $1 AND client_id = $2" + ) + .bind(&form.code) + .bind(&app_client_session.client_id) + .fetch_one(&app_state.db) + .await; + let authorization = match authorizations_res { + Ok(val) => val, + Err(sqlx::Error::RowNotFound) => { + return ( + StatusCode::BAD_REQUEST, + Json("Invalid authorization_code.") + ).into_response(); + }, + Err(err) => { + error!("Could not fetch authorization. {}", err); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Html("Internal server error") + ).into_response(); + } + }; + // 2.2. Validate that the authorization code is not expired + let is_code_valid = authorization.last_used_at + .map_or(false, |ts| { + Utc::now().signed_duration_since(ts) < Duration::seconds(AUTHORIZATION_CODE_TTL_SECONDS) + }); + if !is_code_valid { + return ( + StatusCode::BAD_REQUEST, + Json("Authorization code has expired.") + ).into_response(); + } + + // 3. Generate JWT for oauth2 client user session + let jwt = create_token( + &app_state.secrets, + AppUserTokenClaims::from_client_user_id( + &app_client_session.client_id, + &authorization.user_id + ) + ); + // 4. return JWT + let access_token_res = AccessTokenResponse { + access_token: jwt, + token_type: "jwt".to_string(), + expires_in: 3600 + }; + Json(access_token_res).into_response() +} diff --git a/src/controllers/api/oauth2/mod.rs b/src/controllers/api/oauth2/mod.rs new file mode 100644 index 0000000..0cb1f42 --- /dev/null +++ b/src/controllers/api/oauth2/mod.rs @@ -0,0 +1 @@ +pub mod access_token; diff --git a/src/controllers/api/read_user.rs b/src/controllers/api/read_user.rs new file mode 100644 index 0000000..a6b34dd --- /dev/null +++ b/src/controllers/api/read_user.rs @@ -0,0 +1,37 @@ +use axum::{extract::State, response::IntoResponse, Extension, Json}; +use fully_pub::fully_pub; +use serde::Serialize; + +use crate::{models::{token_claims::AppUserTokenClaims, user::User}, server::AppState}; + +#[derive(Serialize)] +#[fully_pub] +struct ReadUserBasicExtract { + id: String, + handle: String, + full_name: Option, + email: Option, + website: Option, + roles: Vec +} + +pub async fn read_user_basic( + State(app_state): State, + Extension(token_claims): Extension, +) -> impl IntoResponse { + // 1. This handler require client user authentification (JWT) + let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(&token_claims.user_id) + .fetch_one(&app_state.db) + .await + .expect("To get user from claim"); + let output = ReadUserBasicExtract { + id: user_res.id, + handle: user_res.handle, + full_name: user_res.full_name, + email: user_res.email, + website: user_res.website, + roles: user_res.roles.to_vec() + }; + Json(output).into_response() +} diff --git a/src/controllers/api/verify_authorization.rs b/src/controllers/api/verify_authorization.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/controllers/ui/authorize.rs b/src/controllers/ui/authorize.rs index 6d44528..92cb07e 100644 --- a/src/controllers/ui/authorize.rs +++ b/src/controllers/ui/authorize.rs @@ -7,11 +7,16 @@ use serde::{Deserialize, Serialize}; use url::Url; use uuid::Uuid; -use crate::{models::authorization::Authorization, renderer::TemplateRenderer, server::AppState, services::{oauth2::verify_redirect_uri, session::TokenClaims}, utils::get_random_alphanumerical}; +use crate::{ + models::{authorization::Authorization, token_claims::UserTokenClaims}, + renderer::TemplateRenderer, server::AppState, + services::oauth2::{parse_scope, verify_redirect_uri}, + utils::get_random_alphanumerical +}; #[derive(Serialize, Deserialize)] #[fully_pub] -/// query params described in [RFC6759 section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1) +/// query params described in [RFC6749 section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1) struct AuthorizationParams { response_type: String, client_id: String, @@ -45,7 +50,7 @@ fn redirect_to_client( /// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1 pub async fn authorize_form( State(app_state): State, - Extension(token_claims): Extension, + Extension(token_claims): Extension, Extension(renderer): Extension, query_params: Query ) -> impl IntoResponse { @@ -94,18 +99,21 @@ pub async fn authorize_form( match authorizations_res { Ok(existing_authorization) => { - info!("Reusing existing authorization {}", &existing_authorization.id); + info!("Reusing existing authorization: {}", &existing_authorization.id); + // Create new auth code + let authorization_code = get_random_alphanumerical(32); // Update last used timestamp for this authorization - let _result = sqlx::query("UPDATE authorizations SET last_used_at = $2 WHERE id = $1") + let _result = sqlx::query("UPDATE authorizations SET code = $2, last_used_at = $3 WHERE id = $1") .bind(existing_authorization.id) + .bind(authorization_code.clone()) .bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)) .execute(&app_state.db) .await.unwrap(); // Authorization already given, just redirect to the app return redirect_to_client( - &existing_authorization.code, + &authorization_code, &authorization_params ).into_response() }, @@ -137,7 +145,7 @@ pub async fn authorize_form( pub async fn perform_authorize( State(app_state): State, - Extension(token_claims): Extension, + Extension(token_claims): Extension, Form(authorize_form): Form ) -> impl IntoResponse { // 1. Get the app details @@ -152,7 +160,16 @@ pub async fn perform_authorize( ).into_response(); } }; - + // parse scope again + let scopes = match parse_scope(&authorize_form.scope) { + Ok(v) => v, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Html(format!("Invalid scope: {}", err)) + ).into_response(); + } + }; // 2. Create an authorizaton code let authorization_code = get_random_alphanumerical(32); @@ -160,12 +177,12 @@ pub async fn perform_authorize( id: Uuid::new_v4().to_string(), user_id: token_claims.sub, client_id: app.client_id.clone(), - scopes: sqlx::types::Json(Vec::new()), + scopes: sqlx::types::Json(scopes), code: authorization_code.clone(), last_used_at: Some(Utc::now()), created_at: Utc::now(), }; - + // 3. Save authorization in DB with state let res = sqlx::query(" INSERT INTO authorizations diff --git a/src/controllers/ui/login.rs b/src/controllers/ui/login.rs index 8d79a72..1178876 100644 --- a/src/controllers/ui/login.rs +++ b/src/controllers/ui/login.rs @@ -6,7 +6,7 @@ use fully_pub::fully_pub; use minijinja::context; use crate::{ - models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token} + models::{token_claims::UserTokenClaims, user::{User, UserStatus}}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token} }; pub async fn login_form( @@ -80,7 +80,7 @@ pub async fn perform_login( .execute(&app_state.db) .await.unwrap(); - let jwt = create_token(&app_state.secrets, &user); + let jwt = create_token(&app_state.secrets, UserTokenClaims::from_user_id(&user.id)); // TODO: handle keep_session boolean from form and specify cookie max age only if this setting // is true diff --git a/src/controllers/ui/me.rs b/src/controllers/ui/me.rs index d420ae4..3b8cb22 100644 --- a/src/controllers/ui/me.rs +++ b/src/controllers/ui/me.rs @@ -1,14 +1,18 @@ -use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension}; +use axum::{body::Bytes, extract::State, response::IntoResponse, Extension}; use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; use fully_pub::fully_pub; use minijinja::context; -use crate::{models::user::User, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims}; +use crate::{ + models::{token_claims::UserTokenClaims, user::User}, + renderer::TemplateRenderer, + server::AppState +}; pub async fn me_page( State(app_state): State, Extension(renderer): Extension, - Extension(token_claims): Extension + Extension(token_claims): Extension ) -> impl IntoResponse { let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") @@ -29,7 +33,7 @@ pub async fn me_page( pub async fn me_update_details_form( State(app_state): State, Extension(renderer): Extension, - Extension(token_claims): Extension + Extension(token_claims): Extension ) -> impl IntoResponse { let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") @@ -63,7 +67,7 @@ struct UserDetailsUpdateForm { pub async fn me_perform_update_details( State(app_state): State, Extension(renderer): Extension, - Extension(token_claims): Extension, + Extension(token_claims): Extension, TypedMultipart(details_update): TypedMultipart ) -> impl IntoResponse { let template_path = "pages/me/details-form"; diff --git a/src/middlewares/app_auth.rs b/src/middlewares/app_auth.rs new file mode 100644 index 0000000..4f50d31 --- /dev/null +++ b/src/middlewares/app_auth.rs @@ -0,0 +1,112 @@ +use axum::{ + extract::{Request, State}, + http::{HeaderMap, StatusCode}, + middleware::Next, + response::{Html, IntoResponse, Response}, + Extension +}; + +use crate::{ + models::token_claims::AppUserTokenClaims, server::AppState, services::{app_session::AppClientSession, session::verify_token}, utils::parse_basic_auth +}; + + +/// add optional auth to the extension data +pub async fn basic_auth_middleware( + State(app_state): State, + headers: HeaderMap, + mut req: Request, + next: Next, +) -> Result { + let authorization_val = match headers.get("Authorization") { + Some(header_val) => header_val.to_str().expect("Header val to be string"), + None => { + // no auth found, auth may be optional + return Ok(next.run(req).await) + } + }; + // check with config + let (login, password) = match parse_basic_auth(authorization_val) { + Ok(v) => v, + Err(_e) => { + return Err( + (StatusCode::UNAUTHORIZED, Html("Unauthorized: invalid http basic header.")) + ); + } + }; + let app = match app_state.config.applications + .iter() + .find(|a| a.client_id == login) + { + Some(app) => app, + None => { + return Err(( + StatusCode::UNAUTHORIZED, + Html("Unauthorized: Invalid username or password.") + )) + } + }; + if app.client_secret != password { + return Err(( + StatusCode::UNAUTHORIZED, + Html("Unauthorized: Invalid username or password.") + )) + } + req.extensions_mut().insert(AppClientSession { + client_id: login + }); + Ok(next.run(req).await) +} + +/// require auth +pub async fn enforce_basic_auth_middleware( + app_client_session_ext: Option>, + req: Request, + next: Next, +) -> Result { + match app_client_session_ext { + Some(_val) => (), + None => { + // auth is required + return Err( + (StatusCode::UNAUTHORIZED, Html("Unauthorized: application basic HTTP auth is required on this page.")) + ); + } + }; + Ok(next.run(req).await) +} + +/// require App-User auth +pub async fn enforce_jwt_auth_middleware( + State(app_state): State, + headers: HeaderMap, + mut req: Request, + next: Next, +) -> Result { + let authorization_val = match headers.get("Authorization") { + Some(header_val) => header_val.to_str().expect("Header val to be string"), + None => { + return Err( + (StatusCode::UNAUTHORIZED, Html("Unauthorized: JWT must be provided.")) + ); + } + }; + let jwt = match authorization_val.split(" ").nth(1) { + Some(val) => val, + None => { + return Err( + (StatusCode::UNAUTHORIZED, Html("Unauthorized: malformed Authorization header.")) + ); + } + }; + let token_claims: AppUserTokenClaims = match verify_token(&app_state.secrets, &jwt) { + Ok(val) => val, + Err(_e) => { + return Err( + (StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided JWT is invalid.")) + ); + } + }; + req.extensions_mut().insert(token_claims); + Ok(next.run(req).await) +} diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs index 25e5df7..ae8276f 100644 --- a/src/middlewares/mod.rs +++ b/src/middlewares/mod.rs @@ -1,2 +1,3 @@ -pub mod auth; +pub mod user_auth; +pub mod app_auth; pub mod renderer; diff --git a/src/middlewares/renderer.rs b/src/middlewares/renderer.rs index 68da808..0e01238 100644 --- a/src/middlewares/renderer.rs +++ b/src/middlewares/renderer.rs @@ -1,10 +1,10 @@ use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension}; -use crate::{renderer::TemplateRenderer, server::AppState, services::session::TokenClaims}; +use crate::{models::token_claims::UserTokenClaims, renderer::TemplateRenderer, server::AppState}; pub async fn renderer_middleware( State(app_state): State, - token_claims_ext: Option>, + token_claims_ext: Option>, mut req: Request, next: Next, ) -> Result { diff --git a/src/middlewares/auth.rs b/src/middlewares/user_auth.rs similarity index 81% rename from src/middlewares/auth.rs rename to src/middlewares/user_auth.rs index eabab9c..bc56894 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/user_auth.rs @@ -1,7 +1,11 @@ use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::{Html, IntoResponse, Response}, Extension}; use axum_extra::extract::CookieJar; -use crate::{server::AppState, services::session::{TokenClaims, verify_token}}; +use crate::{ + models::token_claims::UserTokenClaims, + server::AppState, + services::session::verify_token +}; /// add optional auth to the extension data @@ -18,11 +22,11 @@ pub async fn auth_middleware( return Ok(next.run(req).await) } }; - let token_claims = match verify_token(&app_state.secrets, &jwt) { + let token_claims: UserTokenClaims = match verify_token(&app_state.secrets, &jwt) { Ok(val) => val, Err(_e) => { return Err( - (StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided is invalid.")) + (StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided JWT is invalid.")) ); } }; @@ -32,7 +36,7 @@ pub async fn auth_middleware( /// require auth pub async fn enforce_auth_middleware( - token_claims_ext: Option>, + token_claims_ext: Option>, req: Request, next: Next, ) -> Result { diff --git a/src/models/authorization.rs b/src/models/authorization.rs index b4cd9cb..6762bc2 100644 --- a/src/models/authorization.rs +++ b/src/models/authorization.rs @@ -2,9 +2,14 @@ use fully_pub::fully_pub; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::types::Json; +use strum_macros::{Display, EnumString}; -#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)] -#[derive(strum_macros::Display)] +#[derive( + Clone, Debug, Serialize, Deserialize, PartialEq, + sqlx::Type, + Display, EnumString +)] +#[strum(serialize_all = "lowercase")] #[fully_pub] enum AuthorizationScope { ReadBasics diff --git a/src/models/mod.rs b/src/models/mod.rs index 37a7310..da715f7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod config; pub mod user; pub mod authorization; +pub mod token_claims; diff --git a/src/models/token_claims.rs b/src/models/token_claims.rs new file mode 100644 index 0000000..1ed624c --- /dev/null +++ b/src/models/token_claims.rs @@ -0,0 +1,49 @@ +use fully_pub::fully_pub; +use jsonwebtoken::get_current_timestamp; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[fully_pub] +struct UserTokenClaims { + /// subject: user id + sub: String, + /// token expiration + exp: u64, + /// token issuer + iss: String + // TODO: add roles +} + +impl UserTokenClaims { + pub fn from_user_id(user_id: &str) -> Self { + UserTokenClaims { + sub: user_id.into(), + exp: get_current_timestamp() + 86_000, + iss: "Minauth".into() + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[fully_pub] +struct AppUserTokenClaims { + /// combined subject + client_id: String, + user_id: String, + /// token expiration + exp: u64, + /// token issuer + iss: String +} + +impl AppUserTokenClaims { + pub fn from_client_user_id(client_id: &str, user_id: &str) -> Self { + AppUserTokenClaims { + client_id: client_id.into(), + user_id: user_id.into(), + exp: get_current_timestamp() + 86_000, + iss: "Minauth".into() + } + } +} + diff --git a/src/renderer.rs b/src/renderer.rs index 7cbf412..7d35398 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -3,14 +3,14 @@ use fully_pub::fully_pub; use log::error; use minijinja::{context, Environment, Value}; -use crate::services::session::TokenClaims; +use crate::models::token_claims::UserTokenClaims; #[derive(Clone)] #[fully_pub] struct TemplateRenderer { env: Environment<'static>, - token_claims: Option + token_claims: Option } impl TemplateRenderer { diff --git a/src/router.rs b/src/router.rs index 167cb58..8487bf3 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,7 +1,16 @@ use axum::{middleware, routing::{get, post}, Router}; use tower_http::services::ServeDir; -use crate::{controllers::ui, middlewares::{auth::{auth_middleware, enforce_auth_middleware}, renderer::renderer_middleware}, server::{AppState, ServerConfig}}; +use crate::{ + controllers::ui, + controllers::api, + middlewares::{ + user_auth, + app_auth, + renderer::renderer_middleware + }, + server::{AppState, ServerConfig} +}; pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router { let public_routes = Router::new() @@ -9,7 +18,8 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router .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)) + .layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware)); let user_routes = Router::new() .route("/me", get(ui::me::me_page)) @@ -18,13 +28,24 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router .route("/logout", get(ui::logout::perform_logout)) .route("/authorize", get(ui::authorize::authorize_form)) .route("/authorize", post(ui::authorize::perform_authorize)) - .layer(middleware::from_fn_with_state(app_state.clone(), enforce_auth_middleware)); + .layer(middleware::from_fn_with_state(app_state.clone(), user_auth::enforce_auth_middleware)) + .layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware)); + + let app_routes = Router::new() + .route("/api/token", post(api::oauth2::access_token::get_access_token)) + .layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_basic_auth_middleware)) + .layer(middleware::from_fn_with_state(app_state.clone(), app_auth::basic_auth_middleware)); + + let app_user_routes = Router::new() + .route("/api/user", get(api::read_user::read_user_basic)) + .layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware)); Router::new() .merge(public_routes) .merge(user_routes) + .merge(app_routes) + .merge(app_user_routes) .layer(middleware::from_fn_with_state(app_state.clone(), renderer_middleware)) - .layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware)) .nest_service( "/assets", ServeDir::new(server_config.assets_path.clone()) diff --git a/src/services/app_session.rs b/src/services/app_session.rs new file mode 100644 index 0000000..ecf6907 --- /dev/null +++ b/src/services/app_session.rs @@ -0,0 +1,12 @@ + +use fully_pub::fully_pub; +use serde::{Deserialize, Serialize}; + + +/// represent a general app session (from http basic auth) +#[derive(Debug, Serialize, Deserialize, Clone)] +#[fully_pub] +struct AppClientSession { + client_id: String +} + diff --git a/src/services/mod.rs b/src/services/mod.rs index 569b260..7193e1b 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,4 @@ pub mod password; pub mod session; pub mod oauth2; +pub mod app_session; diff --git a/src/services/oauth2.rs b/src/services/oauth2.rs index a041e8d..929938c 100644 --- a/src/services/oauth2.rs +++ b/src/services/oauth2.rs @@ -1,7 +1,20 @@ -use crate::models::config::Application; +use std::str::FromStr; +use anyhow::{Result, Context}; + +use crate::models::{authorization::AuthorizationScope, config::Application}; pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool { app.allowed_redirect_uris .iter() .find(|uri| **uri == input_redirect_uri).is_some() } + +pub fn parse_scope(scope_str: &str) -> Result> { + let mut scopes: Vec = vec![]; + for part in scope_str.split(' ') { + scopes.push( + AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")? + ) + } + Ok(scopes) +} diff --git a/src/services/session.rs b/src/services/session.rs index 3941385..f53fd51 100644 --- a/src/services/session.rs +++ b/src/services/session.rs @@ -1,24 +1,12 @@ 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 serde::{de::DeserializeOwned, Deserialize, Serialize}; +use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey}; -use crate::models::{config::AppSecrets, user::User}; +use crate::models::config::AppSecrets; -#[derive(Debug, Serialize, Deserialize, Clone)] -#[fully_pub] -struct TokenClaims { - /// user id - sub: String, - exp: u64 -} - -pub fn create_token(secrets: &AppSecrets, user: &User) -> String { - let claims = TokenClaims { - sub: user.id.clone(), - exp: get_current_timestamp() + 86_400 - }; +pub fn create_token(secrets: &AppSecrets, claims: T) -> String { let token = encode( &Header::default(), &claims, @@ -28,8 +16,8 @@ pub fn create_token(secrets: &AppSecrets, user: &User) -> String { return token; } -pub fn verify_token(secrets: &AppSecrets, jwt: &str) -> Result { - let token_data = decode::( +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) diff --git a/src/utils.rs b/src/utils.rs index b654302..f28b48b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Result, Context}; use argon2::{ password_hash::{ rand_core::OsRng, @@ -6,6 +6,7 @@ use argon2::{ }, Argon2 }; +use base64::{prelude::BASE64_STANDARD, Engine}; use rand::{distributions::Alphanumeric, thread_rng, Rng}; pub fn get_password_hash(password: String) -> Result<(String, String)> { @@ -41,3 +42,22 @@ pub fn get_random_alphanumerical(length: usize) -> String { .map(char::from) .collect() } + +pub fn parse_basic_auth(header_value: &str) -> Result<(String, String)> { + let header_val_components: Vec<&str> = header_value.split(" ").collect(); + let encoded_header_value: &str = header_val_components + .get(1) + .ok_or(anyhow!("Could not find encoded part of Authorization header value"))?; + let basic_auth_str: String = String::from_utf8( + BASE64_STANDARD + .decode(encoded_header_value) + .context("Could not decode base64 in Authorization header value.")? + )?; + let components: Vec<&str> = basic_auth_str.split(':').collect(); + + Ok(( + components.get(0).ok_or(anyhow!("Expected username in encoded Authorization header value."))?.to_string(), + components.get(1).ok_or(anyhow!("Expected password in encoded Authorization header value."))?.to_string() + )) + +}