feat(oauth2): authorize form and redirection
This commit is contained in:
parent
c277ab3bd9
commit
ecf1da2978
15 changed files with 480 additions and 62 deletions
|
|
@ -1,14 +1,18 @@
|
|||
use axum::{extract::{Query, State}, http::StatusCode, response::{Html, IntoResponse}, Extension, Form};
|
||||
use axum::{extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Extension, Form};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use fully_pub::fully_pub;
|
||||
use log::{debug, error, info};
|
||||
use minijinja::context;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{models::authorization::Authorization, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
|
||||
use crate::{models::authorization::Authorization, renderer::TemplateRenderer, server::AppState, services::{oauth2::verify_redirect_uri, session::TokenClaims}, utils::get_random_alphanumerical};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[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)
|
||||
struct AuthorizeQueryParams {
|
||||
struct AuthorizationParams {
|
||||
response_type: String,
|
||||
client_id: String,
|
||||
scope: String,
|
||||
|
|
@ -17,65 +21,175 @@ struct AuthorizeQueryParams {
|
|||
state: String,
|
||||
}
|
||||
|
||||
fn redirect_to_client(
|
||||
authorization_code: &str,
|
||||
authorization_params: &AuthorizationParams
|
||||
) -> impl IntoResponse {
|
||||
let target_url = format!("{}?code={}&state={}",
|
||||
authorization_params.redirect_uri,
|
||||
authorization_code,
|
||||
authorization_params.state,
|
||||
);
|
||||
debug!("Redirecting to {}", target_url);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Location", HeaderValue::from_str(&target_url).unwrap());
|
||||
(
|
||||
StatusCode::FOUND,
|
||||
headers,
|
||||
Html("Redirecting to client…")
|
||||
).into_response()
|
||||
}
|
||||
|
||||
/// The authorization endpoint is used to interact with the resource owner and obtain an authorization grant.
|
||||
/// 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(renderer): Extension<TemplateRenderer>,
|
||||
query_params: Query<AuthorizeQueryParams>
|
||||
query_params: Query<AuthorizationParams>
|
||||
) -> impl IntoResponse {
|
||||
// 1. Verify the app details
|
||||
let app_res = app_state.config.applications
|
||||
.iter()
|
||||
.find(|a| a.client_id == query_params.client_id);
|
||||
let Query(authorization_params) = query_params;
|
||||
|
||||
if app_res.is_none() {
|
||||
// 1. Verify the app details
|
||||
let app = match app_state.config.applications
|
||||
.iter()
|
||||
.find(|a| a.client_id == authorization_params.client_id) {
|
||||
Some(app) => app,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("Invalid client_id query params, app not found.")
|
||||
).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 1.1. Verify that the app redirect_uri is authorized
|
||||
if !verify_redirect_uri(app, &authorization_params.redirect_uri) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("Invalid client_id query params, app not found.")
|
||||
Html("Unauthorized redirect_uri.")
|
||||
).into_response();
|
||||
}
|
||||
// 1.3. Parse and validate redirect_uri.
|
||||
// Note: for now, we only support HTTP(s) redirect URLs.
|
||||
let parsed_redirect_uri: Url = match Url::parse(&authorization_params.redirect_uri) {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("Invalid redirect URL. Could not parse as HTTP(S) URL.")
|
||||
).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Check if the app is already authorized
|
||||
let authorizations_res = sqlx::query_as::<_, Authorization>("SELECT * FROM authorizations WHERE user_id = $1 AND
|
||||
app_id = $2")
|
||||
let authorizations_res = sqlx::query_as::<_, Authorization>(
|
||||
"SELECT * FROM authorizations WHERE user_id = $1 AND client_id = $2"
|
||||
)
|
||||
.bind(&token_claims.sub)
|
||||
.bind(&query_params.client_id)
|
||||
.bind(&authorization_params.client_id)
|
||||
.fetch_one(&app_state.db)
|
||||
.await
|
||||
.expect("To get authorizations");
|
||||
.await;
|
||||
|
||||
match authorizations_res {
|
||||
Ok(existing_authorization) => {
|
||||
info!("Reusing existing authorization {}", &existing_authorization.id);
|
||||
|
||||
// Update last used timestamp for this authorization
|
||||
let _result = sqlx::query("UPDATE authorizations SET last_used_at = $2 WHERE id = $1")
|
||||
.bind(existing_authorization.id)
|
||||
.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_params
|
||||
).into_response()
|
||||
},
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
debug!("Authorization not found.");
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Cannot get existing authorization. {}", err);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("Internal server error: Failed to verify conditions.")
|
||||
).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
dbg!(authorizations_res);
|
||||
|
||||
// 3. Verify scopes
|
||||
|
||||
// 4. Show form that POST to authorize
|
||||
|
||||
|
||||
renderer
|
||||
.render(
|
||||
"pages/authorize",
|
||||
context!()
|
||||
context!(
|
||||
app => app,
|
||||
authorization_params => authorization_params,
|
||||
redirect_uri_host => parsed_redirect_uri.host_str()
|
||||
)
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[fully_pub]
|
||||
struct AuthorizeForm {
|
||||
/// client_id
|
||||
client_id: String,
|
||||
scopes: Vec<String>
|
||||
}
|
||||
|
||||
|
||||
pub async fn perform_authorize(
|
||||
State(app_state): State<AppState>,
|
||||
Extension(renderer): Extension<TemplateRenderer>,
|
||||
Form(authorize_form): Form<AuthorizeForm>
|
||||
Extension(token_claims): Extension<TokenClaims>,
|
||||
Form(authorize_form): Form<AuthorizationParams>
|
||||
) -> impl IntoResponse {
|
||||
// Save authorization in DB
|
||||
// 4.1. Create an authorization code
|
||||
// 4.2. Redirect to the app with a token
|
||||
(StatusCode::FOUND, Html("Redirecting…"))
|
||||
// 1. Get the app details
|
||||
let app = match app_state.config.applications
|
||||
.iter()
|
||||
.find(|a| a.client_id == authorize_form.client_id) {
|
||||
Some(app) => app,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("Invalid client_id, app not found.")
|
||||
).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Create an authorizaton code
|
||||
let authorization_code = get_random_alphanumerical(32);
|
||||
|
||||
let authorization = Authorization {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id: token_claims.sub,
|
||||
client_id: app.client_id.clone(),
|
||||
scopes: sqlx::types::Json(Vec::new()),
|
||||
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
|
||||
(id, user_id, client_id, scopes, code, last_used_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
")
|
||||
.bind(authorization.id.clone())
|
||||
.bind(authorization.user_id)
|
||||
.bind(authorization.client_id)
|
||||
.bind(authorization.scopes)
|
||||
.bind(authorization.code)
|
||||
.bind(authorization.last_used_at.map(|x| x.to_rfc3339_opts(SecondsFormat::Millis, true)))
|
||||
.bind(authorization.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
.execute(&app_state.db)
|
||||
.await;
|
||||
match res {
|
||||
Err(err) => {
|
||||
error!("Failed to save authorization in DB. {}", err);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Html("Internal server error: Failed to process authorization form.")).into_response();
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
info!("Created authorization {}", &authorization.id);
|
||||
|
||||
// 4. Redirect to the app with the authorization code and state
|
||||
redirect_to_client(&authorization_code, &authorize_form).into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use sqlx::types::Json;
|
|||
|
||||
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(strum_macros::Display)]
|
||||
#[fully_pub]
|
||||
enum AuthorizationScope {
|
||||
ReadBasics
|
||||
}
|
||||
|
|
@ -19,6 +20,8 @@ struct Authorization {
|
|||
client_id: String,
|
||||
scopes: Json<Vec<AuthorizationScope>>,
|
||||
|
||||
/// defined in https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2
|
||||
code: String,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fully_pub::fully_pub;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
const fn _default_true() -> bool { true }
|
||||
|
||||
|
|
@ -20,8 +16,10 @@ struct InstanceConfig {
|
|||
struct Application {
|
||||
slug: String,
|
||||
name: String,
|
||||
description: String,
|
||||
client_id: String,
|
||||
client_secret: String
|
||||
client_secret: String,
|
||||
allowed_redirect_uris: Vec<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
|
|||
.route("/me/details-form", post(ui::me::me_perform_update_details))
|
||||
.route("/logout", get(ui::logout::perform_logout))
|
||||
.route("/authorize", get(ui::authorize::authorize_form))
|
||||
.route("/authorize", post(ui::authorize::perform_authorize))
|
||||
.layer(middleware::from_fn_with_state(app_state.clone(), enforce_auth_middleware));
|
||||
|
||||
Router::new()
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod password;
|
||||
pub mod session;
|
||||
pub mod oauth2;
|
||||
|
|
|
|||
7
src/services/oauth2.rs
Normal file
7
src/services/oauth2.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
use crate::models::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()
|
||||
}
|
||||
|
|
@ -1,21 +1,32 @@
|
|||
{% extends "layouts/base.html" %}
|
||||
{% block body %}
|
||||
<!-- Login form -->
|
||||
<!-- Authorize form -->
|
||||
{% if error %}
|
||||
<div>
|
||||
Error: {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form id="authorize-form" method="post">
|
||||
<form id="authorize-form" method="post" action="/authorize">
|
||||
<h1>Do you authorize this app?</h1>
|
||||
<p>
|
||||
You're about to log-in and give some of your personal data to an application.
|
||||
If you accept, you will be redirected to "{{ redirect_uri_host }}".
|
||||
</p>
|
||||
<ul>
|
||||
<li>App name: </li>
|
||||
<li>Permisions: read basics</li>
|
||||
<li>App name: {{ app.name }}</li>
|
||||
<li>App description: <i>{{ app.description }}</i></li>
|
||||
<li>Permisions: {{ authorization_params.scope }}</li>
|
||||
</ul>
|
||||
<input type="hidden" name="client_id" value="" />
|
||||
<input type="hidden" name="scope" value="" />
|
||||
<input type="hidden" name="state" value="" />
|
||||
<input type="hidden" name="client_id" value="{{ authorization_params.client_id }}" />
|
||||
<input type="hidden" name="scope" value="{{ authorization_params.scope }}" />
|
||||
<input type="hidden" name="state" value="{{ authorization_params.state }}" />
|
||||
<input type="hidden" name="response_type" value="{{ authorization_params.response_type }}" />
|
||||
<input type="hidden" name="redirect_uri" value="{{ authorization_params.redirect_uri }}" />
|
||||
|
||||
<button type="submit" class="btn btn-primary">Authorize</button>
|
||||
<div class="d-flex justify-content-end">
|
||||
<!-- TODO: implements authorization rejection -->
|
||||
<a href="/me" class="btn btn-outlined">Don't authorize</a>
|
||||
<button type="submit" class="btn btn-primary">Authorize</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use argon2::{
|
|||
},
|
||||
Argon2
|
||||
};
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
|
||||
pub fn get_password_hash(password: String) -> Result<(String, String)> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
|
@ -33,3 +34,10 @@ pub fn verify_password_hash(password_hash: String, password: String) -> Result<(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_random_alphanumerical(length: usize) -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(length)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue