Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! The signed-in user's own settings: profile, SSH keys, access tokens (spec §7).
2//!
3//! Everything here operates on `user.id` taken from the session, never from the
4//! request — so there is no object-id parameter an attacker could substitute to
5//! reach somebody else's key. The delete queries carry `AND user_id = $2` as a
6//! second line of defence, and their `rows_affected()` is what decides whether
7//! the page says "removed" or "not found".
8
9use axum::extract::{Path as UrlPath, Query, State};
10use axum::response::{IntoResponse, Redirect, Response};
11use axum::Form;
12use df_db::ids::new_id;
13use serde::Deserialize;
14use uuid::Uuid;
15
16use crate::error::{AppError, AppResult};
17use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
18use crate::views::settings as v;
19use crate::views::{self, Chrome};
20
21#[derive(Deserialize, Default)]
22pub struct Flash {
23 pub error: Option<String>,
24 pub notice: Option<String>,
25 /// A freshly minted token, round-tripped once through a redirect so a
26 /// refresh of the POST does not mint a second one.
27 pub token: Option<String>,
28}
29
30/// `GET /settings`
31pub async fn show(
32 State(state): State<AppState>,
33 Query(flash): Query<Flash>,
34 CurrentUser(user): CurrentUser,
35 CsrfToken(csrf): CsrfToken,
36 Nonce(nonce): Nonce,
37) -> AppResult<Response> {
38 let Some(user) = user else {
39 return Err(AppError::Unauthorized);
40 };
41
42 let keys = load_keys(&state, user.id).await?;
43 let tokens = load_tokens(&state, user.id).await?;
44 let ssh_host = state.config.ssh_clone_url(&user.handle, "repo");
45
46 Ok(views::page(
47 Chrome { title: "Settings", user: Some(&user), csrf: &csrf, nonce: &nonce },
48 v::user_settings(v::UserSettings {
49 user: &user,
50 csrf: &csrf,
51 keys: &keys,
52 tokens: &tokens,
53 new_token: flash.token.as_deref(),
54 error: flash.error.as_deref(),
55 notice: flash.notice.as_deref(),
56 ssh_host: &ssh_host,
57 }),
58 )
59 .into_response())
60}
61
62// ─── SSH keys ────────────────────────────────────────────────────────────────
63
64#[derive(Deserialize)]
65pub struct AddKey {
66 pub key: String,
67 pub name: Option<String>,
68}
69
70/// `POST /settings/keys`
71pub async fn add_key(
72 State(state): State<AppState>,
73 CurrentUser(user): CurrentUser,
74 Form(form): Form<AddKey>,
75) -> AppResult<Response> {
76 let Some(user) = user else {
77 return Err(AppError::Unauthorized);
78 };
79
80 let parsed = match df_auth::ssh_keys::parse(&form.key) {
81 Ok(p) => p,
82 Err(e) => return Ok(redirect_err(&e.to_string())),
83 };
84
85 let name = form
86 .name
87 .as_deref()
88 .map(str::trim)
89 .filter(|s| !s.is_empty())
90 .map(|s| s.chars().take(100).collect::<String>())
91 .unwrap_or_else(|| df_auth::ssh_keys::suggested_name(&parsed));
92
93 // `fingerprint` is UNIQUE across the whole table, not per user: the same key
94 // must not authenticate as two different people, which is the entire basis
95 // of SSH auth here. So a duplicate is a conflict even when it belongs to
96 // somebody else — and the message deliberately does not say which.
97 let inserted = sqlx::query(
98 "INSERT INTO ssh_keys (id, user_id, name, key_type, fingerprint, public_key)
99 VALUES ($1, $2, $3, $4, $5, $6)
100 ON CONFLICT (fingerprint) DO NOTHING",
101 )
102 .bind(new_id())
103 .bind(user.id)
104 .bind(&name)
105 .bind(&parsed.key_type)
106 .bind(&parsed.fingerprint)
107 .bind(&parsed.openssh)
108 .execute(&state.db)
109 .await?;
110
111 if inserted.rows_affected() == 0 {
112 return Ok(redirect_err("That key is already registered."));
113 }
114
115 audit(&state, user.id, "ssh_key.added", &parsed.fingerprint).await;
116 Ok(redirect_ok("SSH key added."))
117}
118
119/// `POST /settings/keys/{id}/delete` and `DELETE /settings/keys/{id}`
120pub async fn delete_key(
121 State(state): State<AppState>,
122 UrlPath(id): UrlPath<Uuid>,
123 CurrentUser(user): CurrentUser,
124) -> AppResult<Response> {
125 let Some(user) = user else {
126 return Err(AppError::Unauthorized);
127 };
128
129 let deleted = sqlx::query("DELETE FROM ssh_keys WHERE id = $1 AND user_id = $2")
130 .bind(id)
131 .bind(user.id)
132 .execute(&state.db)
133 .await?;
134
135 if deleted.rows_affected() == 0 {
136 return Ok(redirect_err("That key is not yours, or no longer exists."));
137 }
138
139 audit(&state, user.id, "ssh_key.removed", &id.to_string()).await;
140 Ok(redirect_ok("SSH key removed."))
141}
142
143// ─── access tokens ───────────────────────────────────────────────────────────
144
145#[derive(Deserialize)]
146pub struct CreateToken {
147 pub name: String,
148 pub expires_days: Option<i64>,
149}
150
151/// `POST /settings/tokens`
152pub async fn create_token(
153 State(state): State<AppState>,
154 CurrentUser(user): CurrentUser,
155 Form(form): Form<CreateToken>,
156) -> AppResult<Response> {
157 let Some(user) = user else {
158 return Err(AppError::Unauthorized);
159 };
160
161 let name: String = form.name.trim().chars().take(100).collect();
162 if name.is_empty() {
163 return Ok(redirect_err("A token needs a name."));
164 }
165
166 // Clamped rather than rejected: the field is a fixed select, so an
167 // out-of-range value is a hand-crafted request, and the safe reading of one
168 // is the shortest lifetime rather than the longest.
169 let expires_at = match form.expires_days.unwrap_or(90) {
170 0 => None,
171 d => Some(chrono::Utc::now() + chrono::Duration::days(d.clamp(1, 365))),
172 };
173
174 let token = df_auth::tokens::create(&state.db, user.id, &name, &[], expires_at).await?;
175
176 audit(&state, user.id, "token.created", &token.prefix).await;
177
178 // The plaintext travels back through the redirect so a browser refresh does
179 // not mint a second token. It is in a URL, which is not ideal — but the URL
180 // is same-origin, never linked, and the alternative is rendering the POST
181 // response directly and having refresh-to-remint.
182 Ok(Redirect::to(&format!(
183 "/settings?token={}&notice={}",
184 urlencode(&token.plaintext),
185 urlencode("Token created.")
186 ))
187 .into_response())
188}
189
190/// `POST /settings/tokens/{id}/delete` and `DELETE /settings/tokens/{id}`
191pub async fn delete_token(
192 State(state): State<AppState>,
193 UrlPath(id): UrlPath<Uuid>,
194 CurrentUser(user): CurrentUser,
195) -> AppResult<Response> {
196 let Some(user) = user else {
197 return Err(AppError::Unauthorized);
198 };
199
200 if df_auth::tokens::revoke(&state.db, user.id, id).await? {
201 audit(&state, user.id, "token.revoked", &id.to_string()).await;
202 Ok(redirect_ok("Token revoked."))
203 } else {
204 Ok(redirect_err("That token is not yours, or no longer exists."))
205 }
206}
207
208// ─── loading ─────────────────────────────────────────────────────────────────
209
210async fn load_keys(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::SshKeyRow>> {
211 let rows: Vec<(Uuid, String, String, String, Option<chrono::DateTime<chrono::Utc>>)> =
212 sqlx::query_as(
213 "SELECT id, name, key_type, fingerprint, last_used_at
214 FROM ssh_keys WHERE user_id = $1 ORDER BY created_at",
215 )
216 .bind(user_id)
217 .fetch_all(&state.db)
218 .await?;
219
220 Ok(rows
221 .into_iter()
222 .map(|(id, name, key_type, fingerprint, last_used_at)| v::SshKeyRow {
223 id,
224 name,
225 key_type,
226 fingerprint,
227 last_used_at,
228 })
229 .collect())
230}
231
232async fn load_tokens(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::TokenRow>> {
233 let rows: Vec<(
234 Uuid,
235 String,
236 String,
237 Option<chrono::DateTime<chrono::Utc>>,
238 Option<chrono::DateTime<chrono::Utc>>,
239 )> = sqlx::query_as(
240 "SELECT id, name, prefix, expires_at, last_used_at
241 FROM access_tokens WHERE user_id = $1 ORDER BY created_at",
242 )
243 .bind(user_id)
244 .fetch_all(&state.db)
245 .await?;
246
247 Ok(rows
248 .into_iter()
249 .map(|(id, name, prefix, expires_at, last_used_at)| v::TokenRow {
250 id,
251 name,
252 prefix,
253 expires_at,
254 last_used_at,
255 })
256 .collect())
257}
258
259// ─── helpers ─────────────────────────────────────────────────────────────────
260
261/// Record a credential change in the audit log (spec §9).
262///
263/// Best-effort: a settings change must not fail because the audit insert did.
264/// It is logged loudly instead, because a silently missing audit trail is worse
265/// than a noisy one.
266pub async fn audit(state: &AppState, actor: Uuid, action: &str, target: &str) {
267 if let Err(e) = sqlx::query(
268 "INSERT INTO audit_log (id, actor_id, action, target) VALUES ($1, $2, $3, $4)",
269 )
270 .bind(new_id())
271 .bind(actor)
272 .bind(action)
273 .bind(target)
274 .execute(&state.db)
275 .await
276 {
277 tracing::error!(%action, %target, "audit log write failed: {e}");
278 }
279}
280
281fn redirect_ok(msg: &str) -> Response {
282 Redirect::to(&format!("/settings?notice={}", urlencode(msg))).into_response()
283}
284
285fn redirect_err(msg: &str) -> Response {
286 Redirect::to(&format!("/settings?error={}", urlencode(msg))).into_response()
287}
288
289pub fn urlencode(s: &str) -> String {
290 // NON_ALPHANUMERIC is deliberately blunt: this escapes into a query string
291 // that is then re-rendered into HTML, so encoding more than strictly
292 // necessary costs nothing and removes a whole class of mistake.
293 percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
294}

294 lines · Rust