mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-11-13 12:16:54 +00:00
feat: Initial implementation of OIDC
This commit is contained in:
32 files changed
+1300
-63
No files matched your search
@@ -49,6 +49,7 @@ url.workspace = true
|
||||
recaptcha-verify = { version = "0.2.0", default-features = false }
|
||||
reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots-no-provider"] } # As long as recaptcha-verify's reqwest is outdated
|
||||
form_urlencoded = "1.2.2"
|
||||
openidconnect.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
memory-serve = "2.1.0"
|
||||
|
||||
@@ -133,6 +133,7 @@ pub fn build(services: &Services) -> Router<state::State> {
|
||||
.nest("/account/", account::build())
|
||||
.merge(debug::build())
|
||||
.nest("/oauth2/", oauth::build())
|
||||
.nest("/oidc/", oidc::build())
|
||||
.merge(resources::build())
|
||||
.merge(threepid::build())
|
||||
.fallback(async || WebError::NotFound),
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::{
|
||||
GET_POST, Result, TemplateContext,
|
||||
account::register::{TrustedFlowStatus, UntrustedFlowStatus, registration_flow_status},
|
||||
components::UserCard,
|
||||
oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState},
|
||||
},
|
||||
response,
|
||||
session::{LoginQuery, LoginTarget, User, UserSession},
|
||||
@@ -68,6 +69,24 @@ async fn route_login(
|
||||
) -> Result {
|
||||
let user_id = user.into_session().map(|session| session.user_id);
|
||||
|
||||
if services.oidc.enabled() {
|
||||
if user_id.is_some() && !reauthenticate {
|
||||
return response!(Redirect::to(&next.unwrap_or_default().target_path()));
|
||||
}
|
||||
|
||||
let (session, redirect_url) = services.oidc.begin_session().await;
|
||||
|
||||
session_store
|
||||
.insert(OIDC_SESSION_ID_KEY, OidcSession {
|
||||
next: next.unwrap_or_default(),
|
||||
state: OidcSessionState::CodeExchange { expected_user: user_id, session },
|
||||
})
|
||||
.await
|
||||
.expect("should be able to serialize OIDC session");
|
||||
|
||||
return response!(Redirect::to(redirect_url.as_str()));
|
||||
}
|
||||
|
||||
let body = match &user_id {
|
||||
| None => {
|
||||
let (trusted_flow_status, untrusted_flow_status) =
|
||||
|
||||
@@ -520,7 +520,7 @@ async fn complete_registration(
|
||||
) -> Result<Redirect> {
|
||||
services
|
||||
.users
|
||||
.create_local_account(&user_id, password_hash, email)
|
||||
.create_local_account(&user_id, Some(password_hash), email)
|
||||
.await?;
|
||||
|
||||
if let Some(registration_token) = registration_token {
|
||||
|
||||
@@ -17,6 +17,7 @@ mod components;
|
||||
pub(super) mod debug;
|
||||
pub(super) mod index;
|
||||
pub(super) mod oauth;
|
||||
pub(super) mod oidc;
|
||||
pub(super) mod resources;
|
||||
pub(super) mod threepid;
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::{
|
||||
Extension, Router,
|
||||
extract::{Query, State},
|
||||
response::Redirect,
|
||||
routing::on,
|
||||
};
|
||||
use conduwuit_service::{oauth::grant::AuthorizationCodeResponse, oidc::SessionCompletionStatus};
|
||||
use futures::FutureExt;
|
||||
use ruma::OwnedServerName;
|
||||
use serde::{Deserialize, de::IgnoredAny};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::{
|
||||
WebError,
|
||||
extract::{Expect, PostForm},
|
||||
pages::{
|
||||
GET_POST, Result, TemplateContext,
|
||||
oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState},
|
||||
},
|
||||
response,
|
||||
session::{User, UserSession},
|
||||
template,
|
||||
};
|
||||
|
||||
pub(crate) fn build() -> Router<crate::State> {
|
||||
Router::new().route("/", on(GET_POST, route_complete))
|
||||
}
|
||||
|
||||
template! {
|
||||
struct OidcComplete use "oidc_complete.html.j2" {
|
||||
server_name: OwnedServerName,
|
||||
username_error: Option<String>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginForm {
|
||||
username: String,
|
||||
}
|
||||
|
||||
async fn route_complete(
|
||||
State(services): State<crate::State>,
|
||||
Extension(context): Extension<TemplateContext>,
|
||||
Expect(Query(query)): Expect<Query<AuthorizationCodeResponse>>,
|
||||
session_store: Session,
|
||||
user: User<true>,
|
||||
PostForm(form): PostForm<LoginForm>,
|
||||
) -> Result {
|
||||
let user_id = user.into_session().map(|session| session.user_id);
|
||||
|
||||
let Some(session) = session_store
|
||||
.get::<OidcSession>(OIDC_SESSION_ID_KEY)
|
||||
.await
|
||||
.expect("should be able to deserialize oidc session")
|
||||
else {
|
||||
return response!(WebError::BadRequest(
|
||||
"No OIDC session found. What are you doing here?".to_owned()
|
||||
));
|
||||
};
|
||||
|
||||
let session_completion_status = match session.state {
|
||||
| OidcSessionState::CodeExchange { expected_user, session: pending_session } => {
|
||||
if let (Some(user_id), Some(expected_user)) = (&user_id, &expected_user)
|
||||
&& user_id != expected_user
|
||||
{
|
||||
return response!(WebError::BadRequest(
|
||||
"Identity mismatch. You may have switched accounts at your identity \
|
||||
provider. Please log out and back in to continue."
|
||||
.to_owned()
|
||||
));
|
||||
}
|
||||
|
||||
let claims = services
|
||||
.oidc
|
||||
.exchange_code(pending_session, query)
|
||||
.boxed()
|
||||
.await
|
||||
.map_err(|err| WebError::BadRequest(err.to_owned()))?;
|
||||
|
||||
session_store
|
||||
.insert(OIDC_SESSION_ID_KEY, OidcSession {
|
||||
next: session.next.clone(),
|
||||
state: OidcSessionState::Authorized { claims: Box::new(claims.clone()) },
|
||||
})
|
||||
.await
|
||||
.expect("Should be able to serialize oidc session");
|
||||
|
||||
services.oidc.complete_session(&claims, None).await
|
||||
},
|
||||
| OidcSessionState::Authorized { claims } =>
|
||||
services
|
||||
.oidc
|
||||
.complete_session(&claims, form.map(|form| form.username))
|
||||
.await,
|
||||
}
|
||||
.map_err(|err| WebError::BadRequest(err.to_owned()))?;
|
||||
|
||||
match session_completion_status {
|
||||
| SessionCompletionStatus::Complete(user_id) => {
|
||||
let _ = session_store
|
||||
.remove::<IgnoredAny>(OIDC_SESSION_ID_KEY)
|
||||
.await;
|
||||
|
||||
let user_session = UserSession { user_id, last_login: SystemTime::now() };
|
||||
|
||||
session_store
|
||||
.insert(User::KEY, user_session)
|
||||
.await
|
||||
.expect("should be able to serialize user session");
|
||||
|
||||
response!(Redirect::to(&session.next.target_path()))
|
||||
},
|
||||
| SessionCompletionStatus::NeedsLocalpart => {
|
||||
response!(OidcComplete::new(context, services.globals.server_name().to_owned(), None))
|
||||
},
|
||||
| SessionCompletionStatus::InvalidLocalpart(error) => {
|
||||
response!(OidcComplete::new(
|
||||
context,
|
||||
services.globals.server_name().to_owned(),
|
||||
Some(error)
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use axum::Router;
|
||||
use conduwuit_service::oidc;
|
||||
use openidconnect::core::CoreIdTokenClaims;
|
||||
use ruma::OwnedUserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::session::LoginTarget;
|
||||
|
||||
mod complete;
|
||||
|
||||
pub(crate) const OIDC_SESSION_ID_KEY: &str = "oidc_session";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(crate) struct OidcSession {
|
||||
pub next: LoginTarget,
|
||||
pub state: OidcSessionState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(crate) enum OidcSessionState {
|
||||
CodeExchange {
|
||||
expected_user: Option<OwnedUserId>,
|
||||
session: oidc::PendingSession,
|
||||
},
|
||||
Authorized {
|
||||
claims: Box<CoreIdTokenClaims>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn build() -> Router<crate::State> {
|
||||
#[allow(clippy::wildcard_imports)]
|
||||
use self::*;
|
||||
|
||||
Router::new().nest("/complete", complete::build())
|
||||
}
|
||||
@@ -33,6 +33,7 @@
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1;
|
||||
align-items: baseline;
|
||||
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 2px solid var(--secondary);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "_layout.html.j2" %}
|
||||
{% import "_components/form.html.j2" as form %}
|
||||
|
||||
{%- block head -%}
|
||||
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block title -%}
|
||||
Link your account
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block content -%}
|
||||
<div class="panel narrow">
|
||||
<h1 class="with-matrix-icon">
|
||||
Link your account
|
||||
<a href="https://matrix.org" target="_blank" noreferer>
|
||||
<img class="matrix-icon" alt="Matrix logo" aria-ignore src="{{ crate::ROUTE_PREFIX }}/resources/matrix-icon.svg">
|
||||
</a>
|
||||
</h1>
|
||||
<form method="post">
|
||||
<p>To finish linking your account to Matrix, choose a username.</p>
|
||||
<p>
|
||||
<label for="username">Username</label>
|
||||
<span class="username-input">
|
||||
<span>@</span>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
<span>:{{ server_name }}</span>
|
||||
</span>
|
||||
{% if let Some(username_error) = username_error %}
|
||||
<small class="error">
|
||||
{{ username_error }}
|
||||
</small>
|
||||
{% endif %}
|
||||
<small>Your username cannot be changed after you create your account.</small>
|
||||
</p>
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user