feat(oauth2): get access token route and read basic user info
This commit is contained in:
parent
ecf1da2978
commit
f990708052
32 changed files with 465 additions and 67 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
11
TODO.md
11
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
|
||||
|
|
|
@ -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
1
http_integration_tests/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
tmp
|
7
http_integration_tests/access_token_request.sh
Executable file
7
http_integration_tests/access_token_request.sh
Executable 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
|
|
@ -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
|
||||
|
|
5
http_integration_tests/get_user_info.sh
Executable file
5
http_integration_tests/get_user_info.sh
Executable 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)"
|
|
@ -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"
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
pub mod verify_authorization;
|
||||
pub mod oauth2;
|
||||
pub mod read_user;
|
||||
|
|
91
src/controllers/api/oauth2/access_token.rs
Normal file
91
src/controllers/api/oauth2/access_token.rs
Normal 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()
|
||||
}
|
1
src/controllers/api/oauth2/mod.rs
Normal file
1
src/controllers/api/oauth2/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod access_token;
|
37
src/controllers/api/read_user.rs
Normal file
37
src/controllers/api/read_user.rs
Normal 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()
|
||||
}
|
|
@ -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<AppState>,
|
||||
Extension(token_claims): Extension<TokenClaims>,
|
||||
Extension(token_claims): Extension<UserTokenClaims>,
|
||||
Extension(renderer): Extension<TemplateRenderer>,
|
||||
query_params: Query<AuthorizationParams>
|
||||
) -> 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<AppState>,
|
||||
Extension(token_claims): Extension<TokenClaims>,
|
||||
Extension(token_claims): Extension<UserTokenClaims>,
|
||||
Form(authorize_form): Form<AuthorizationParams>
|
||||
) -> 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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<AppState>,
|
||||
Extension(renderer): Extension<TemplateRenderer>,
|
||||
Extension(token_claims): Extension<TokenClaims>
|
||||
Extension(token_claims): Extension<UserTokenClaims>
|
||||
) -> 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<AppState>,
|
||||
Extension(renderer): Extension<TemplateRenderer>,
|
||||
Extension(token_claims): Extension<TokenClaims>
|
||||
Extension(token_claims): Extension<UserTokenClaims>
|
||||
) -> 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<AppState>,
|
||||
Extension(renderer): Extension<TemplateRenderer>,
|
||||
Extension(token_claims): Extension<TokenClaims>,
|
||||
Extension(token_claims): Extension<UserTokenClaims>,
|
||||
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
|
||||
) -> impl IntoResponse {
|
||||
let template_path = "pages/me/details-form";
|
||||
|
|
112
src/middlewares/app_auth.rs
Normal file
112
src/middlewares/app_auth.rs
Normal 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)
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
pub mod auth;
|
||||
pub mod user_auth;
|
||||
pub mod app_auth;
|
||||
pub mod renderer;
|
||||
|
|
|
@ -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<AppState>,
|
||||
token_claims_ext: Option<Extension<TokenClaims>>,
|
||||
token_claims_ext: Option<Extension<UserTokenClaims>>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
|
|
|
@ -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<Extension<TokenClaims>>,
|
||||
token_claims_ext: Option<Extension<UserTokenClaims>>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, impl IntoResponse> {
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
pub mod config;
|
||||
pub mod user;
|
||||
pub mod authorization;
|
||||
pub mod token_claims;
|
||||
|
|
49
src/models/token_claims.rs
Normal file
49
src/models/token_claims.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TokenClaims>
|
||||
token_claims: Option<UserTokenClaims>
|
||||
}
|
||||
|
||||
impl TemplateRenderer {
|
||||
|
|
|
@ -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<AppState> {
|
||||
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())
|
||||
|
|
12
src/services/app_session.rs
Normal file
12
src/services/app_session.rs
Normal 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
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod password;
|
||||
pub mod session;
|
||||
pub mod oauth2;
|
||||
pub mod app_session;
|
||||
|
|
|
@ -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<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)
|
||||
}
|
||||
|
|
|
@ -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<T: Serialize>(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<TokenClaims> {
|
||||
let token_data = decode::<TokenClaims>(
|
||||
pub fn verify_token<T: DeserializeOwned>(secrets: &AppSecrets, jwt: &str) -> Result<T> {
|
||||
let token_data = decode::<T>(
|
||||
&jwt,
|
||||
&DecodingKey::from_secret(&secrets.jwt_secret.as_bytes()),
|
||||
&Validation::new(Algorithm::HS256)
|
||||
|
|
22
src/utils.rs
22
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()
|
||||
))
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue