feat(oauth2): get access token route and read basic user info

This commit is contained in:
Matthieu Bessat 2024-11-11 14:49:17 +01:00
parent ecf1da2978
commit f990708052
32 changed files with 465 additions and 67 deletions

7
Cargo.lock generated
View file

@ -1407,6 +1407,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"strum",
"strum_macros", "strum_macros",
"tokio", "tokio",
"toml", "toml",
@ -2307,6 +2308,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
[[package]] [[package]]
name = "strum_macros" name = "strum_macros"
version = "0.26.4" version = "0.26.4"

View file

@ -57,6 +57,7 @@ base64 = "0.22.1"
rand = "0.8.5" rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["std"] } rand_core = { version = "0.6.4", features = ["std"] }
url = "2.5.3" url = "2.5.3"
strum = "0.26.3"
[build-dependencies] [build-dependencies]
minijinja-embed = "2.3.1" minijinja-embed = "2.3.1"

View file

@ -1,6 +1,6 @@
# Minauthator # Minauthator
Auth provider supporting [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749) Auth provider supporting [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749).
## Features ## Features

11
TODO.md
View file

@ -3,11 +3,10 @@
- [x] Login form - [x] Login form
- [x] Register form - [x] Register form
- [x] Generate JWT - [x] Generate JWT
- Redirect to login form if unauthenticated - [ ] Redirect to login form if unauthenticated
- Authorize form - [x] Authorize form
- Select by app client id - [x] Verify authorize
- Verify authorize - [x] Upload picture
- Select by app client secret - [x] Get access token
- Upload picture
- [ ] Support error responses by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 - [ ] Support error responses by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1

View file

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

1
http_integration_tests/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
tmp

View file

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

View file

@ -2,9 +2,14 @@
curl -v http://localhost:8085/authorize \ curl -v http://localhost:8085/authorize \
-G \ -G \
--cookie ".curl-cookies" \ -D "tmp/headers.txt" \
--cookie "tmp/.curl-cookies" \
-d client_id="a1785786-8be1-443c-9a6f-35feed703609" \ -d client_id="a1785786-8be1-443c-9a6f-35feed703609" \
-d response_type="code" \ -d response_type="code" \
-d redirect_uri="http://localhost:9090/authorize" \ -d redirect_uri="http://localhost:9090/authorize" \
-d scope="read_basics" \ -d scope="read_basics" \
-d state="qxYAfk4kf6pbZkms78jM" -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

View file

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

View file

@ -1,6 +1,6 @@
#!/usr/bin/sh #!/usr/bin/sh
curl -v http://localhost:8085/login \ curl -v http://localhost:8085/login \
--cookie-jar ".curl-cookies" \ --cookie-jar "tmp/.curl-cookies" \
-d login="test" \ -d login="test" \
-d password="test" -d password="test"

View file

@ -1 +1,2 @@
pub mod verify_authorization; pub mod oauth2;
pub mod read_user;

View file

@ -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<AppState>,
Extension(app_client_session): Extension<AppClientSession>,
Form(form): Form<AccessTokenRequestParams>
) -> 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()
}

View file

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

View file

@ -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<String>,
email: Option<String>,
website: Option<String>,
roles: Vec<String>
}
pub async fn read_user_basic(
State(app_state): State<AppState>,
Extension(token_claims): Extension<AppUserTokenClaims>,
) -> 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()
}

View file

@ -7,11 +7,16 @@ use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use uuid::Uuid; 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)] #[derive(Serialize, Deserialize)]
#[fully_pub] #[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 { struct AuthorizationParams {
response_type: String, response_type: String,
client_id: String, client_id: String,
@ -45,7 +50,7 @@ fn redirect_to_client(
/// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1 /// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
pub async fn authorize_form( pub async fn authorize_form(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims>, Extension(token_claims): Extension<UserTokenClaims>,
Extension(renderer): Extension<TemplateRenderer>, Extension(renderer): Extension<TemplateRenderer>,
query_params: Query<AuthorizationParams> query_params: Query<AuthorizationParams>
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -94,18 +99,21 @@ pub async fn authorize_form(
match authorizations_res { match authorizations_res {
Ok(existing_authorization) => { 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 // 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(existing_authorization.id)
.bind(authorization_code.clone())
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)) .bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db) .execute(&app_state.db)
.await.unwrap(); .await.unwrap();
// Authorization already given, just redirect to the app // Authorization already given, just redirect to the app
return redirect_to_client( return redirect_to_client(
&existing_authorization.code, &authorization_code,
&authorization_params &authorization_params
).into_response() ).into_response()
}, },
@ -137,7 +145,7 @@ pub async fn authorize_form(
pub async fn perform_authorize( pub async fn perform_authorize(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims>, Extension(token_claims): Extension<UserTokenClaims>,
Form(authorize_form): Form<AuthorizationParams> Form(authorize_form): Form<AuthorizationParams>
) -> impl IntoResponse { ) -> impl IntoResponse {
// 1. Get the app details // 1. Get the app details
@ -152,7 +160,16 @@ pub async fn perform_authorize(
).into_response(); ).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 // 2. Create an authorizaton code
let authorization_code = get_random_alphanumerical(32); let authorization_code = get_random_alphanumerical(32);
@ -160,7 +177,7 @@ pub async fn perform_authorize(
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
user_id: token_claims.sub, user_id: token_claims.sub,
client_id: app.client_id.clone(), client_id: app.client_id.clone(),
scopes: sqlx::types::Json(Vec::new()), scopes: sqlx::types::Json(scopes),
code: authorization_code.clone(), code: authorization_code.clone(),
last_used_at: Some(Utc::now()), last_used_at: Some(Utc::now()),
created_at: Utc::now(), created_at: Utc::now(),

View file

@ -6,7 +6,7 @@ use fully_pub::fully_pub;
use minijinja::context; use minijinja::context;
use crate::{ 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( pub async fn login_form(
@ -80,7 +80,7 @@ pub async fn perform_login(
.execute(&app_state.db) .execute(&app_state.db)
.await.unwrap(); .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 // TODO: handle keep_session boolean from form and specify cookie max age only if this setting
// is true // is true

View file

@ -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 axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use minijinja::context; 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( pub async fn me_page(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>, Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<UserTokenClaims>
) -> impl IntoResponse { ) -> impl IntoResponse {
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") 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( pub async fn me_update_details_form(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>, Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<UserTokenClaims>
) -> impl IntoResponse { ) -> impl IntoResponse {
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") 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( pub async fn me_perform_update_details(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>, Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims>, Extension(token_claims): Extension<UserTokenClaims>,
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm> TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
) -> impl IntoResponse { ) -> impl IntoResponse {
let template_path = "pages/me/details-form"; let template_path = "pages/me/details-form";

112
src/middlewares/app_auth.rs Normal file
View file

@ -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<AppState>,
headers: HeaderMap,
mut req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
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<Extension<AppClientSession>>,
req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
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<AppState>,
headers: HeaderMap,
mut req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
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)
}

View file

@ -1,2 +1,3 @@
pub mod auth; pub mod user_auth;
pub mod app_auth;
pub mod renderer; pub mod renderer;

View file

@ -1,10 +1,10 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension}; 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( pub async fn renderer_middleware(
State(app_state): State<AppState>, State(app_state): State<AppState>,
token_claims_ext: Option<Extension<TokenClaims>>, token_claims_ext: Option<Extension<UserTokenClaims>>,
mut req: Request, mut req: Request,
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {

View file

@ -1,7 +1,11 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::{Html, IntoResponse, Response}, Extension}; use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::{Html, IntoResponse, Response}, Extension};
use axum_extra::extract::CookieJar; 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 /// add optional auth to the extension data
@ -18,11 +22,11 @@ pub async fn auth_middleware(
return Ok(next.run(req).await) 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, Ok(val) => val,
Err(_e) => { Err(_e) => {
return Err( 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 /// require auth
pub async fn enforce_auth_middleware( pub async fn enforce_auth_middleware(
token_claims_ext: Option<Extension<TokenClaims>>, token_claims_ext: Option<Extension<UserTokenClaims>>,
req: Request, req: Request,
next: Next, next: Next,
) -> Result<Response, impl IntoResponse> { ) -> Result<Response, impl IntoResponse> {

View file

@ -2,9 +2,14 @@ use fully_pub::fully_pub;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::Json; use sqlx::types::Json;
use strum_macros::{Display, EnumString};
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(
#[derive(strum_macros::Display)] Clone, Debug, Serialize, Deserialize, PartialEq,
sqlx::Type,
Display, EnumString
)]
#[strum(serialize_all = "lowercase")]
#[fully_pub] #[fully_pub]
enum AuthorizationScope { enum AuthorizationScope {
ReadBasics ReadBasics

View file

@ -1,3 +1,4 @@
pub mod config; pub mod config;
pub mod user; pub mod user;
pub mod authorization; pub mod authorization;
pub mod token_claims;

View file

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

View file

@ -3,14 +3,14 @@ use fully_pub::fully_pub;
use log::error; use log::error;
use minijinja::{context, Environment, Value}; use minijinja::{context, Environment, Value};
use crate::services::session::TokenClaims; use crate::models::token_claims::UserTokenClaims;
#[derive(Clone)] #[derive(Clone)]
#[fully_pub] #[fully_pub]
struct TemplateRenderer { struct TemplateRenderer {
env: Environment<'static>, env: Environment<'static>,
token_claims: Option<TokenClaims> token_claims: Option<UserTokenClaims>
} }
impl TemplateRenderer { impl TemplateRenderer {

View file

@ -1,7 +1,16 @@
use axum::{middleware, routing::{get, post}, Router}; use axum::{middleware, routing::{get, post}, Router};
use tower_http::services::ServeDir; 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<AppState> { pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
let public_routes = Router::new() 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", get(ui::register::register_form))
.route("/register", post(ui::register::perform_register)) .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))
.layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware));
let user_routes = Router::new() let user_routes = Router::new()
.route("/me", get(ui::me::me_page)) .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("/logout", get(ui::logout::perform_logout))
.route("/authorize", get(ui::authorize::authorize_form)) .route("/authorize", get(ui::authorize::authorize_form))
.route("/authorize", post(ui::authorize::perform_authorize)) .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() Router::new()
.merge(public_routes) .merge(public_routes)
.merge(user_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(), renderer_middleware))
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
.nest_service( .nest_service(
"/assets", "/assets",
ServeDir::new(server_config.assets_path.clone()) ServeDir::new(server_config.assets_path.clone())

View file

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

View file

@ -1,3 +1,4 @@
pub mod password; pub mod password;
pub mod session; pub mod session;
pub mod oauth2; pub mod oauth2;
pub mod app_session;

View file

@ -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 { pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool {
app.allowed_redirect_uris app.allowed_redirect_uris
.iter() .iter()
.find(|uri| **uri == input_redirect_uri).is_some() .find(|uri| **uri == input_redirect_uri).is_some()
} }
pub fn parse_scope(scope_str: &str) -> Result<Vec<AuthorizationScope>> {
let mut scopes: Vec<AuthorizationScope> = vec![];
for part in scope_str.split(' ') {
scopes.push(
AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")?
)
}
Ok(scopes)
}

View file

@ -1,24 +1,12 @@
use fully_pub::fully_pub; use fully_pub::fully_pub;
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use jsonwebtoken::{encode, decode, get_current_timestamp, Header, Algorithm, Validation, EncodingKey, DecodingKey}; 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)] pub fn create_token<T: Serialize>(secrets: &AppSecrets, claims: T) -> String {
#[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
};
let token = encode( let token = encode(
&Header::default(), &Header::default(),
&claims, &claims,
@ -28,8 +16,8 @@ pub fn create_token(secrets: &AppSecrets, user: &User) -> String {
return token; return token;
} }
pub fn verify_token(secrets: &AppSecrets, jwt: &str) -> Result<TokenClaims> { pub fn verify_token<T: DeserializeOwned>(secrets: &AppSecrets, jwt: &str) -> Result<T> {
let token_data = decode::<TokenClaims>( let token_data = decode::<T>(
&jwt, &jwt,
&DecodingKey::from_secret(&secrets.jwt_secret.as_bytes()), &DecodingKey::from_secret(&secrets.jwt_secret.as_bytes()),
&Validation::new(Algorithm::HS256) &Validation::new(Algorithm::HS256)

View file

@ -1,4 +1,4 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result, Context};
use argon2::{ use argon2::{
password_hash::{ password_hash::{
rand_core::OsRng, rand_core::OsRng,
@ -6,6 +6,7 @@ use argon2::{
}, },
Argon2 Argon2
}; };
use base64::{prelude::BASE64_STANDARD, Engine};
use rand::{distributions::Alphanumeric, thread_rng, Rng}; use rand::{distributions::Alphanumeric, thread_rng, Rng};
pub fn get_password_hash(password: String) -> Result<(String, String)> { 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) .map(char::from)
.collect() .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()
))
}