use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Form, State};
use axum::http::{header, HeaderValue, Request, StatusCode};
use axum::middleware::Next;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::Router;
use super::session::{self, AdminSession, AdminSessionSecret, SESSION_COOKIE};
use super::templates::render_template;
use super::urls::AppState;
use super::user::AdminUser;
use crate::core::{Filter, Model, Op, SelectQuery, SqlValue, WhereExpr};
pub(crate) fn public_router(state: AppState) -> Router {
let logout = Router::new()
.route("/logout", post(logout_submit))
.route_layer(crate::forms::csrf::layer());
Router::new()
.route("/login", get(login_form).post(login_submit))
.merge(logout)
.with_state(state)
}
pub(crate) fn protected_router(state: AppState) -> Router {
let router = Router::new().route(
"/account/password",
get(change_password_form).post(change_password_submit),
);
#[cfg(feature = "totp")]
let router = router.route(
"/account/totp",
get(totp_enroll_form).post(totp_enroll_submit),
);
router.with_state(state)
}
async fn login_form(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
login_response(&state, &headers, None).await
}
async fn login_response(
state: &AppState,
headers: &axum::http::HeaderMap,
error: Option<&str>,
) -> Response {
use crate::forms::csrf;
let (token, set_cookie) = csrf::ensure_token(headers, csrf::CSRF_COOKIE);
let html = render_login_form(state, error, &csrf::csrf_input_html(&token)).await;
let mut resp = Html(html).into_response();
if let Some(cookie) = set_cookie {
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
}
resp
}
async fn render_login_form(state: &AppState, error: Option<&str>, csrf_input: &str) -> String {
let admin_prefix = &state.config.admin_prefix;
#[cfg(feature = "admin-sso")]
let sso_providers =
super::sso_provider::list_enabled(&state.pool, &format!("{admin_prefix}/login")).await;
#[cfg(feature = "admin-sso")]
let sso_enabled = !sso_providers.is_empty();
#[cfg(feature = "admin-sso")]
let sso_providers_json = serde_json::to_value(&sso_providers).unwrap_or_default();
#[cfg(not(feature = "admin-sso"))]
let sso_enabled = false;
#[cfg(not(feature = "admin-sso"))]
let sso_providers_json = serde_json::Value::Array(Vec::new());
let ctx = serde_json::json!({
"title": "Sign in",
"action": format!("{admin_prefix}/login"),
"error": error,
"csrf_input": csrf_input,
"sso_enabled": sso_enabled,
"sso_providers": sso_providers_json,
"admin_title": state
.config
.title
.as_deref()
.unwrap_or("Rustango Admin"),
"admin_prefix": admin_prefix,
"static_url": &state.config.static_url,
"totp_enabled": cfg!(feature = "totp"),
});
render_template("login.html", &ctx)
}
#[derive(serde::Deserialize)]
struct LoginInput {
username: String,
password: String,
#[serde(rename = "_csrf", default)]
csrf_token: Option<String>,
#[cfg_attr(not(feature = "totp"), allow(dead_code))]
#[serde(default)]
totp_code: Option<String>,
}
async fn login_submit(
State(state): State<AppState>,
ip: crate::login_throttle::ClientIp,
headers: axum::http::HeaderMap,
Form(form): Form<LoginInput>,
) -> Response {
use crate::login_throttle::{LoginRefused, LoginScope};
use crate::signals::auth::{
meta_from_headers, send_user_logged_in, send_user_login_failed, AuthFailureReason,
UserLoggedInContext, UserLoginFailedContext,
};
let meta = meta_from_headers(&headers, Some("/login"));
let Some(secret) = state.config.session_secret.clone() else {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"session auth not configured",
)
.into_response();
};
if !crate::forms::csrf::verify_form_token(&headers, form.csrf_token.as_deref()) {
return login_response(
&state,
&headers,
Some("Your session expired or the form was invalid. Please try again."),
)
.await;
}
let mut attempt = match crate::login_throttle::shared()
.begin(&LoginScope::Admin, &ip, &form.username)
.await
{
Ok(a) => a,
Err(refused) => return refused.into_response(),
};
let fields: Vec<&'static crate::core::FieldSchema> = AdminUser::SCHEMA.fields.iter().collect();
let select = SelectQuery::by_pk(
AdminUser::SCHEMA,
"username",
SqlValue::String(form.username.clone()),
);
let row = crate::sql::select_one_row_as_json(&state.pool, &select, &fields)
.await
.ok()
.flatten();
let Some(row) = row else {
if crate::passwords::verify_dummy_async(&form.password)
.await
.is_err()
{
return LoginRefused::Busy.into_response();
}
attempt.failed().await;
send_user_login_failed(UserLoginFailedContext {
source: "admin",
attempted_username: Some(form.username.clone()),
reason: AuthFailureReason::InvalidCredentials,
request: meta.clone(),
})
.await;
return login_response(&state, &headers, Some("Invalid credentials.")).await;
};
let stored_name = row
.get("username")
.and_then(|v| v.as_str())
.unwrap_or(form.username.as_str());
if let Err(refused) = attempt.resolve(stored_name).await {
return refused.into_response();
}
let id = row.get("id").and_then(|v| v.as_i64()).unwrap_or_default();
let stored_hash = row
.get("password_hash")
.and_then(|v| v.as_str())
.unwrap_or_default();
let is_active = row.get("active").and_then(|v| v.as_bool()).unwrap_or(true);
let is_superuser = row
.get("is_superuser")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let password_ok = match crate::passwords::verify_async(&form.password, stored_hash).await {
Ok(ok) => ok,
Err(crate::passwords::PasswordError::Busy) => return LoginRefused::Busy.into_response(),
Err(_) => false,
};
if !is_active {
attempt.failed().await;
send_user_login_failed(UserLoginFailedContext {
source: "admin",
attempted_username: Some(form.username.clone()),
reason: AuthFailureReason::Inactive,
request: meta.clone(),
})
.await;
return login_response(&state, &headers, Some("Invalid credentials.")).await;
}
if !password_ok {
attempt.failed().await;
send_user_login_failed(UserLoginFailedContext {
source: "admin",
attempted_username: Some(form.username.clone()),
reason: AuthFailureReason::InvalidCredentials,
request: meta.clone(),
})
.await;
return login_response(&state, &headers, Some("Invalid credentials.")).await;
}
#[cfg(feature = "totp")]
{
let enrolled = match super::totp_store::confirmed_secret_checked(&state.pool, id).await {
Ok(secret) => secret,
Err(e) => {
tracing::error!(
target: "rustango::admin",
user_id = id,
error = %e,
"cannot read the TOTP device; refusing the login rather \
than treating it as no second factor",
);
send_user_login_failed(UserLoginFailedContext {
source: "admin",
attempted_username: Some(form.username.clone()),
reason: AuthFailureReason::InvalidCredentials,
request: meta.clone(),
})
.await;
return login_response(&state, &headers, Some("Invalid credentials.")).await;
}
};
if let Some(totp_secret) = enrolled {
let code = form.totp_code.as_deref().unwrap_or("").trim();
let accepted = !code.is_empty()
&& super::totp_store::redeem_code(&state.pool, id, &totp_secret, code)
.await
.unwrap_or(false);
if !accepted {
if !code.is_empty() {
attempt.failed().await;
}
send_user_login_failed(UserLoginFailedContext {
source: "admin",
attempted_username: Some(form.username.clone()),
reason: AuthFailureReason::InvalidCredentials,
request: meta.clone(),
})
.await;
return login_response(
&state,
&headers,
Some("Enter the 6-digit code from your authenticator app."),
)
.await;
}
}
}
attempt.succeeded().await;
let auth_hash = crate::session::PasswordFingerprint::of(&secret, stored_hash);
let cookie_value = session::encode(
&secret,
AdminSession {
user_id: id,
username: form.username.clone(),
is_superuser,
},
&auth_hash,
);
let cookie = format!(
"{name}={val}; Path=/; HttpOnly; SameSite=Lax{secure}",
name = SESSION_COOKIE,
val = cookie_value,
secure = if state.config.secure_cookies {
"; Secure"
} else {
""
},
);
let redirect_to = if state.config.admin_prefix.is_empty() {
"/".to_owned()
} else {
state.config.admin_prefix.clone()
};
let mut resp = Redirect::to(&redirect_to).into_response();
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
send_user_logged_in(UserLoggedInContext {
source: "admin",
user_id: id,
username: form.username.clone(),
is_superuser,
request: meta,
})
.await;
resp
}
async fn change_password_form(State(state): State<AppState>) -> Html<String> {
Html(render_change_password_form(&state, None, None))
}
#[derive(serde::Deserialize)]
struct ChangePasswordInput {
current_password: String,
new_password: String,
new_password_confirm: String,
}
async fn change_password_submit(
State(state): State<AppState>,
Form(form): Form<ChangePasswordInput>,
) -> Response {
let Some(session) = super::session::current() else {
return (StatusCode::UNAUTHORIZED, "session required").into_response();
};
if form.new_password != form.new_password_confirm {
return Html(render_change_password_form(
&state,
None,
Some("Confirmation password did not match."),
))
.into_response();
}
if form.new_password.len() < 8 {
return Html(render_change_password_form(
&state,
None,
Some("New password must be at least 8 characters."),
))
.into_response();
}
let fields: Vec<&'static crate::core::FieldSchema> = AdminUser::SCHEMA.fields.iter().collect();
let select = SelectQuery::by_pk(AdminUser::SCHEMA, "id", SqlValue::I64(session.user_id));
let row = crate::sql::select_one_row_as_json(&state.pool, &select, &fields)
.await
.ok()
.flatten();
let Some(row) = row else {
return (StatusCode::UNAUTHORIZED, "user not found").into_response();
};
let stored_hash = row
.get("password_hash")
.and_then(|v| v.as_str())
.unwrap_or_default();
let ok = match crate::passwords::verify_async(&form.current_password, stored_hash).await {
Ok(ok) => ok,
Err(crate::passwords::PasswordError::Busy) => {
return crate::login_throttle::LoginRefused::Busy.into_response()
}
Err(_) => false,
};
if !ok {
return Html(render_change_password_form(
&state,
None,
Some("Current password is incorrect."),
))
.into_response();
}
let new_hash = match crate::passwords::hash_async(&form.new_password).await {
Ok(h) => h,
Err(crate::passwords::PasswordError::Busy) => {
return crate::login_throttle::LoginRefused::Busy.into_response()
}
Err(_) => {
return Html(render_change_password_form(
&state,
None,
Some("Internal hashing error."),
))
.into_response();
}
};
use crate::core::{Assignment, Expr, UpdateQuery};
let q = UpdateQuery {
model: AdminUser::SCHEMA,
set: vec![Assignment {
column: "password_hash",
value: Expr::Literal(SqlValue::String(new_hash.clone())),
}],
where_clause: WhereExpr::Predicate(Filter {
column: "id",
op: Op::Eq,
value: SqlValue::I64(session.user_id),
}),
};
if let Err(e) = crate::sql::update_pool(&state.pool, &q).await {
return Html(render_change_password_form(
&state,
None,
Some(&format!("Update failed: {e}")),
))
.into_response();
}
let mut resp = Html(render_change_password_form(
&state,
Some("Password updated."),
None,
))
.into_response();
if let Some(secret) = state.config.session_secret.as_ref() {
let auth_hash = crate::session::PasswordFingerprint::of(secret, &new_hash);
let cookie_value = session::encode(
secret,
AdminSession {
user_id: session.user_id,
username: session.username.clone(),
is_superuser: session.is_superuser,
},
&auth_hash,
);
let cookie = format!(
"{name}={val}; Path=/; HttpOnly; SameSite=Lax{secure}",
name = SESSION_COOKIE,
val = cookie_value,
secure = if state.config.secure_cookies {
"; Secure"
} else {
""
},
);
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
}
resp
}
fn render_change_password_form(
state: &AppState,
success: Option<&str>,
error: Option<&str>,
) -> String {
let admin_prefix = &state.config.admin_prefix;
let mut ctx = serde_json::json!({
"title": "Change password",
"action": format!("{admin_prefix}/account/password"),
"success": success,
"error": error,
});
super::templates::render_with_chrome(
"change_password.html",
&mut ctx,
super::helpers::chrome_context(state, None),
)
}
#[cfg(feature = "totp")]
#[derive(serde::Deserialize)]
struct TotpEnrollInput {
#[serde(default)]
totp_code: Option<String>,
#[serde(default)]
reset: Option<String>,
}
#[cfg(feature = "totp")]
fn render_totp_enroll(
state: &AppState,
already_enabled: bool,
secret_base32: &str,
otpauth_url: &str,
error: Option<&str>,
success: Option<&str>,
) -> String {
let admin_prefix = &state.config.admin_prefix;
let ctx = serde_json::json!({
"title": "Two-factor authentication",
"action": format!("{admin_prefix}/account/totp"),
"admin_title": state.config.title.as_deref().unwrap_or("Rustango Admin"),
"admin_prefix": admin_prefix,
"static_url": &state.config.static_url,
"already_enabled": already_enabled,
"secret_base32": secret_base32,
"otpauth_url": otpauth_url,
"error": error,
"success": success,
"csrf_input": super::helpers::current_csrf_input(),
});
render_template("totp_enroll.html", &ctx)
}
#[cfg(feature = "totp")]
fn enroll_otpauth(state: &AppState, account: &str, secret: &crate::totp::TotpSecret) -> String {
let issuer = state.config.title.as_deref().unwrap_or("Rustango Admin");
crate::totp::otpauth_url(issuer, account, secret)
}
#[cfg(feature = "totp")]
async fn totp_enroll_form(State(state): State<AppState>) -> Response {
let Some(session) = super::session::current() else {
return (StatusCode::UNAUTHORIZED, "session required").into_response();
};
let _ = super::totp_store::ensure_table(&state.pool).await;
let device = super::totp_store::device(&state.pool, session.user_id).await;
if device.as_ref().is_some_and(|d| d.confirmed) {
return Html(render_totp_enroll(&state, true, "", "", None, None)).into_response();
}
let secret = match device.and_then(|d| crate::totp::TotpSecret::from_base32(&d.secret_base32)) {
Some(s) => s,
None => {
let s = crate::totp::TotpSecret::generate();
if super::totp_store::start_enrollment(&state.pool, session.user_id, &s)
.await
.is_err()
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
"could not start enrollment",
)
.into_response();
}
s
}
};
let otpauth = enroll_otpauth(&state, &session.username, &secret);
Html(render_totp_enroll(
&state,
false,
&secret.to_base32(),
&otpauth,
None,
None,
))
.into_response()
}
#[cfg(feature = "totp")]
async fn totp_enroll_submit(
State(state): State<AppState>,
Form(form): Form<TotpEnrollInput>,
) -> Response {
let Some(session) = super::session::current() else {
return (StatusCode::UNAUTHORIZED, "session required").into_response();
};
let _ = super::totp_store::ensure_table(&state.pool).await;
if form.reset.is_some() {
let s = crate::totp::TotpSecret::generate();
let _ = super::totp_store::start_enrollment(&state.pool, session.user_id, &s).await;
let otpauth = enroll_otpauth(&state, &session.username, &s);
return Html(render_totp_enroll(
&state,
false,
&s.to_base32(),
&otpauth,
None,
None,
))
.into_response();
}
let Some(device) = super::totp_store::device(&state.pool, session.user_id).await else {
return Html(render_totp_enroll(
&state,
false,
"",
"",
Some("No enrollment in progress — reload the page."),
None,
))
.into_response();
};
let Some(secret) = crate::totp::TotpSecret::from_base32(&device.secret_base32) else {
return Html(render_totp_enroll(
&state,
false,
"",
"",
Some("Stored setup key is invalid — re-enroll."),
None,
))
.into_response();
};
let code = form.totp_code.as_deref().unwrap_or("").trim();
let confirmed = if code.is_empty() {
Ok(false)
} else {
super::totp_store::confirm_with_code(&state.pool, session.user_id, &secret, code).await
};
if matches!(confirmed, Ok(false)) {
let otpauth = enroll_otpauth(&state, &session.username, &secret);
return Html(render_totp_enroll(
&state,
false,
&device.secret_base32,
&otpauth,
Some("That code didn't match. Try again."),
None,
))
.into_response();
}
if confirmed.is_err() {
return Html(render_totp_enroll(
&state,
false,
"",
"",
Some("Could not save — please try again."),
None,
))
.into_response();
}
Html(render_totp_enroll(
&state,
true,
"",
"",
None,
Some("Two-factor authentication is now enabled."),
))
.into_response()
}
async fn logout_submit(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
use crate::signals::auth::{meta_from_headers, send_user_logged_out, UserLoggedOutContext};
let meta = meta_from_headers(&headers, Some("/logout"));
let (user_id, username) = state
.config
.session_secret
.as_ref()
.and_then(|secret| {
let val = crate::cookies::cookie_from_headers(&headers, SESSION_COOKIE)?;
let sess = session::decode(secret, val)?;
Some((Some(sess.user_id), Some(sess.username)))
})
.unwrap_or((None, None));
let cookie = format!(
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}",
name = SESSION_COOKIE,
secure = if state.config.secure_cookies {
"; Secure"
} else {
""
},
);
let mut resp = Redirect::to(&format!("{}/login", state.config.admin_prefix)).into_response();
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
send_user_logged_out(UserLoggedOutContext {
source: "admin",
user_id,
username,
request: meta,
})
.await;
resp
}
#[derive(Clone)]
pub(crate) struct SessionGate {
pub(crate) secret: Arc<AdminSessionSecret>,
pub(crate) login_path: String,
pub(crate) require_superuser: bool,
pub(crate) pool: crate::sql::Pool,
}
pub(crate) async fn require_session(
State(gate): State<SessionGate>,
mut request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
if path == gate.login_path || path == "/login" || path.starts_with("/__static__") {
return next.run(request).await;
}
if let Some((mut session, cookie_auth_hash)) = read_session_cookie(&request, &gate.secret) {
match gate_live_check(&gate, session.user_id, &cookie_auth_hash).await {
GateCheck::Reject => return Redirect::to(&gate.login_path).into_response(),
GateCheck::Live { is_superuser } => session.is_superuser = is_superuser,
GateCheck::DbError => {}
}
if gate.require_superuser && !session.is_superuser {
return forbidden_page(&session);
}
request.extensions_mut().insert(session.clone());
return super::session::CURRENT_SESSION
.scope(session, next.run(request))
.await;
}
Redirect::to(&gate.login_path).into_response()
}
fn forbidden_page(session: &AdminSession) -> Response {
let username = crate::text::html_escape(&session.username);
let body = format!(
"<!doctype html>\
<html><head><title>Forbidden</title>\
<style>body{{font-family:system-ui;max-width:42em;margin:4em auto;padding:0 1em;line-height:1.5}}\
h1{{font-size:1.4em}}\
.meta{{color:#666;font-size:.9em}}\
</style></head><body>\
<h1>403 — Admin access required</h1>\
<p>You are signed in as <strong>{username}</strong>, but only \
superusers can use the admin.</p>\
<p class=\"meta\">Ask your administrator to grant superuser \
status, or sign out below if this isn't the account you \
intended to use.</p>\
<form method=\"post\" action=\"/logout\">\
<button type=\"submit\">Sign out</button>\
</form>\
</body></html>"
);
let mut resp = Html(body).into_response();
*resp.status_mut() = StatusCode::FORBIDDEN;
resp
}
fn read_session_cookie(
req: &Request<Body>,
secret: &AdminSessionSecret,
) -> Option<(AdminSession, crate::session::PasswordFingerprint)> {
let val = crate::cookies::cookie_from_headers(req.headers(), SESSION_COOKIE)?;
session::decode_full(secret, val)
}
enum GateCheck {
Live { is_superuser: bool },
Reject,
DbError,
}
async fn gate_live_check(
gate: &SessionGate,
user_id: i64,
cookie_auth_hash: &crate::session::PasswordFingerprint,
) -> GateCheck {
let fields: Vec<&'static crate::core::FieldSchema> = AdminUser::SCHEMA.fields.iter().collect();
let select = SelectQuery::by_pk(AdminUser::SCHEMA, "id", SqlValue::I64(user_id));
match crate::sql::select_one_row_as_json(&gate.pool, &select, &fields).await {
Ok(Some(row)) => {
let current = row
.get("password_hash")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !cookie_auth_hash.matches(&gate.secret, current) {
return GateCheck::Reject; }
let active = row.get("active").and_then(|v| v.as_bool()).unwrap_or(true);
if !active {
return GateCheck::Reject;
}
let is_superuser = row
.get("is_superuser")
.and_then(|v| v.as_bool())
.unwrap_or(false);
GateCheck::Live { is_superuser }
}
Ok(None) => GateCheck::Reject, Err(_) => GateCheck::DbError,
}
}
#[cfg(all(test, feature = "postgres"))]
mod tests {
use super::*;
use crate::sql::sqlx::PgPool;
use crate::sql::Pool;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;
fn test_state() -> AppState {
let pool = Pool::Postgres(
PgPool::connect_lazy("postgres://_:_@127.0.0.1:1/_unused")
.expect("connect_lazy never fails"),
);
let mut config = super::super::urls::Config::default();
config.session_secret = Some(crate::session::SessionSecret::from_bytes(vec![7u8; 32]));
AppState {
pool,
config: Arc::new(config),
}
}
#[tokio::test]
async fn get_login_seeds_csrf_cookie_and_form_token() {
let resp = public_router(test_state())
.oneshot(
Request::builder()
.uri("/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let set_cookie = resp
.headers()
.get(header::SET_COOKIE)
.expect("GET should seed a CSRF cookie")
.to_str()
.unwrap();
assert!(set_cookie.contains("rustango_csrf="), "{set_cookie}");
let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
let body = std::str::from_utf8(&body).unwrap();
assert!(
body.contains(r#"name="_csrf""#),
"form must carry the token"
);
}
#[tokio::test]
async fn post_login_without_csrf_token_is_rejected() {
let resp = public_router(test_state())
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("username=alice&password=secret"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let issued_session = resp.headers().get_all(header::SET_COOKIE).iter().any(|c| {
c.to_str()
.map(|s| s.contains(SESSION_COOKIE))
.unwrap_or(false)
});
assert!(
!issued_session,
"a CSRF-less POST must not establish a session"
);
let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
assert!(std::str::from_utf8(&body).unwrap().contains("try again"));
}
}