Microservice to bring 2FA to self hosted PDSes
1use axum::{
2 extract::{Request, State},
3 http::StatusCode,
4 middleware::Next,
5 response::{IntoResponse, Redirect, Response},
6};
7use axum_extra::extract::cookie::SignedCookieJar;
8
9use crate::AppState;
10
11use super::rbac::RbacConfig;
12use super::session;
13
14/// Admin session data injected into request extensions.
15#[derive(Debug, Clone)]
16pub struct AdminSession {
17 pub did: String,
18 pub handle: String,
19 pub roles: Vec<String>,
20}
21
22/// Pre-computed permission flags for template rendering and quick checks.
23#[derive(Debug, Clone)]
24pub struct AdminPermissions {
25 pub can_view_accounts: bool,
26 pub can_manage_takedowns: bool,
27 pub can_delete_account: bool,
28 pub can_reset_password: bool,
29 pub can_create_account: bool,
30 pub can_manage_invites: bool,
31 pub can_create_invite: bool,
32 pub can_send_email: bool,
33 pub can_request_crawl: bool,
34}
35
36impl AdminPermissions {
37 pub fn compute(rbac: &RbacConfig, did: &str) -> Self {
38 Self {
39 can_view_accounts: rbac
40 .can_access_endpoint(did, "com.atproto.admin.getAccountInfo")
41 || rbac.can_access_endpoint(did, "com.atproto.admin.getAccountInfos"),
42 can_manage_takedowns: rbac
43 .can_access_endpoint(did, "com.atproto.admin.updateSubjectStatus"),
44 can_delete_account: rbac
45 .can_access_endpoint(did, "com.atproto.admin.deleteAccount"),
46 can_reset_password: rbac
47 .can_access_endpoint(did, "com.atproto.admin.updateAccountPassword"),
48 can_create_account: rbac
49 .can_access_endpoint(did, "com.atproto.server.createAccount"),
50 can_manage_invites: rbac
51 .can_access_endpoint(did, "com.atproto.admin.getInviteCodes"),
52 can_create_invite: rbac
53 .can_access_endpoint(did, "com.atproto.server.createInviteCode"),
54 can_send_email: rbac.can_access_endpoint(did, "com.atproto.admin.sendEmail"),
55 can_request_crawl: rbac
56 .can_access_endpoint(did, "com.atproto.sync.requestCrawl"),
57 }
58 }
59}
60
61/// Middleware that checks for a valid admin session cookie.
62/// If valid, injects AdminSession and AdminPermissions into request extensions.
63/// If invalid or missing, redirects to /admin/login.
64pub async fn admin_auth_middleware(
65 State(state): State<AppState>,
66 jar: SignedCookieJar,
67 mut req: Request,
68 next: Next,
69) -> Response {
70 let rbac = match &state.admin_rbac_config {
71 Some(rbac) => rbac,
72 None => return StatusCode::NOT_FOUND.into_response(),
73 };
74
75 // Extract session ID from signed cookie
76 let session_id = match jar.get("__gatekeeper_admin_session") {
77 Some(cookie) => cookie.value().to_string(),
78 None => return Redirect::to("/admin/login").into_response(),
79 };
80
81 // Look up session in database
82 let session_row = match session::get_session(&state.pds_gatekeeper_pool, &session_id).await {
83 Ok(Some(row)) => row,
84 Ok(None) => return Redirect::to("/admin/login").into_response(),
85 Err(e) => {
86 tracing::error!("Failed to look up admin session: {}", e);
87 return Redirect::to("/admin/login").into_response();
88 }
89 };
90
91 // Verify the DID is still a valid member
92 if !rbac.is_member(&session_row.did) {
93 return Redirect::to("/admin/login").into_response();
94 }
95
96 let roles = rbac.get_member_roles(&session_row.did);
97 let permissions = AdminPermissions::compute(rbac, &session_row.did);
98
99 let admin_session = AdminSession {
100 did: session_row.did,
101 handle: session_row.handle,
102 roles,
103 };
104
105 req.extensions_mut().insert(admin_session);
106 req.extensions_mut().insert(permissions);
107
108 next.run(req).await
109}