1use crate::*;
2use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
3use base64::{
4 Engine,
5 engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD},
6};
7use rusqlite::{Connection, OptionalExtension, params as sql};
8use std::os::unix::fs::PermissionsExt;
9use webauthn_rs::prelude::*;
10
11const COOKIE: &str = "__Host-snow-session";
12const FLOW_COOKIE: &str = "__Host-snow-flow";
13const SESSION_TTL: i64 = 30 * 86400;
14const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm", "ai"];
15
16pub struct Store {
17 pub db: Mutex<Connection>,
18 pub origin: url::Url,
19 file: url::Url,
20 webauthn: Webauthn,
21 passwords: Semaphore,
22}
23
24pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
25 headers
26 .get("cookie")?
27 .to_str()
28 .ok()?
29 .split(';')
30 .find_map(|part| {
31 let (key, value) = part.trim().split_once('=')?;
32 (key == name).then(|| value.to_owned())
33 })
34}
35pub(crate) fn set_cookie(name: &str, value: &str, ttl: i64) -> String {
36 format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}")
37}
38fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> {
39 let value: Option<String> = db.query_row(statement, [key], |r| r.get(0)).optional()?;
40 Ok(value
41 .map(|s| serde_json::from_str(&s))
42 .transpose()?
43 .unwrap_or(Value::Null))
44}
45pub(crate) fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result<Value> {
46 let value: Option<String> = db
47 .query_row(
48 "SELECT data FROM pending WHERE hash=? AND kind=? AND expires>?",
49 sql![mcp::hash(token), kind, now() as i64],
50 |r| r.get(0),
51 )
52 .optional()?;
53 if consume && value.is_some() {
54 db.execute("DELETE FROM pending WHERE hash=?", [mcp::hash(token)])?;
55 }
56 Ok(value
57 .map(|s| serde_json::from_str(&s))
58 .transpose()?
59 .unwrap_or(Value::Null))
60}
61pub(crate) fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result<String> {
62 db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?;
63 let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?;
64 if count >= 4096 {
65 return Err(Error::new(
66 429,
67 "Too many sign-in requests. Try again in a few minutes.",
68 ));
69 }
70 let token = mcp::secret();
71 db.execute(
72 "INSERT INTO pending VALUES (?,?,?,?)",
73 sql![
74 mcp::hash(&token),
75 kind,
76 value.to_string(),
77 now() as i64 + ttl
78 ],
79 )?;
80 Ok(token)
81}
82pub fn user(db: &Connection, id: &str) -> Result<Value> {
83 let mut profile = row(db, "SELECT profile FROM users WHERE id=?", id)?;
84 if profile.is_null() {
85 return Err(Error::new(
86 404,
87 "This account no longer exists. Sign in again.",
88 ));
89 }
90 profile["id"] = json!(id);
91 let mut statement = db.prepare("SELECT roles.id, roles.name FROM roles JOIN memberships ON roles.id=memberships.role_id WHERE user_id=? ORDER BY roles.name")?;
92 profile["groups"] = json!(
93 statement
94 .query_map([id], |r| Ok(
95 json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?})
96 ))?
97 .collect::<std::result::Result<Vec<_>, _>>()?
98 );
99 Ok(profile)
100}
101pub fn credentials(db: &Connection, id: &str) -> Result<Value> {
102 let mut statement = db.prepare(
103 "SELECT id, kind, label, created FROM credentials WHERE user_id=? ORDER BY created",
104 )?;
105 Ok(json!(statement.query_map([id], |r| Ok(json!({"id":r.get::<_,String>(0)?,"type":r.get::<_,String>(1)?,"userLabel":r.get::<_,Option<String>>(2)?,"createdDate":r.get::<_,i64>(3)?})))?.collect::<std::result::Result<Vec<_>,_>>()?))
106}
107pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> {
108 for key in [
109 "id",
110 "groups",
111 "sessions",
112 "credentials",
113 "picture",
114 "console",
115 ] {
116 profile.as_object_mut().unwrap().remove(key);
117 }
118 if profile["emailVerified"] == true {
119 let conflict: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE email=lower(?) AND verified=1 AND user_id!=?)", sql![string(&profile["email"]),id], |r| r.get(0))?;
120 if conflict { return Err(Error::new(409, "This email belongs to another account. Ask an admin to check its owner.")); }
121 }
122 db.execute(
123 "UPDATE users SET profile=? WHERE id=?",
124 sql![profile.to_string(), id],
125 )
126 .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?;
127 Ok(())
128}
129pub fn password_hash(password: &str) -> Result<String> {
130 let salt = SaltString::encode_b64(&rand::random::<[u8; 16]>())
131 .map_err(|_| Error::new(500, "Couldn't prepare password storage."))?;
132 Ok(Argon2::default()
133 .hash_password(password.as_bytes(), &salt)
134 .map_err(|_| Error::new(500, "Couldn't store the password."))?
135 .to_string())
136}
137pub fn set_password(db: &Connection, id: &str, hash: &str) -> Result<()> {
138 db.execute(
139 "DELETE FROM credentials WHERE user_id=? AND kind='password'",
140 [id],
141 )?;
142 db.execute(
143 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
144 sql![
145 uuid::Uuid::new_v4().to_string(),
146 id,
147 "password",
148 Option::<String>::None,
149 (now() * 1000.0) as i64,
150 json!({"phc":hash}).to_string()
151 ],
152 )?;
153 Ok(())
154}
155
156impl Store {
157 pub fn new(data: &std::path::Path, origin: &str, file: &str, rp: &str) -> Result<Self> {
158 let origin = url::Url::parse(origin)?;
159 let file = url::Url::parse(file)?;
160 if origin.scheme() != "https"
161 || file.scheme() != "https"
162 || origin.path() != "/"
163 || file.path() != "/"
164 || origin.origin() == file.origin()
165 {
166 return Err(Error::new(
167 500,
168 "Set separate HTTPS origins for Snowglobe and Files.",
169 ));
170 }
171 let rp_origin = url::Url::parse(&format!("https://{rp}"))?;
172 let webauthn = WebauthnBuilder::new(rp, &rp_origin)?
173 .append_allowed_origin(&origin)
174 .rp_name("snow globe")
175 .build()?;
176 std::fs::create_dir_all(data)?;
177 let path = data.canonicalize()?.join("accounts.sqlite");
178 let db = Connection::open_with_flags(
179 &path,
180 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
181 | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
182 | rusqlite::OpenFlags::SQLITE_OPEN_NOFOLLOW,
183 )?;
184 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
185 db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
186 CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, profile TEXT NOT NULL, username TEXT GENERATED ALWAYS AS (json_extract(profile,'$.username')) STORED UNIQUE);
187 CREATE UNIQUE INDEX IF NOT EXISTS verified_email ON users(lower(json_extract(profile,'$.email'))) WHERE json_extract(profile,'$.emailVerified')=1 AND json_extract(profile,'$.email') IS NOT NULL;
188 CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE);
189 CREATE TABLE IF NOT EXISTS memberships (user_id TEXT REFERENCES users(id) ON DELETE CASCADE, role_id TEXT REFERENCES roles(id), PRIMARY KEY(user_id,role_id));
190 CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,kind TEXT NOT NULL,label TEXT,created INTEGER NOT NULL,data TEXT NOT NULL);
191 CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,client TEXT NOT NULL CHECK(client IN ('dashboard','file')),expires INTEGER NOT NULL,ip TEXT NOT NULL,created INTEGER NOT NULL,last_used INTEGER NOT NULL,auth_time INTEGER NOT NULL);
192 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);
193 CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY);
194 CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?;
195 identity::initialise(&db)?;
196 oidc::initialise(&db)?;
197 guest::initialise(&db)?;
198 for name in GROUPS {
199 db.execute(
200 "INSERT OR IGNORE INTO roles(id,name) VALUES (?,?)",
201 sql![uuid::Uuid::new_v4().to_string(), name],
202 )?;
203 }
204 Ok(Self {
205 db: Mutex::new(db),
206 origin,
207 file,
208 webauthn,
209 passwords: Semaphore::new(2),
210 })
211 }
212 pub fn ready(&self) -> bool {
213 self.db
214 .lock()
215 .unwrap()
216 .query_row("SELECT EXISTS(SELECT 1 FROM users)", [], |r| r.get(0))
217 .unwrap_or(false)
218 }
219 pub fn import(&self, export: Value) -> Result<Value> {
220 if self
221 .webauthn
222 .get_allowed_origins()
223 .first()
224 .and_then(|u| u.host_str())
225 != export["rpId"].as_str()
226 {
227 return Err(Error::new(
228 400,
229 "The export's passkey domain does not match this server.",
230 ));
231 }
232 let mut db = self.db.lock().unwrap();
233 let digest = mcp::hash(&export.to_string());
234 if db.query_row(
235 "SELECT EXISTS(SELECT 1 FROM migration WHERE digest=?)",
236 [&digest],
237 |r| r.get::<_, bool>(0),
238 )? {
239 return Ok(
240 json!({"accounts":array(&export["users"]).len(),"credentials":array(&export["users"]).iter().map(|u|array(&u["credentials"]).len()).sum::<usize>()}),
241 );
242 }
243 if db.query_row("SELECT count(*) FROM users", [], |r| r.get::<_, i64>(0))? != 0 {
244 return Err(Error::new(
245 409,
246 "Accounts already exist. Import into an empty store.",
247 ));
248 }
249 let transaction = db.transaction()?;
250 for role in array(&export["roles"]) {
251 if GROUPS.contains(&string(&role["name"])) {
252 transaction.execute(
253 "INSERT INTO roles VALUES (?,?) ON CONFLICT(name) DO UPDATE SET id=excluded.id",
254 sql![string(&role["id"]), string(&role["name"])],
255 )?;
256 }
257 }
258 let mut count = 0;
259 for profile in array(&export["users"]) {
260 let id = string(&profile["id"]);
261 uuid::Uuid::parse_str(id)?;
262 string(&profile["username"])
263 .parse::<axum::http::HeaderValue>()
264 .map_err(|_| Error::new(400, "The source has an invalid username."))?;
265 let mut value = profile.clone();
266 value["requiredActions"] = json!(
267 array(&profile["requiredActions"])
268 .iter()
269 .filter(|v| **v == "UPDATE_PASSWORD" || **v == "UPDATE_PROFILE")
270 .collect::<Vec<_>>()
271 );
272 for key in ["id", "roles", "credentials"] {
273 value.as_object_mut().unwrap().remove(key);
274 }
275 transaction.execute(
276 "INSERT INTO users(id,profile) VALUES (?,?)",
277 sql![id, value.to_string()],
278 )?;
279 for role in array(&profile["roles"]) {
280 transaction.execute(
281 "INSERT INTO memberships SELECT ?,id FROM roles WHERE id=?",
282 sql![id, string(role)],
283 )?;
284 }
285 for credential in array(&profile["credentials"]) {
286 let kind = string(&credential["type"]);
287 let source = &credential["credentialData"];
288 let data = match kind {
289 "password" => {
290 if source["algorithm"] != "argon2"
291 || source["additionalParameters"]["type"][0] != "id"
292 {
293 return Err(Error::new(
294 500,
295 "The source uses an unsupported password format.",
296 ));
297 }
298 let parameters = &source["additionalParameters"];
299 let salt = STANDARD_NO_PAD
300 .encode(STANDARD.decode(string(&credential["secretData"]["salt"]))?);
301 let hash = STANDARD_NO_PAD
302 .encode(STANDARD.decode(string(&credential["secretData"]["value"]))?);
303 let phc = format!(
304 "$argon2id$v=19$m={},t={},p={}${}${}",
305 string(&parameters["memory"][0]),
306 source["hashIterations"],
307 string(&parameters["parallelism"][0]),
308 salt,
309 hash
310 );
311 PasswordHash::new(&phc).map_err(|_| {
312 Error::new(500, "The source password hash couldn't be imported.")
313 })?;
314 json!({"phc":phc})
315 }
316 "webauthn-passwordless" => {
317 let key: serde_cbor_2::Value = serde_cbor_2::from_slice(
318 &URL_SAFE_NO_PAD.decode(string(&source["credentialPublicKey"]))?,
319 )?;
320 let public_key = COSEKey::try_from(&key)?;
321 let cred = Credential {
322 cred_id: STANDARD.decode(string(&source["credentialId"]))?.into(),
323 cred: public_key,
324 counter: source["counter"].as_u64().unwrap_or(0).try_into()?,
325 transports: serde_json::from_value(source["transports"].clone())
326 .unwrap_or(None),
327 user_verified: true,
328 backup_eligible: false,
329 backup_state: false,
330 registration_policy: serde_json::from_value(json!("required"))?,
331 extensions: Default::default(),
332 attestation: Default::default(),
333 attestation_format: AttestationFormat::None,
334 };
335 // Keycloak omits backup flags; learn them only from the first verified assertion.
336 json!({"passkey":Passkey::from(cred),"handle":URL_SAFE_NO_PAD.encode(id.as_bytes()),"backupUnknown":true})
337 }
338 _ => {
339 return Err(Error::new(
340 500,
341 "The source has a credential type this import doesn't support.",
342 ));
343 }
344 };
345 transaction.execute(
346 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
347 sql![
348 string(&credential["id"]),
349 id,
350 kind,
351 credential["userLabel"].as_str(),
352 credential["createdDate"].as_i64().unwrap_or(0),
353 data.to_string()
354 ],
355 )?;
356 count += 1;
357 }
358 }
359 transaction.execute("INSERT INTO migration VALUES (?)", [digest])?;
360 transaction.commit()?;
361 Ok(json!({"accounts":array(&export["users"]).len(),"credentials":count}))
362 }
363 pub fn session(&self, headers: &HeaderMap, client: &str) -> Result<Value> {
364 let Some(token) = cookie(headers, COOKIE) else {
365 return Ok(Value::Null);
366 };
367 let db = self.db.lock().unwrap();
368 let id: Option<String> = db
369 .query_row(
370 "SELECT user_id FROM sessions WHERE hash=? AND client=? AND expires>?",
371 sql![mcp::hash(&token), client, now() as i64],
372 |r| r.get(0),
373 )
374 .optional()?;
375 let Some(id) = id else {
376 return Ok(Value::Null);
377 };
378 let user = user(&db, &id)?;
379 if user["enabled"] != true || (client == "file" && guest::is_guest(&user)) {
380 return Ok(Value::Null);
381 }
382 db.execute(
383 "UPDATE sessions SET last_used=? WHERE hash=? AND last_used<?",
384 sql![
385 (now() * 1000.0) as i64,
386 mcp::hash(&token),
387 (now() * 1000.0) as i64 - 60000
388 ],
389 )?;
390 Ok(user)
391 }
392 pub(crate) fn create_session(
393 &self,
394 id: &str,
395 client: &str,
396 headers: &HeaderMap,
397 password: Option<&Value>,
398 ) -> Result<String> {
399 let token = mcp::secret();
400 let db = self.db.lock().unwrap();
401 let profile = user(&db, id)?;
402 if profile["enabled"] != true || (client == "file" && guest::is_guest(&profile)) {
403 return Err(Error::new(403, "This account is disabled."));
404 }
405 if let Some(expected) = password {
406 if row(
407 &db,
408 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
409 id,
410 )? != *expected
411 {
412 return Err(Error::new(401, "Your password changed. Sign in again."));
413 }
414 }
415 db.execute("DELETE FROM sessions WHERE expires<=?", [now() as i64])?;
416 let ip = headers
417 .get("X-Studio-Client-IP")
418 .and_then(|v| v.to_str().ok())
419 .unwrap_or("unknown");
420 db.execute(
421 "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)",
422 sql![
423 mcp::hash(&token),
424 id,
425 client,
426 now() as i64 + SESSION_TTL,
427 ip,
428 (now() * 1000.0) as i64,
429 (now() * 1000.0) as i64,
430 now() as i64
431 ],
432 )?;
433 Ok(set_cookie(COOKIE, &token, SESSION_TTL))
434 }
435 pub(crate) fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> {
436 let ip = headers
437 .get("X-Studio-Client-IP")
438 .and_then(|v| v.to_str().ok())
439 .unwrap_or("unknown");
440 let db = self.db.lock().unwrap();
441 db.execute("DELETE FROM attempts WHERE expires<=?", [now() as i64])?;
442 let address = mcp::hash(ip);
443 db.execute(
444 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
445 sql![address, now() as i64 + 300],
446 )?;
447 let total: i64 =
448 db.query_row("SELECT count FROM attempts WHERE key=?", [address], |r| {
449 r.get(0)
450 })?;
451 if total > 100 {
452 return Err(Error::new(
453 429,
454 "Too many attempts. Try again in five minutes.",
455 ));
456 }
457 let key = mcp::hash(&format!("{ip}:{name}"));
458 db.execute(
459 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
460 sql![key, now() as i64 + 300],
461 )?;
462 let count: i64 = db.query_row("SELECT count FROM attempts WHERE key=?", [key], |r| {
463 r.get(0)
464 })?;
465 if count > 20 {
466 return Err(Error::new(
467 429,
468 "Too many attempts. Try again in five minutes.",
469 ));
470 }
471 Ok(())
472 }
473 fn csrf(&self, headers: &HeaderMap, body: &Value) -> Result<()> {
474 if headers.get("origin").and_then(|v| v.to_str().ok())
475 != Some(self.origin.origin().ascii_serialization().as_str())
476 {
477 return Err(Error::new(403, "Open sign-in on Snowglobe and try again."));
478 }
479 let cookie = cookie(headers, FLOW_COOKIE).unwrap_or_default();
480 if cookie.is_empty()
481 || !bool::from(cookie.as_bytes().ct_eq(string(&body["csrf"]).as_bytes()))
482 || pending(&self.db.lock().unwrap(), &cookie, "csrf", false)?.is_null()
483 {
484 return Err(Error::new(
485 403,
486 "Sign-in expired. Reload the page and try again.",
487 ));
488 }
489 Ok(())
490 }
491 fn next(&self, id: &str, flow: &str, path: &str) -> Result<String> {
492 if flow.is_empty() {
493 if !path.starts_with('/')
494 || path.starts_with("//")
495 || path.contains('\\')
496 || path.chars().any(char::is_control)
497 {
498 return Ok("/".into());
499 }
500 return Ok(path.to_owned());
501 }
502 let db = self.db.lock().unwrap();
503 if guest::is_guest(&user(&db, id)?) {
504 return Err(Error::new(
505 403,
506 "Guest accounts can use Shale. Open Shale to continue.",
507 ));
508 }
509 if !array(&user(&db, id)?["requiredActions"]).is_empty() {
510 return Ok("/account".into());
511 }
512 let value = pending(&db, flow, "file", false)?;
513 if value.is_null() {
514 return Err(Error::new(
515 400,
516 "File sign-in expired. Open Files and try again.",
517 ));
518 }
519 let code = issue(&db, "handoff", json!({"user":id,"flow":flow}), 60)?;
520 Ok(format!(
521 "{}auth/file/callback?code={}",
522 self.file,
523 encoded(&code)
524 ))
525 }
526 pub fn sessions(db: &Connection, id: &str) -> Result<Value> {
527 let mut statement = db.prepare("SELECT hash,ip,created,last_used,client FROM sessions WHERE user_id=? AND expires>? ORDER BY last_used DESC")?;
528 Ok(json!(statement.query_map(sql![id,now() as i64],|r|Ok(json!({"id":r.get::<_,String>(0)?,"ipAddress":r.get::<_,String>(1)?,"start":r.get::<_,i64>(2)?,"lastAccess":r.get::<_,i64>(3)?,"clients":{"snow":r.get::<_,String>(4)?}})))?.collect::<std::result::Result<Vec<_>,_>>()?))
529 }
530 pub async fn hash_password(&self, password: &str) -> Result<String> {
531 let _slot = self
532 .passwords
533 .try_acquire()
534 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
535 let password = password.to_owned();
536 tokio::task::spawn_blocking(move || password_hash(&password)).await?
537 }
538 pub fn recent(&self, headers: &HeaderMap) -> Result<()> {
539 let token = cookie(headers, COOKIE).unwrap_or_default();
540 let valid: bool = self.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM sessions WHERE hash=? AND client='dashboard' AND expires>? AND auth_time>?)",sql![mcp::hash(&token),now() as i64,now() as i64-900],|r|r.get(0))?;
541 if !valid {
542 return Err(Error::new(
543 403,
544 "Sign out and sign in again before changing sign-in methods.",
545 ));
546 }
547 Ok(())
548 }
549 pub fn setup_link(&self, id: &str) -> Result<String> {
550 let db = self.db.lock().unwrap();
551 let profile = user(&db, id)?;
552 if guest::is_guest(&profile) {
553 return Err(Error::new(
554 400,
555 "Guests sign in with their provider. Use the Shale sign-in page.",
556 ));
557 }
558 if profile["enabled"] != true {
559 return Err(Error::new(
560 400,
561 "Enable this account before creating a setup link.",
562 ));
563 }
564 db.execute(
565 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
566 [id],
567 )?;
568 let token = issue(&db, "setup", json!({"user":id}), 86400)?;
569 Ok(format!("{}sign-in?setup={}", self.origin, token))
570 }
571}
572
573pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
574 if request.uri().path() == "/auth/shale/page" {
575 return shale_page::proxy(app, request).await;
576 }
577 if request.uri().path().starts_with("/auth/guest/") {
578 return Ok(guest::route(State(app), request).await);
579 }
580 if request.uri().path().starts_with("/auth/oidc/") {
581 return Ok(oidc::route(State(app), request).await);
582 }
583 let auth = &app.auth;
584 let path = request.uri().path().to_owned();
585 let method = request.method().clone();
586 let query: HashMap<String, String> =
587 url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes())
588 .into_owned()
589 .collect();
590 let headers = request.headers().clone();
591 if path == "/auth/file/check" && method == Method::GET {
592 let user = auth.session(&headers, "file")?;
593 if user.is_null() || !array(&user["requiredActions"]).is_empty() {
594 return Ok(StatusCode::UNAUTHORIZED.into_response());
595 }
596 let groups = array(&user["groups"])
597 .iter()
598 .map(|g| string(&g["name"]))
599 .collect::<Vec<_>>()
600 .join(",");
601 return Ok((
602 StatusCode::NO_CONTENT,
603 [
604 (
605 "X-Auth-Request-Preferred-Username",
606 string(&user["username"]).to_owned(),
607 ),
608 ("X-Auth-Request-Groups", groups),
609 ],
610 )
611 .into_response());
612 }
613 if path == "/auth/file/sign-in" && method == Method::GET {
614 let target = query
615 .get("rd")
616 .map(String::as_str)
617 .unwrap_or(auth.file.as_str());
618 let destination = auth.file.join(target)?;
619 if destination.origin() != auth.file.origin()
620 || !destination.username().is_empty()
621 || destination.password().is_some()
622 {
623 return Err(Error::new(400, "Open Files to sign in."));
624 }
625 let flow = issue(
626 &auth.db.lock().unwrap(),
627 "file",
628 json!({"next":destination}),
629 300,
630 )?;
631 return Ok((
632 StatusCode::FOUND,
633 [
634 (
635 "location",
636 format!("{}auth/continue?flow={flow}", auth.origin),
637 ),
638 ("set-cookie", set_cookie(FLOW_COOKIE, &flow, 300)),
639 ],
640 )
641 .into_response());
642 }
643 if path == "/auth/continue" && method == Method::GET {
644 let flow = query.get("flow").cloned().unwrap_or_default();
645 let user = auth.session(&headers, "dashboard")?;
646 let next = if user.is_null() {
647 format!("/sign-in?flow={}", encoded(&flow))
648 } else {
649 auth.next(string(&user["id"]), &flow, "/")?
650 };
651 return Ok((StatusCode::FOUND, [("location", next)]).into_response());
652 }
653 if path == "/auth/file/callback" && method == Method::GET {
654 let token = query.get("code").cloned().unwrap_or_default();
655 let (id, next) = {
656 let mut db = auth.db.lock().unwrap();
657 let transaction = db.transaction()?;
658 let code = pending(&transaction, &token, "handoff", false)?;
659 let flow = cookie(&headers, FLOW_COOKIE).unwrap_or_default();
660 if code.is_null()
661 || flow.is_empty()
662 || !bool::from(flow.as_bytes().ct_eq(string(&code["flow"]).as_bytes()))
663 {
664 return Err(Error::new(
665 403,
666 "File sign-in expired. Open Files and try again.",
667 ));
668 }
669 let target = pending(&transaction, &flow, "file", true)?;
670 if target.is_null() {
671 return Err(Error::new(
672 403,
673 "File sign-in expired. Open Files and try again.",
674 ));
675 }
676 pending(&transaction, &token, "handoff", true)?;
677 let user = user(&transaction, string(&code["user"]))?;
678 if user["enabled"] != true {
679 return Err(Error::new(403, "This account is disabled. Contact Clover."));
680 }
681 let result = (
682 string(&code["user"]).to_owned(),
683 string(&target["next"]).to_owned(),
684 );
685 transaction.commit()?;
686 result
687 };
688 let session = auth.create_session(&id, "file", &headers, None)?;
689 return Ok((
690 StatusCode::FOUND,
691 [("location", next), ("set-cookie", session)],
692 )
693 .into_response());
694 }
695 if path == "/auth/status" && method == Method::GET {
696 let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?;
697 let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?});
698 value["service"] = json!("snow globe");
699 if let Some(flow) = query.get("flow") {
700 if !pending(&auth.db.lock().unwrap(), flow, "file", false)?.is_null() {
701 value["service"] = json!("copyparty");
702 }
703 } else if let Ok((target, config)) = oidc::sign_in_target(
704 auth,
705 query.get("next").map(String::as_str).unwrap_or_default(),
706 ) {
707 value["service"] = config["name"].clone();
708 if target
709 .query_pairs()
710 .any(|(key, value)| key == "client_id" && value == "forward-auth")
711 {
712 if let Some(redirect) = target
713 .query_pairs()
714 .find(|(key, _)| key == "redirect_uri")
715 .and_then(|(_, value)| url::Url::parse(&value).ok())
716 {
717 value["service"] = json!(
718 redirect
719 .host_str()
720 .unwrap_or("snow globe")
721 .split('.')
722 .next()
723 .unwrap_or("snow globe")
724 );
725 if let Ok(catalog) = core::launcher(app.clone()).await {
726 if let Some(service) = array(&catalog.value).iter().find(|service| {
727 service["urls"].as_object().is_some_and(|urls| {
728 urls.values().any(|url| {
729 url.as_str()
730 == Some(redirect.origin().ascii_serialization().as_str())
731 })
732 })
733 }) {
734 value["service"] = service["name"].clone();
735 }
736 }
737 }
738 }
739 }
740 value["providers"] = guest::providers(
741 auth,
742 query.get("next").map(String::as_str).unwrap_or_default(),
743 )?;
744 if let Some(setup) = query.get("setup") {
745 let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?;
746 if entry.is_null() {
747 return Err(Error::new(
748 410,
749 "This link expired. Ask Clover for a new one.",
750 ));
751 }
752 value["setup"] =
753 user(&auth.db.lock().unwrap(), string(&entry["user"]))?["username"].clone();
754 }
755 return Ok((
756 [
757 ("set-cookie", set_cookie(FLOW_COOKIE, &csrf, 900)),
758 ("cache-control", "no-store".into()),
759 ],
760 axum::Json(value),
761 )
762 .into_response());
763 }
764 let shale_logout = path == "/auth/shale/sign-out";
765 if path == "/auth/sign-out" || path == "/auth/file/sign-out" || shale_logout {
766 if method == Method::GET && path == "/auth/file/sign-out" {
767 return Ok(axum::response::Html("<!doctype html><html><meta name=viewport content='width=device-width'><title>Sign out of Files</title><body><form method=post action='/auth/file/sign-out'><button>sign out of Files</button></form></body></html>").into_response());
768 }
769 if method
770 != (if shale_logout {
771 Method::GET
772 } else {
773 Method::POST
774 })
775 {
776 return Err(Error::new(405, "Use the sign-out button."));
777 }
778 let expected = if shale_logout {
779 &app.shale.origin
780 } else if path.contains("/file/") {
781 &auth.file
782 } else {
783 &auth.origin
784 };
785 let source = if shale_logout {
786 headers
787 .get("referer")
788 .and_then(|v| v.to_str().ok())
789 .and_then(|v| url::Url::parse(v).ok())
790 .map(|v| v.origin().ascii_serialization())
791 } else {
792 headers
793 .get("origin")
794 .and_then(|v| v.to_str().ok())
795 .map(str::to_owned)
796 };
797 if source.as_deref() != Some(expected.origin().ascii_serialization().as_str()) {
798 return Err(Error::new(403, "Open your account to sign out."));
799 }
800 if let Some(token) = cookie(&headers, COOKIE) {
801 auth.db
802 .lock()
803 .unwrap()
804 .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?;
805 }
806 if path.contains("/file/") || shale_logout {
807 return Ok((
808 StatusCode::SEE_OTHER,
809 [
810 ("set-cookie", set_cookie(COOKIE, "", 0)),
811 (
812 "location",
813 if shale_logout {
814 app.shale.origin.to_string()
815 } else {
816 "/".into()
817 },
818 ),
819 ],
820 )
821 .into_response());
822 }
823 return Ok((
824 [("set-cookie", set_cookie(COOKIE, "", 0))],
825 axum::Json(json!({"next":"/sign-in"})),
826 )
827 .into_response());
828 }
829 if method != Method::POST {
830 return Err(Error::new(404, "No sign-in action here."));
831 }
832 let body: Value =
833 serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 128 * 1024).await?)
834 .map_err(|_| Error::new(400, "Reload the form and try again."))?;
835 auth.csrf(&headers, &body)?;
836 if path == "/auth/password" || path == "/auth/passkey/start" {
837 let name = string(&body["username"]).trim().to_lowercase();
838 if name.len() > 254 || (name.is_empty() && path == "/auth/password") {
839 return Err(Error::new(400, "Enter your username."));
840 }
841 auth.limit(&headers, &name)?;
842 let id: Option<String> = auth.db.lock().unwrap().query_row("SELECT id FROM users WHERE username=? OR (lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1) ORDER BY username=? DESC LIMIT 1",sql![name,name,name],|r|r.get(0)).optional()?;
843 let user = id
844 .as_ref()
845 .map(|id| user(&auth.db.lock().unwrap(), id))
846 .transpose()?
847 .unwrap_or(Value::Null);
848 if path == "/auth/password" {
849 let password = string(&body["password"]).to_owned();
850 if password.len() > 1024 {
851 return Err(Error::new(400, "That password is too long."));
852 }
853 let data = row(
854 &auth.db.lock().unwrap(),
855 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
856 id.as_deref().unwrap_or(""),
857 )?;
858 let phc = string(&data["phc"]).to_owned();
859 let _slot = auth
860 .passwords
861 .try_acquire()
862 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
863 let verified = tokio::task::spawn_blocking(move || {
864 if phc.is_empty() {
865 let _ = password_hash(&password);
866 return false;
867 }
868 PasswordHash::new(&phc).is_ok_and(|hash| {
869 Argon2::default()
870 .verify_password(password.as_bytes(), &hash)
871 .is_ok()
872 })
873 })
874 .await?;
875 if !verified || user["enabled"] != true {
876 return Err(Error::new(
877 401,
878 "That username or password doesn't match. Try again.",
879 ));
880 }
881 let id = id.unwrap();
882 let mut session = auth.create_session(&id, "dashboard", &headers, Some(&data))?;
883 if body["remember"] == false {
884 session = session.replace(&format!("; Max-Age={SESSION_TTL}"), "");
885 }
886 let next = if !array(&user["requiredActions"]).is_empty() {
887 "/account".into()
888 } else {
889 auth.next(&id, string(&body["flow"]), string(&body["next"]))?
890 };
891 return Ok(
892 ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(),
893 );
894 }
895 if !name.is_empty() && user["enabled"] != true {
896 return Err(Error::new(
897 401,
898 "No passkey is available for that username. Try your password.",
899 ));
900 }
901 let keys = if let Some(id) = &id {
902 passkeys(&auth.db.lock().unwrap(), id)?
903 } else if name.is_empty() {
904 let db = auth.db.lock().unwrap();
905 let mut statement =
906 db.prepare("SELECT id FROM users WHERE json_extract(profile,'$.enabled')=1")?;
907 let ids = statement
908 .query_map([], |r| r.get::<_, String>(0))?
909 .collect::<std::result::Result<Vec<_>, _>>()?;
910 ids.iter()
911 .map(|id| passkeys(&db, id))
912 .collect::<Result<Vec<_>>>()?
913 .into_iter()
914 .flatten()
915 .collect()
916 } else {
917 Vec::new()
918 };
919 if keys.is_empty() {
920 return Err(Error::new(
921 401,
922 "No passkey is available for that username. Try your password.",
923 ));
924 }
925 let (mut options, state) = auth.webauthn.start_passkey_authentication(&keys)?;
926 if name.is_empty() {
927 options.public_key.allow_credentials.clear();
928 }
929 let token = issue(
930 &auth.db.lock().unwrap(),
931 "authentication",
932 json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"],"remember":body["remember"]}),
933 300,
934 )?;
935 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
936 }
937 if path == "/auth/passkey/finish" {
938 let value = pending(
939 &auth.db.lock().unwrap(),
940 string(&body["token"]),
941 "authentication",
942 true,
943 )?;
944 if value.is_null() || value["csrf"] != body["csrf"] {
945 return Err(Error::new(403, "Passkey sign-in expired. Try again."));
946 }
947 let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone())
948 .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?;
949 let id = if let Some(id) = value["user"].as_str() {
950 id.to_owned()
951 } else {
952 let db = auth.db.lock().unwrap();
953 let mut statement = db.prepare(
954 "SELECT user_id,data FROM credentials WHERE kind='webauthn-passwordless'",
955 )?;
956 let rows = statement
957 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
958 .collect::<std::result::Result<Vec<_>, _>>()?;
959 let mut found = None;
960 for (id, data) in rows {
961 let data: Value = serde_json::from_str(&data)?;
962 let passkey: Passkey = serde_json::from_value(data["passkey"].clone())?;
963 if passkey.cred_id().as_slice() == credential.get_credential_id()
964 && body["credential"]["response"]["userHandle"] == data["handle"]
965 {
966 if found.is_some() {
967 return Err(Error::new(
968 401,
969 "That passkey couldn't identify your account.",
970 ));
971 }
972 found = Some(id);
973 }
974 }
975 found.ok_or_else(|| {
976 Error::new(
977 401,
978 "That passkey couldn't identify your account. Try your password.",
979 )
980 })?
981 };
982 let mut state = value["state"].clone();
983 let mut allowed: Vec<Credential> =
984 serde_json::from_value(state["ast"]["credentials"].clone())?;
985 {
986 let db = auth.db.lock().unwrap();
987 let mut statement = db.prepare(
988 "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
989 )?;
990 for stored in statement.query_map([&id], |r| r.get::<_, String>(0))? {
991 let stored: Value = serde_json::from_str(&stored?)?;
992 let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?;
993 if stored["backupUnknown"] == true
994 && passkey.cred_id().as_slice() == credential.get_credential_id()
995 {
996 let flags = credential
997 .response
998 .authenticator_data
999 .as_slice()
1000 .get(32)
1001 .copied()
1002 .ok_or_else(|| Error::new(400, "The passkey response was incomplete."))?;
1003 for key in &mut allowed {
1004 if key.cred_id == *passkey.cred_id() {
1005 key.backup_eligible = flags & 8 != 0;
1006 key.backup_state = flags & 16 != 0;
1007 }
1008 }
1009 }
1010 }
1011 }
1012 state["ast"]["credentials"] = json!(allowed);
1013 let state: PasskeyAuthentication = serde_json::from_value(state)?;
1014 let result = auth
1015 .webauthn
1016 .finish_passkey_authentication(&credential, &state)
1017 .map_err(|error| {
1018 eprintln!("passkey authentication: {error:?}");
1019 Error::new(
1020 401,
1021 "That passkey couldn't sign in. Try again or use your password.",
1022 )
1023 })?;
1024 let id = id.as_str();
1025 {
1026 let db = auth.db.lock().unwrap();
1027 let user = user(&db, id)?;
1028 if user["enabled"] != true {
1029 return Err(Error::new(403, "This account is disabled. Contact Clover."));
1030 }
1031 let mut statement = db.prepare(
1032 "SELECT id,data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
1033 )?;
1034 let rows = statement
1035 .query_map([id], |r| {
1036 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1037 })?
1038 .collect::<std::result::Result<Vec<_>, _>>()?;
1039 let mut matched = false;
1040 for (key, data) in rows {
1041 let mut data: Value = serde_json::from_str(&data)?;
1042 let mut passkey: Passkey = serde_json::from_value(data["passkey"].clone())?;
1043 if passkey.cred_id() == result.cred_id() {
1044 if let Some(handle) = body["credential"]["response"]["userHandle"].as_str() {
1045 if !handle.is_empty() && handle != string(&data["handle"]) {
1046 return Err(Error::new(
1047 401,
1048 "That passkey belongs to a different account.",
1049 ));
1050 }
1051 }
1052 matched = true;
1053 let current: Credential = passkey.clone().into();
1054 if (current.counter != 0 || result.counter() != 0)
1055 && result.counter() <= current.counter
1056 {
1057 return Err(Error::new(
1058 401,
1059 "This passkey returned an old counter. Try another sign-in method.",
1060 ));
1061 }
1062 if data["backupUnknown"] == true {
1063 let mut key: Credential = passkey.into();
1064 key.backup_eligible = result.backup_eligible();
1065 key.backup_state = result.backup_state();
1066 passkey = key.into();
1067 data.as_object_mut().unwrap().remove("backupUnknown");
1068 }
1069 passkey.update_credential(&result);
1070 data["passkey"] = json!(passkey);
1071 db.execute(
1072 "UPDATE credentials SET data=? WHERE id=?",
1073 sql![data.to_string(), key],
1074 )?;
1075 break;
1076 }
1077 }
1078 if !matched {
1079 return Err(Error::new(
1080 401,
1081 "This passkey was removed. Try another sign-in method.",
1082 ));
1083 }
1084 }
1085 let mut session = auth.create_session(id, "dashboard", &headers, None)?;
1086 if body.get("remember").unwrap_or(&value["remember"]) == false {
1087 session = session.replace(&format!("; Max-Age={SESSION_TTL}"), "");
1088 }
1089 let next =
1090 if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() {
1091 "/account".into()
1092 } else {
1093 auth.next(id, string(&value["flow"]), string(&value["next"]))?
1094 };
1095 return Ok(([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response());
1096 }
1097 if path == "/auth/setup" {
1098 let email = string(&body["email"]).trim();
1099 if !email.contains('@') || email.len() > 254 {
1100 return Err(Error::new(400, "Enter your email address."));
1101 }
1102 let password = string(&body["password"]).to_owned();
1103 if password.chars().count() < 8 || password.len() > 1024 {
1104 return Err(Error::new(
1105 400,
1106 "Use a password with at least 8 characters.",
1107 ));
1108 }
1109 let _slot = auth
1110 .passwords
1111 .try_acquire()
1112 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
1113 let hash = tokio::task::spawn_blocking(move || password_hash(&password)).await??;
1114 let id = {
1115 let mut db = auth.db.lock().unwrap();
1116 let transaction = db.transaction()?;
1117 let entry = pending(&transaction, string(&body["setup"]), "setup", true)?;
1118 if entry.is_null() {
1119 return Err(Error::new(
1120 410,
1121 "This link expired. Ask Clover for a new one.",
1122 ));
1123 }
1124 let id = string(&entry["user"]).to_owned();
1125 let mut profile = user(&transaction, &id)?;
1126 profile["email"] = json!(email);
1127 profile["emailVerified"] = json!(false);
1128 if profile["enabled"] != true {
1129 return Err(Error::new(
1130 403,
1131 "This account is disabled. Ask Clover for a new link.",
1132 ));
1133 }
1134 profile["requiredActions"] = json!([]);
1135 set_password(&transaction, &id, &hash)?;
1136 save_user(&transaction, &id, profile)?;
1137 transaction.execute("DELETE FROM sessions WHERE user_id=?", [&id])?;
1138 transaction.commit()?;
1139 id
1140 };
1141 users::revoke_connections(&app, &id)?;
1142 return Ok((
1143 [(
1144 "set-cookie",
1145 auth.create_session(&id, "dashboard", &headers, None)?,
1146 )],
1147 axum::Json(json!({"next":"/account?welcome=1"})),
1148 )
1149 .into_response());
1150 }
1151 let user = auth.session(&headers, "dashboard")?;
1152 if user.is_null() {
1153 return Err(Error::new(401, "Sign in to manage your account."));
1154 }
1155 if guest::is_guest(&user) {
1156 return Err(Error::new(
1157 403,
1158 "Guests sign in with their provider. Use the Shale sign-in page.",
1159 ));
1160 }
1161 let id = string(&user["id"]);
1162 if path == "/auth/passkey/register" {
1163 auth.recent(&headers)?;
1164 let db = auth.db.lock().unwrap();
1165 let keys = passkeys(&db, id)?;
1166 let ids = keys.iter().map(|key| key.cred_id().clone()).collect();
1167 let uuid = uuid::Uuid::parse_str(id)?;
1168 let (options, state) = auth.webauthn.start_passkey_registration(
1169 uuid,
1170 string(&user["username"]),
1171 string(&user["username"]),
1172 Some(ids),
1173 )?;
1174 let token = issue(
1175 &db,
1176 "registration",
1177 json!({"user":id,"csrf":body["csrf"],"state":state}),
1178 300,
1179 )?;
1180 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
1181 }
1182 if path == "/auth/passkey/save" {
1183 auth.recent(&headers)?;
1184 let db = auth.db.lock().unwrap();
1185 let value = pending(&db, string(&body["token"]), "registration", true)?;
1186 if value.is_null() || value["user"] != user["id"] || value["csrf"] != body["csrf"] {
1187 return Err(Error::new(403, "Passkey setup expired. Try again."));
1188 }
1189 let credential: RegisterPublicKeyCredential =
1190 serde_json::from_value(body["credential"].clone()).map_err(|_| {
1191 Error::new(400, "The browser couldn't create your passkey. Try again.")
1192 })?;
1193 let state: PasskeyRegistration = serde_json::from_value(value["state"].clone())?;
1194 let passkey = auth
1195 .webauthn
1196 .finish_passkey_registration(&credential, &state)
1197 .map_err(|_| Error::new(400, "That passkey couldn't be added. Try again."))?;
1198 let label = string(&body["label"]).trim();
1199 if label.len() > 100 {
1200 return Err(Error::new(400, "Use a shorter passkey name."));
1201 }
1202 db.execute("INSERT INTO credentials VALUES (?,?,?,?,?,?)",sql![uuid::Uuid::new_v4().to_string(),id,"webauthn-passwordless",if label.is_empty(){"passkey"}else{label},(now()*1000.0) as i64,json!({"passkey":passkey,"handle":URL_SAFE_NO_PAD.encode(uuid::Uuid::parse_str(id)?.as_bytes())}).to_string()])?;
1203 return Ok(StatusCode::NO_CONTENT.into_response());
1204 }
1205 if path == "/auth/password/change" {
1206 auth.recent(&headers)?;
1207 let data = row(
1208 &auth.db.lock().unwrap(),
1209 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
1210 id,
1211 )?;
1212 let phc = string(&data["phc"]).to_owned();
1213 let current = string(&body["current"]).to_owned();
1214 let password = string(&body["password"]).to_owned();
1215 if password.chars().count() < 8 || password.len() > 1024 || current.len() > 1024 {
1216 return Err(Error::new(
1217 400,
1218 "Use a password with at least 8 characters.",
1219 ));
1220 }
1221 auth.limit(&headers, id)?;
1222 let _slot = auth
1223 .passwords
1224 .try_acquire()
1225 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
1226 let hash = tokio::task::spawn_blocking(move || {
1227 if !phc.is_empty()
1228 && !PasswordHash::new(&phc).is_ok_and(|hash| {
1229 Argon2::default()
1230 .verify_password(current.as_bytes(), &hash)
1231 .is_ok()
1232 })
1233 {
1234 return Err(Error::new(
1235 401,
1236 "Your current password doesn't match. Try again.",
1237 ));
1238 }
1239 password_hash(&password)
1240 })
1241 .await??;
1242 {
1243 let mut db = auth.db.lock().unwrap();
1244 let transaction = db.transaction()?;
1245 let mut profile = self::user(&transaction, id)?;
1246 if profile["enabled"] != true {
1247 return Err(Error::new(403, "This account is disabled."));
1248 }
1249 set_password(&transaction, id, &hash)?;
1250 profile["requiredActions"] = json!(
1251 array(&user["requiredActions"])
1252 .iter()
1253 .filter(|v| **v != "UPDATE_PASSWORD")
1254 .collect::<Vec<_>>()
1255 );
1256 save_user(&transaction, id, profile)?;
1257 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
1258 transaction.commit()?;
1259 }
1260 users::revoke_connections(&app, id)?;
1261 return Ok((
1262 [(
1263 "set-cookie",
1264 auth.create_session(id, "dashboard", &headers, None)?,
1265 )],
1266 StatusCode::NO_CONTENT,
1267 )
1268 .into_response());
1269 }
1270 Err(Error::new(404, "No account action here."))
1271}
1272
1273fn passkeys(db: &Connection, id: &str) -> Result<Vec<Passkey>> {
1274 let mut statement = db
1275 .prepare("SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'")?;
1276 statement
1277 .query_map([id], |r| r.get::<_, String>(0))?
1278 .map(|data| {
1279 let value: Value = serde_json::from_str(&data?)?;
1280 Ok(serde_json::from_value(value["passkey"].clone())?)
1281 })
1282 .collect()
1283}