Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! The signed-in user's own settings: profile, SSH keys, access tokens (spec §7).
Matt W2//!
Matt W3//! Everything here operates on `user.id` taken from the session, never from the
Matt W4//! request — so there is no object-id parameter an attacker could substitute to
Matt W5//! reach somebody else's key. The delete queries carry `AND user_id = $2` as a
Matt W6//! second line of defence, and their `rows_affected()` is what decides whether
Matt W7//! the page says "removed" or "not found".
Matt W8
Matt W9use axum::extract::{Path as UrlPath, Query, State};
Matt W10use axum::response::{IntoResponse, Redirect, Response};
Matt W11use axum::Form;
Matt W12use df_db::ids::new_id;
Matt W13use serde::Deserialize;
Matt W14use uuid::Uuid;
Matt W15
Matt W16use crate::error::{AppError, AppResult};
Matt W17use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W18use crate::views::settings as v;
Matt W19use crate::views::{self, Chrome};
Matt W20
Matt W21#[derive(Deserialize, Default)]
Matt W22pub struct Flash {
Matt W23 pub error: Option<String>,
Matt W24 pub notice: Option<String>,
Matt W25 /// A freshly minted token, round-tripped once through a redirect so a
Matt W26 /// refresh of the POST does not mint a second one.
Matt W27 pub token: Option<String>,
Matt W28}
Matt W29
Matt W30/// `GET /settings`
Matt W31pub async fn show(
Matt W32 State(state): State<AppState>,
Matt W33 Query(flash): Query<Flash>,
Matt W34 CurrentUser(user): CurrentUser,
Matt W35 CsrfToken(csrf): CsrfToken,
Matt W36 Nonce(nonce): Nonce,
Matt W37) -> AppResult<Response> {
Matt W38 let Some(user) = user else {
Matt W39 return Err(AppError::Unauthorized);
Matt W40 };
Matt W41
Matt W42 let keys = load_keys(&state, user.id).await?;
Matt W43 let tokens = load_tokens(&state, user.id).await?;
Matt W44 let ssh_host = state.config.ssh_clone_url(&user.handle, "repo");
Matt W45
Matt W46 Ok(views::page(
Matt W47 Chrome { title: "Settings", user: Some(&user), csrf: &csrf, nonce: &nonce },
Matt W48 v::user_settings(v::UserSettings {
Matt W49 user: &user,
Matt W50 csrf: &csrf,
Matt W51 keys: &keys,
Matt W52 tokens: &tokens,
Matt W53 new_token: flash.token.as_deref(),
Matt W54 error: flash.error.as_deref(),
Matt W55 notice: flash.notice.as_deref(),
Matt W56 ssh_host: &ssh_host,
Matt W57 }),
Matt W58 )
Matt W59 .into_response())
Matt W60}
Matt W61
Matt W62// ─── SSH keys ────────────────────────────────────────────────────────────────
Matt W63
Matt W64#[derive(Deserialize)]
Matt W65pub struct AddKey {
Matt W66 pub key: String,
Matt W67 pub name: Option<String>,
Matt W68}
Matt W69
Matt W70/// `POST /settings/keys`
Matt W71pub async fn add_key(
Matt W72 State(state): State<AppState>,
Matt W73 CurrentUser(user): CurrentUser,
Matt W74 Form(form): Form<AddKey>,
Matt W75) -> AppResult<Response> {
Matt W76 let Some(user) = user else {
Matt W77 return Err(AppError::Unauthorized);
Matt W78 };
Matt W79
Matt W80 let parsed = match df_auth::ssh_keys::parse(&form.key) {
Matt W81 Ok(p) => p,
Matt W82 Err(e) => return Ok(redirect_err(&e.to_string())),
Matt W83 };
Matt W84
Matt W85 let name = form
Matt W86 .name
Matt W87 .as_deref()
Matt W88 .map(str::trim)
Matt W89 .filter(|s| !s.is_empty())
Matt W90 .map(|s| s.chars().take(100).collect::<String>())
Matt W91 .unwrap_or_else(|| df_auth::ssh_keys::suggested_name(&parsed));
Matt W92
Matt W93 // `fingerprint` is UNIQUE across the whole table, not per user: the same key
Matt W94 // must not authenticate as two different people, which is the entire basis
Matt W95 // of SSH auth here. So a duplicate is a conflict even when it belongs to
Matt W96 // somebody else — and the message deliberately does not say which.
Matt W97 let inserted = sqlx::query(
Matt W98 "INSERT INTO ssh_keys (id, user_id, name, key_type, fingerprint, public_key)
Matt W99 VALUES ($1, $2, $3, $4, $5, $6)
Matt W100 ON CONFLICT (fingerprint) DO NOTHING",
Matt W101 )
Matt W102 .bind(new_id())
Matt W103 .bind(user.id)
Matt W104 .bind(&name)
Matt W105 .bind(&parsed.key_type)
Matt W106 .bind(&parsed.fingerprint)
Matt W107 .bind(&parsed.openssh)
Matt W108 .execute(&state.db)
Matt W109 .await?;
Matt W110
Matt W111 if inserted.rows_affected() == 0 {
Matt W112 return Ok(redirect_err("That key is already registered."));
Matt W113 }
Matt W114
Matt W115 audit(&state, user.id, "ssh_key.added", &parsed.fingerprint).await;
Matt W116 Ok(redirect_ok("SSH key added."))
Matt W117}
Matt W118
Matt W119/// `POST /settings/keys/{id}/delete` and `DELETE /settings/keys/{id}`
Matt W120pub async fn delete_key(
Matt W121 State(state): State<AppState>,
Matt W122 UrlPath(id): UrlPath<Uuid>,
Matt W123 CurrentUser(user): CurrentUser,
Matt W124) -> AppResult<Response> {
Matt W125 let Some(user) = user else {
Matt W126 return Err(AppError::Unauthorized);
Matt W127 };
Matt W128
Matt W129 let deleted = sqlx::query("DELETE FROM ssh_keys WHERE id = $1 AND user_id = $2")
Matt W130 .bind(id)
Matt W131 .bind(user.id)
Matt W132 .execute(&state.db)
Matt W133 .await?;
Matt W134
Matt W135 if deleted.rows_affected() == 0 {
Matt W136 return Ok(redirect_err("That key is not yours, or no longer exists."));
Matt W137 }
Matt W138
Matt W139 audit(&state, user.id, "ssh_key.removed", &id.to_string()).await;
Matt W140 Ok(redirect_ok("SSH key removed."))
Matt W141}
Matt W142
Matt W143// ─── access tokens ───────────────────────────────────────────────────────────
Matt W144
Matt W145#[derive(Deserialize)]
Matt W146pub struct CreateToken {
Matt W147 pub name: String,
Matt W148 pub expires_days: Option<i64>,
Matt W149}
Matt W150
Matt W151/// `POST /settings/tokens`
Matt W152pub async fn create_token(
Matt W153 State(state): State<AppState>,
Matt W154 CurrentUser(user): CurrentUser,
Matt W155 Form(form): Form<CreateToken>,
Matt W156) -> AppResult<Response> {
Matt W157 let Some(user) = user else {
Matt W158 return Err(AppError::Unauthorized);
Matt W159 };
Matt W160
Matt W161 let name: String = form.name.trim().chars().take(100).collect();
Matt W162 if name.is_empty() {
Matt W163 return Ok(redirect_err("A token needs a name."));
Matt W164 }
Matt W165
Matt W166 // Clamped rather than rejected: the field is a fixed select, so an
Matt W167 // out-of-range value is a hand-crafted request, and the safe reading of one
Matt W168 // is the shortest lifetime rather than the longest.
Matt W169 let expires_at = match form.expires_days.unwrap_or(90) {
Matt W170 0 => None,
Matt W171 d => Some(chrono::Utc::now() + chrono::Duration::days(d.clamp(1, 365))),
Matt W172 };
Matt W173
Matt W174 let token = df_auth::tokens::create(&state.db, user.id, &name, &[], expires_at).await?;
Matt W175
Matt W176 audit(&state, user.id, "token.created", &token.prefix).await;
Matt W177
Matt W178 // The plaintext travels back through the redirect so a browser refresh does
Matt W179 // not mint a second token. It is in a URL, which is not ideal — but the URL
Matt W180 // is same-origin, never linked, and the alternative is rendering the POST
Matt W181 // response directly and having refresh-to-remint.
Matt W182 Ok(Redirect::to(&format!(
Matt W183 "/settings?token={}&notice={}",
Matt W184 urlencode(&token.plaintext),
Matt W185 urlencode("Token created.")
Matt W186 ))
Matt W187 .into_response())
Matt W188}
Matt W189
Matt W190/// `POST /settings/tokens/{id}/delete` and `DELETE /settings/tokens/{id}`
Matt W191pub async fn delete_token(
Matt W192 State(state): State<AppState>,
Matt W193 UrlPath(id): UrlPath<Uuid>,
Matt W194 CurrentUser(user): CurrentUser,
Matt W195) -> AppResult<Response> {
Matt W196 let Some(user) = user else {
Matt W197 return Err(AppError::Unauthorized);
Matt W198 };
Matt W199
Matt W200 if df_auth::tokens::revoke(&state.db, user.id, id).await? {
Matt W201 audit(&state, user.id, "token.revoked", &id.to_string()).await;
Matt W202 Ok(redirect_ok("Token revoked."))
Matt W203 } else {
Matt W204 Ok(redirect_err("That token is not yours, or no longer exists."))
Matt W205 }
Matt W206}
Matt W207
Matt W208// ─── loading ─────────────────────────────────────────────────────────────────
Matt W209
Matt W210async fn load_keys(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::SshKeyRow>> {
Matt W211 let rows: Vec<(Uuid, String, String, String, Option<chrono::DateTime<chrono::Utc>>)> =
Matt W212 sqlx::query_as(
Matt W213 "SELECT id, name, key_type, fingerprint, last_used_at
Matt W214 FROM ssh_keys WHERE user_id = $1 ORDER BY created_at",
Matt W215 )
Matt W216 .bind(user_id)
Matt W217 .fetch_all(&state.db)
Matt W218 .await?;
Matt W219
Matt W220 Ok(rows
Matt W221 .into_iter()
Matt W222 .map(|(id, name, key_type, fingerprint, last_used_at)| v::SshKeyRow {
Matt W223 id,
Matt W224 name,
Matt W225 key_type,
Matt W226 fingerprint,
Matt W227 last_used_at,
Matt W228 })
Matt W229 .collect())
Matt W230}
Matt W231
Matt W232async fn load_tokens(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::TokenRow>> {
Matt W233 let rows: Vec<(
Matt W234 Uuid,
Matt W235 String,
Matt W236 String,
Matt W237 Option<chrono::DateTime<chrono::Utc>>,
Matt W238 Option<chrono::DateTime<chrono::Utc>>,
Matt W239 )> = sqlx::query_as(
Matt W240 "SELECT id, name, prefix, expires_at, last_used_at
Matt W241 FROM access_tokens WHERE user_id = $1 ORDER BY created_at",
Matt W242 )
Matt W243 .bind(user_id)
Matt W244 .fetch_all(&state.db)
Matt W245 .await?;
Matt W246
Matt W247 Ok(rows
Matt W248 .into_iter()
Matt W249 .map(|(id, name, prefix, expires_at, last_used_at)| v::TokenRow {
Matt W250 id,
Matt W251 name,
Matt W252 prefix,
Matt W253 expires_at,
Matt W254 last_used_at,
Matt W255 })
Matt W256 .collect())
Matt W257}
Matt W258
Matt W259// ─── helpers ─────────────────────────────────────────────────────────────────
Matt W260
Matt W261/// Record a credential change in the audit log (spec §9).
Matt W262///
Matt W263/// Best-effort: a settings change must not fail because the audit insert did.
Matt W264/// It is logged loudly instead, because a silently missing audit trail is worse
Matt W265/// than a noisy one.
Matt W266pub async fn audit(state: &AppState, actor: Uuid, action: &str, target: &str) {
Matt W267 if let Err(e) = sqlx::query(
Matt W268 "INSERT INTO audit_log (id, actor_id, action, target) VALUES ($1, $2, $3, $4)",
Matt W269 )
Matt W270 .bind(new_id())
Matt W271 .bind(actor)
Matt W272 .bind(action)
Matt W273 .bind(target)
Matt W274 .execute(&state.db)
Matt W275 .await
Matt W276 {
Matt W277 tracing::error!(%action, %target, "audit log write failed: {e}");
Matt W278 }
Matt W279}
Matt W280
Matt W281fn redirect_ok(msg: &str) -> Response {
Matt W282 Redirect::to(&format!("/settings?notice={}", urlencode(msg))).into_response()
Matt W283}
Matt W284
Matt W285fn redirect_err(msg: &str) -> Response {
Matt W286 Redirect::to(&format!("/settings?error={}", urlencode(msg))).into_response()
Matt W287}
Matt W288
Matt W289pub fn urlencode(s: &str) -> String {
Matt W290 // NON_ALPHANUMERIC is deliberately blunt: this escapes into a query string
Matt W291 // that is then re-rendered into HTML, so encoding more than strictly
Matt W292 // necessary costs nothing and removes a whole class of mistake.
Matt W293 percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
Matt W294}

294 lines · Rust