1use crate::*;
2use axum::extract::{FromRequest, Multipart};
3use futures::{StreamExt, stream};
4
5async fn call(path: &str, method: Method, body: Option<Value>) -> Result<Value> {
6 host::call(json!({"operation":"iam.request", "path":path,
7 "method":method.as_str(), "body":body}))
8 .await
9}
10async fn get(path: &str) -> Result<Value> {
11 Ok(call(path, Method::GET, None).await?["body"].take())
12}
13async fn list() -> Result<Value> {
14 let list = get("/users?max=1000").await?;
15 let found = stream::iter(array(&list).iter().cloned())
16 .map(|mut user| async move {
17 user["groups"] = get(&format!(
18 "/users/{}/role-mappings/realm",
19 encoded(string(&user["id"]))
20 ))
21 .await?;
22 for key in ["email", "firstName", "lastName"] {
23 if user.get(key).is_none() {
24 user[key] = Value::Null;
25 }
26 }
27 Ok::<_, Error>(user)
28 })
29 .buffered(4)
30 .collect::<Vec<_>>()
31 .await
32 .into_iter()
33 .collect::<Result<Vec<_>>>()?;
34 Ok(json!(found))
35}
36async fn directory(app: Arc<App>) -> Result<Arc<Document>> {
37 app.cache
38 .get(
39 "users".into(),
40 Duration::from_secs(300),
41 move || async move {
42 let (list, groups) = tokio::try_join!(list(), get("/roles"))?;
43 let users = stream::iter(array(&list).iter().cloned())
44 .map(|mut user| async move {
45 user["sessions"] =
46 get(&format!("/users/{}/sessions", encoded(string(&user["id"]))))
47 .await?;
48 Ok::<_, Error>(user)
49 })
50 .buffered(4)
51 .collect::<Vec<_>>()
52 .await
53 .into_iter()
54 .collect::<Result<Vec<_>>>()?;
55 Ok(json!({"users":users,"groups":groups}))
56 },
57 )
58 .await
59}
60async fn found(id: &str) -> Result<Value> {
61 array(&list().await?)
62 .iter()
63 .find(|u| u["id"] == id)
64 .cloned()
65 .ok_or_else(|| Error::new(404, "No user with that id"))
66}
67async fn spare(me: &Value, id: &str, remove_role: Option<&str>) -> Result<()> {
68 let user = found(id).await?;
69 if user["username"] == me["name"] {
70 let keeps_admin = remove_role.is_some()
71 && array(&user["groups"])
72 .iter()
73 .any(|g| g["name"] == "infra-admin" && g["name"] != remove_role.unwrap());
74 if !keeps_admin {
75 return Err(Error::new(
76 400,
77 "That would lock you out of this page. Sign in as another admin to change it.",
78 ));
79 }
80 }
81 Ok(())
82}
83fn uuid(id: &str) -> Result<()> {
84 if regex::Regex::new(
85 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
86 )
87 .unwrap()
88 .is_match(id)
89 {
90 Ok(())
91 } else {
92 Err(Error::new(400, "No user with that id"))
93 }
94}
95fn profile(body: &Value, full: bool) -> Result<Value> {
96 let mut profile = serde_json::Map::new();
97 let username_pattern = regex::Regex::new(r"^[a-z0-9][a-z0-9._@-]*$").unwrap();
98 for key in [
99 "username",
100 "email",
101 "firstName",
102 "lastName",
103 "enabled",
104 "emailVerified",
105 "requiredActions",
106 ] {
107 let Some(value) = body.get(key) else {
108 if full && ["username", "email", "firstName", "lastName"].contains(&key) {
109 return Err(Error::new(400, "Enter a user profile."));
110 }
111 continue;
112 };
113 let value = match key {
114 "username" => {
115 let v = string(value).trim().to_lowercase();
116 if !username_pattern.is_match(&v) {
117 return Err(Error::new(
118 400,
119 "Usernames use lowercase letters, digits, dots, dashes, and @",
120 ));
121 }
122 json!(v)
123 }
124 "email" | "firstName" | "lastName" => {
125 let v = value
126 .as_str()
127 .ok_or_else(|| Error::new(400, "Enter a name or email address."))?
128 .trim();
129 if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) {
130 return Err(Error::new(400, "Enter a full email address"));
131 }
132 if v.is_empty() { Value::Null } else { json!(v) }
133 }
134 "enabled" | "emailVerified" => {
135 if !value.is_boolean() {
136 return Err(Error::new(400, "Invalid profile."));
137 }
138 value.clone()
139 }
140 _ => {
141 if !value.is_array() || array(value).iter().any(|v| !v.is_string()) {
142 return Err(Error::new(400, "Invalid required actions."));
143 }
144 value.clone()
145 }
146 };
147 profile.insert(key.into(), value);
148 }
149 Ok(Value::Object(profile))
150}
151fn password(value: &Value) -> Result<&str> {
152 value
153 .as_str()
154 .filter(|s| s.chars().count() >= 8)
155 .ok_or_else(|| Error::new(400, "Use at least 8 characters"))
156}
157
158pub async fn route(
159 app: Arc<App>,
160 method: &Method,
161 parts: &[&str],
162 me: &Value,
163 body: Value,
164) -> Result<Response> {
165 if parts.is_empty() && method == Method::GET {
166 return Ok(directory(app).await?.response());
167 }
168 if let Some(id) = parts.first() {
169 uuid(id)?;
170 }
171 let value = match parts {
172 [] if method == Method::POST => {
173 let mut profile = profile(&body["profile"], true)?;
174 let setup = string(&body["setup"]["kind"]);
175 if setup == "email" && profile["email"].is_null() {
176 return Err(Error::new(400, "Add an email address to send a setup link"));
177 }
178 if setup != "email" && setup != "password" {
179 return Err(Error::new(400, "Choose how this user signs in."));
180 }
181 if setup == "password" {
182 password(&body["setup"]["password"])?;
183 }
184 if !body["groups"].is_array() {
185 return Err(Error::new(400, "Choose groups."));
186 }
187 for group in array(&body["groups"]) {
188 uuid(string(group))?;
189 }
190 let actions = if setup == "email" {
191 json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"])
192 } else {
193 json!([])
194 };
195 profile["enabled"] = json!(true);
196 profile["emailVerified"] = json!(false);
197 profile["requiredActions"] = actions.clone();
198 let response = call("/users", Method::POST, Some(profile)).await?;
199 let id = response["id"]
200 .as_str()
201 .ok_or_else(|| {
202 Error::new(
203 502,
204 "Keycloak created the user but did not return its ID. Reload the page.",
205 )
206 })?
207 .to_owned();
208 for group in array(&body["groups"]) {
209 change_role(&id, string(group), Method::POST).await?;
210 }
211 if setup == "email" {
212 call(
213 &format!("/users/{id}/execute-actions-email"),
214 Method::PUT,
215 Some(actions),
216 )
217 .await?;
218 } else {
219 call(&format!("/users/{id}/reset-password"),Method::PUT,Some(json!({"type":"password","value":body["setup"]["password"],"temporary":true}))).await?;
220 }
221 app.cache.invalidate("users");
222 return Ok((StatusCode::CREATED, axum::Json(json!({"id":id}))).into_response());
223 }
224 [id] if method == Method::PATCH => {
225 let profile = profile(&body, false)?;
226 if profile["enabled"] == false {
227 spare(me, id, None).await?;
228 }
229 call(&format!("/users/{id}"), Method::PUT, Some(profile)).await?;
230 Value::Null
231 }
232 [id] if method == Method::DELETE => {
233 spare(me, id, None).await?;
234 call(&format!("/users/{id}"), Method::DELETE, None).await?;
235 Value::Null
236 }
237 [id, "groups", group] if method == Method::PUT || method == Method::DELETE => {
238 uuid(group)?;
239 if method == Method::DELETE {
240 let groups = get("/roles").await?;
241 let name = array(&groups)
242 .iter()
243 .find(|g| g["id"] == *group)
244 .map(|g| string(&g["name"]));
245 spare(me, id, name).await?;
246 }
247 change_role(
248 id,
249 group,
250 if method == Method::PUT {
251 Method::POST
252 } else {
253 Method::DELETE
254 },
255 )
256 .await?;
257 Value::Null
258 }
259 [id, "credentials"] if method == Method::GET => {
260 get(&format!("/users/{id}/credentials")).await?
261 }
262 [id, "logout"] if method == Method::POST => {
263 call(&format!("/users/{id}/logout"), Method::POST, None).await?;
264 Value::Null
265 }
266 [id, "actions-email"] if method == Method::POST => {
267 let user = found(id).await?;
268 if user["email"].is_null() {
269 return Err(Error::new(400, "Add an email address first"));
270 }
271 if array(&user["requiredActions"]).is_empty() {
272 return Err(Error::new(400, "Pick at least one required action first"));
273 }
274 call(
275 &format!("/users/{id}/execute-actions-email"),
276 Method::PUT,
277 Some(user["requiredActions"].clone()),
278 )
279 .await?;
280 Value::Null
281 }
282 [id, "password"] if method == Method::PUT => {
283 let password = password(&body["password"])?;
284 if !body["temporary"].is_boolean() {
285 return Err(Error::new(
286 400,
287 "Choose whether this password is temporary.",
288 ));
289 }
290 call(
291 &format!("/users/{id}/reset-password"),
292 Method::PUT,
293 Some(json!({"type":"password","value":password,"temporary":body["temporary"]})),
294 )
295 .await?;
296 Value::Null
297 }
298 _ => return Err(Error::new(404, "Not Found")),
299 };
300 if method != Method::GET {
301 app.cache.invalidate("users");
302 }
303 Ok(if value.is_null() {
304 StatusCode::NO_CONTENT.into_response()
305 } else {
306 Document::new(value).response()
307 })
308}
309async fn change_role(id: &str, group: &str, method: Method) -> Result<()> {
310 let roles = get("/roles").await?;
311 let role = array(&roles)
312 .iter()
313 .find(|g| g["id"] == group)
314 .ok_or_else(|| Error::new(404, "That role is no longer available. Reload the page."))?;
315 call(
316 &format!("/users/{id}/role-mappings/realm"),
317 method,
318 Some(json!([role])),
319 )
320 .await?;
321 Ok(())
322}
323pub async fn self_user(app: &App, me: &Value) -> Result<Value> {
324 let name = string(&me["name"]).to_owned();
325 let value = app
326 .cache
327 .coalesce(format!("identity:{name}"), move || async move {
328 let found = get(&format!("/users?username={}&exact=true", encoded(&name))).await?;
329 let mut user = array(&found)
330 .iter()
331 .find(|u| u["username"] == name)
332 .cloned()
333 .ok_or_else(|| {
334 Error::new(
335 404,
336 format!(
337 "Keycloak has no user named {}. Sign out, then sign in again.",
338 name
339 ),
340 )
341 })?;
342 user["groups"] = get(&format!(
343 "/users/{}/role-mappings/realm",
344 encoded(string(&user["id"]))
345 ))
346 .await?;
347 for key in ["email", "firstName", "lastName"] {
348 if user.get(key).is_none() {
349 user[key] = Value::Null;
350 }
351 }
352 Ok(user)
353 })
354 .await?;
355 Ok(value.value.clone())
356}
357fn image_type(bytes: &[u8]) -> Option<&'static str> {
358 if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
359 Some("image/webp")
360 } else if bytes.get(1..4) == Some(b"PNG") {
361 Some("image/png")
362 } else {
363 None
364 }
365}
366pub async fn picture(app: &App, parts: &[&str]) -> Result<Response> {
367 let ["account", "pictures", id] = parts else {
368 return Err(Error::new(404, "No picture here"));
369 };
370 if id.is_empty()
371 || !id
372 .bytes()
373 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
374 {
375 return Err(Error::new(404, "No picture here"));
376 }
377 let bytes = tokio::fs::read(app.data.join("pictures").join(id))
378 .await
379 .map_err(|_| Error::new(404, "No picture here"))?;
380 Ok((
381 [
382 (
383 "content-type",
384 image_type(&bytes).unwrap_or("application/octet-stream"),
385 ),
386 ("cache-control", "max-age=86400"),
387 ],
388 bytes,
389 )
390 .into_response())
391}
392pub async fn account(
393 app: Arc<App>,
394 request: Request,
395 parts: &[&str],
396 me: &Value,
397) -> Result<Response> {
398 let method = request.method().clone();
399 let headers = request.headers();
400 let origin = format!(
401 "{}://{}",
402 headers
403 .get("X-Forwarded-Proto")
404 .and_then(|h| h.to_str().ok())
405 .unwrap_or("http"),
406 headers
407 .get("X-Forwarded-Host")
408 .or(headers.get("Host"))
409 .and_then(|h| h.to_str().ok())
410 .unwrap_or("localhost")
411 );
412 let realm = || {
413 std::env::var("STUDIO_KEYCLOAK_URL")
414 .map(|s| format!("{s}/realms/master"))
415 .map_err(|_| {
416 Error::new(
417 501,
418 "Keycloak isn't connected to this home server. Connect it, then retry.",
419 )
420 })
421 };
422 if parts == ["sign-out"] && method == Method::GET {
423 let logout = format!(
424 "{}/protocol/openid-connect/logout?{}",
425 realm()?,
426 params(&[
427 ("client_id", "forward-auth".into()),
428 ("post_logout_redirect_uri", format!("{origin}/"))
429 ])
430 );
431 return Ok((
432 StatusCode::FOUND,
433 [(
434 "location",
435 format!("/snow.oauth2/sign_out?{}", params(&[("rd", logout)])),
436 )],
437 )
438 .into_response());
439 }
440 if let ["actions", action] = parts {
441 if method != Method::GET
442 || (![
443 "webauthn-register-passwordless",
444 "UPDATE_PASSWORD",
445 "UPDATE_EMAIL",
446 ]
447 .contains(action)
448 && !regex::Regex::new(r"^delete_credential:[\w-]+$")
449 .unwrap()
450 .is_match(action))
451 {
452 return Err(Error::new(
453 400,
454 "Keycloak can't start that action from here",
455 ));
456 }
457 return Ok((
458 StatusCode::FOUND,
459 [(
460 "location",
461 format!(
462 "{}/protocol/openid-connect/auth?{}",
463 realm()?,
464 params(&[
465 ("client_id", "forward-auth".into()),
466 ("redirect_uri", format!("{origin}/account")),
467 ("response_type", "code".into()),
468 ("scope", "openid".into()),
469 ("kc_action", action.to_string())
470 ])
471 ),
472 )],
473 )
474 .into_response());
475 }
476 let mut user = self_user(&app, me).await?;
477 let id = string(&user["id"]).to_owned();
478 let value = match parts {
479 [] if method == Method::GET => {
480 let attributes = user
481 .as_object_mut()
482 .unwrap()
483 .remove("attributes")
484 .unwrap_or(Value::Null);
485 user["picture"] = attributes["picture"][0].clone();
486 user["credentials"] = get(&format!("/users/{id}/credentials")).await?;
487 user["console"] = json!(format!("{}/account", realm()?));
488 user
489 }
490 [] if method == Method::PATCH => {
491 let body: Value = serde_json::from_slice(
492 &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?,
493 )
494 .map_err(|_| Error::new(400, "Invalid profile."))?;
495 let mut value = serde_json::Map::new();
496 for key in ["firstName", "lastName"] {
497 if let Some(v) = body.get(key) {
498 let v = v
499 .as_str()
500 .ok_or_else(|| Error::new(400, "Enter a name."))?
501 .trim();
502 value.insert(
503 key.into(),
504 if v.is_empty() { Value::Null } else { json!(v) },
505 );
506 }
507 }
508 call(
509 &format!("/users/{id}"),
510 Method::PUT,
511 Some(Value::Object(value)),
512 )
513 .await?;
514 Value::Null
515 }
516 ["verify-email"] if method == Method::POST => {
517 call(
518 &format!("/users/{id}/execute-actions-email"),
519 Method::PUT,
520 Some(json!(["VERIFY_EMAIL"])),
521 )
522 .await?;
523 Value::Null
524 }
525 ["picture"] if method == Method::PUT => {
526 let (parts, body) = request.into_parts();
527 let bytes = axum::body::to_bytes(body, 512 * 1024)
528 .await
529 .map_err(|_| Error::new(413, "That picture is over 512 KB. Pick a smaller one."))?;
530 let request = Request::from_parts(parts, axum::body::Body::from(bytes));
531 let mut multipart = Multipart::from_request(request, &())
532 .await
533 .map_err(|_| Error::new(400, "Pick a picture to upload"))?;
534 let mut picture = None;
535 while let Some(field) = multipart
536 .next_field()
537 .await
538 .map_err(|_| Error::new(400, "Pick a picture to upload"))?
539 {
540 if field.name() == Some("picture") && field.file_name().is_some() {
541 picture = Some(
542 field
543 .bytes()
544 .await
545 .map_err(|_| Error::new(400, "Pick a picture to upload"))?,
546 );
547 break;
548 }
549 }
550 let bytes = picture.ok_or_else(|| Error::new(400, "Pick a picture to upload"))?;
551 if image_type(&bytes).is_none() {
552 return Err(Error::new(415, "Upload a WebP or PNG picture"));
553 }
554 tokio::fs::create_dir_all(app.data.join("pictures")).await?;
555 tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?;
556 let picture = format!(
557 "{origin}/api/account/pictures/{id}?v={}",
558 (now() * 1000.0) as u64
559 );
560 call(
561 &format!("/users/{id}"),
562 Method::PUT,
563 Some(json!({"attributes":{"picture":[picture]}})),
564 )
565 .await?;
566 json!({"picture":picture})
567 }
568 ["picture"] if method == Method::DELETE => {
569 call(
570 &format!("/users/{id}"),
571 Method::PUT,
572 Some(json!({"attributes":{"picture":null}})),
573 )
574 .await?;
575 let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await;
576 Value::Null
577 }
578 _ => return Err(Error::new(404, "Not Found")),
579 };
580 if method != Method::GET {
581 app.cache.invalidate("users");
582 }
583 Ok(if value.is_null() {
584 StatusCode::NO_CONTENT.into_response()
585 } else {
586 Document::new(value).response()
587 })
588}