Redirect back to the originating page after sign-in, not always /
Deploy / deploy (push) Successful in 29s
Deploy / deploy (push) Successful in 29s
Sign in link now carries ?redirect=<question_id>; /auth/login stashes it in the session (validated same-site-path-only to avoid an open redirect via a crafted query param), /auth/callback reads it back for the final redirect instead of a hardcoded "/".
This commit is contained in:
+5
-1
@@ -138,7 +138,11 @@ fn QuestionView(
|
|||||||
.into_any()
|
.into_any()
|
||||||
} else {
|
} else {
|
||||||
view! {
|
view! {
|
||||||
<a class="alt-submit" href="/auth/login" rel="external">
|
<a
|
||||||
|
class="alt-submit"
|
||||||
|
href=format!("/auth/login?redirect={question_id}")
|
||||||
|
rel="external"
|
||||||
|
>
|
||||||
"Sign in"
|
"Sign in"
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-4
@@ -40,6 +40,7 @@ pub struct Oidc {
|
|||||||
const PKCE_KEY: &str = "oidc_pkce_verifier";
|
const PKCE_KEY: &str = "oidc_pkce_verifier";
|
||||||
const CSRF_KEY: &str = "oidc_csrf_state";
|
const CSRF_KEY: &str = "oidc_csrf_state";
|
||||||
const NONCE_KEY: &str = "oidc_nonce";
|
const NONCE_KEY: &str = "oidc_nonce";
|
||||||
|
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
||||||
|
|
||||||
impl Oidc {
|
impl Oidc {
|
||||||
/// Discovers the provider and builds the client from environment:
|
/// Discovers the provider and builds the client from environment:
|
||||||
@@ -92,8 +93,31 @@ fn internal(err: impl std::fmt::Display) -> HandlerError {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /auth/login — stash PKCE/state/nonce in the session and bounce to Kanidm.
|
#[derive(Deserialize)]
|
||||||
pub async fn login(State(state): State<AppState>, session: Session) -> Result<Redirect, HandlerError> {
|
pub struct LoginParams {
|
||||||
|
redirect: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A same-site, single-segment-rooted path only - never an absolute
|
||||||
|
/// URL or scheme-relative one (`//evil.com/...` is a valid "path" to a
|
||||||
|
/// browser but resolves to an external host), since this value comes
|
||||||
|
/// straight from a query param an attacker could craft into a phishing
|
||||||
|
/// link that still passes through this app's own real login flow.
|
||||||
|
fn is_safe_redirect(path: &str) -> bool {
|
||||||
|
path.starts_with('/') && !path.starts_with("//") && !path.contains("://")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /auth/login — stash PKCE/state/nonce (and where to return to
|
||||||
|
/// after) in the session and bounce to Kanidm.
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: Session,
|
||||||
|
Query(params): Query<LoginParams>,
|
||||||
|
) -> Result<Redirect, HandlerError> {
|
||||||
|
if let Some(redirect) = params.redirect.filter(|r| is_safe_redirect(r)) {
|
||||||
|
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
|
||||||
let (auth_url, csrf_state, nonce) = state
|
let (auth_url, csrf_state, nonce) = state
|
||||||
@@ -209,8 +233,11 @@ pub async fn callback(
|
|||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
tracing::info!(user = %user.username, "signed in");
|
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
|
||||||
Ok(Redirect::to("/"))
|
let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
tracing::info!(user = %user.username, redirect = %target, "signed in");
|
||||||
|
Ok(Redirect::to(&target))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /auth/logout — drop the session.
|
/// GET /auth/logout — drop the session.
|
||||||
|
|||||||
Reference in New Issue
Block a user