| 1 | use crate::*; |
| 2 | use axum::extract::{FromRequest, Multipart}; |
| 3 | use futures::{StreamExt, stream}; |
| 4 | |
| 5 | async 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 | } |
| 10 | async fn get(path: &str) -> Result<Value> { |
| 11 | Ok(call(path, Method::GET, None).await?["body"].take()) |
| 12 | } |
| 13 | async 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 | } |
| 36 | async 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 | } |
| 60 | async 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 | } |
| 67 | async 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 | } |
| 83 | fn 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 | } |
| 95 | fn 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 | } |
| 151 | fn 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 | |
| 158 | pub 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 | } |
| 309 | async 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 | } |
| 323 | pub 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 | } |
| 357 | fn 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 | } |
| 366 | pub 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 | } |
| 392 | pub 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 | } |