Skip to main content

reinhardt_admin/server/
login.rs

1//! Admin Login Server Function
2//!
3//! Provides JWT-based authentication for the admin WASM SPA.
4//!
5//! On successful login, the JWT token is set as an HTTP-Only cookie
6//! (`reinhardt_admin_token`) instead of being returned in the response body.
7//! This prevents XSS attacks from stealing the token.
8
9use crate::adapters::LoginResponse;
10use reinhardt_pages::server_fn::{ServerFnError, server_fn};
11
12#[cfg(server)]
13use super::admin_auth::AdminLoginAuthenticator;
14#[cfg(server)]
15use super::security::{build_admin_auth_cookie, require_csrf_header, require_csrf_token};
16#[cfg(server)]
17use crate::adapters::{AdminDatabase, AdminSite};
18#[cfg(server)]
19use crate::core::{AdminDatabaseKey, AdminSiteKey};
20#[cfg(server)]
21use reinhardt_auth::JwtAuth;
22#[cfg(server)]
23use reinhardt_di::Depends;
24#[cfg(server)]
25use reinhardt_pages::server_fn::ServerFnRequest;
26
27/// Authenticate an admin user and set a JWT cookie.
28///
29/// This server function validates the provided credentials against the
30/// database and, on success, sets the JWT token as an HTTP-Only cookie.
31/// The browser automatically attaches this cookie to subsequent requests,
32/// eliminating the need for sessionStorage token management.
33///
34/// # Authentication Flow
35///
36/// 1. Validate CSRF token (double-submit cookie pattern)
37/// 2. Look up user by username in the database
38/// 3. Verify password using Argon2id
39/// 4. Check that the user is active and has staff privileges
40/// 5. Generate JWT token and set as HTTP-Only cookie
41/// 6. Return user info (without the token) in the response body
42///
43/// # Security
44///
45/// - CSRF protection via the double-submit cookie pattern
46/// - Password verification uses constant-time Argon2id comparison
47/// - Generic error messages prevent username enumeration
48/// - Only active staff users can obtain tokens
49/// - JWT stored in HTTP-Only cookie (not accessible via JavaScript)
50/// - `SameSite=Strict` prevents cross-origin cookie sending
51///
52/// # Example
53///
54/// ```ignore
55/// use reinhardt_admin::server::login::admin_login;
56///
57/// // Client-side usage (automatically generates HTTP request)
58/// let response = admin_login(
59///     "admin".to_string(),
60///     "password123".to_string(),
61///     csrf_token.to_string(),
62/// ).await?;
63/// // No need to store token — browser handles it via cookie
64/// ```
65#[server_fn]
66pub async fn admin_login(
67	username: String,
68	password: String,
69	csrf_token: String,
70	#[inject] http_request: ServerFnRequest,
71	#[inject] db: Depends<AdminDatabaseKey, AdminDatabase>,
72	#[inject] site: Depends<AdminSiteKey, AdminSite>,
73	#[inject] authenticator: AdminLoginAuthenticator,
74) -> Result<LoginResponse, ServerFnError> {
75	// Validate CSRF token
76	require_csrf_token(&csrf_token, &http_request.inner().headers)?;
77
78	complete_admin_login(username, password, http_request, db, site, authenticator).await
79}
80
81/// Authenticate an admin user using CSRF supplied through `X-CSRFToken`.
82#[server_fn]
83pub async fn admin_login_with_header(
84	username: String,
85	password: String,
86	#[inject] http_request: ServerFnRequest,
87	#[inject] db: Depends<AdminDatabaseKey, AdminDatabase>,
88	#[inject] site: Depends<AdminSiteKey, AdminSite>,
89	#[inject] authenticator: AdminLoginAuthenticator,
90) -> Result<LoginResponse, ServerFnError> {
91	require_csrf_header(&http_request.inner().headers)?;
92
93	complete_admin_login(username, password, http_request, db, site, authenticator).await
94}
95
96#[cfg(server)]
97async fn complete_admin_login(
98	username: String,
99	password: String,
100	http_request: ServerFnRequest,
101	db: Depends<AdminDatabaseKey, AdminDatabase>,
102	site: Depends<AdminSiteKey, AdminSite>,
103	authenticator: AdminLoginAuthenticator,
104) -> Result<LoginResponse, ServerFnError> {
105	// Verify JWT secret is configured
106	let jwt_secret = site.jwt_secret().ok_or_else(|| {
107		::tracing::error!("admin_login: JWT secret not configured on AdminSite");
108		ServerFnError::server(500, "Admin login is not configured")
109	})?;
110	let jwt_auth = JwtAuth::new(jwt_secret);
111
112	// Authenticate user (username lookup + password verification + staff check)
113	let user_info = (authenticator.0)(username.clone(), password, db.connection_arc())
114		.await
115		.map_err(|e| {
116			::tracing::warn!(error = ?e, "admin_login: Authentication failed");
117			ServerFnError::server(500, "Internal authentication error")
118		})?;
119
120	let user_info = user_info.ok_or_else(|| {
121		// Generic message to prevent username enumeration
122		ServerFnError::server(401, "Invalid username or password")
123	})?;
124
125	// Generate JWT token
126	let token = jwt_auth
127		.generate_token(
128			user_info.user_id.clone(),
129			user_info.username.clone(),
130			user_info.is_staff,
131			user_info.is_superuser,
132		)
133		.map_err(|e| {
134			::tracing::error!(error = ?e, "admin_login: JWT token generation failed");
135			ServerFnError::server(500, "Token generation failed")
136		})?;
137
138	// Set JWT as HTTP-Only cookie via the shared cookie jar.
139	// The server_fn router (router_ext.rs) reads SharedResponseCookies
140	// and applies them as Set-Cookie response headers.
141	let is_secure = http_request.inner().is_secure;
142	let cookie = build_admin_auth_cookie(&token, is_secure);
143	http_request.add_response_cookie(cookie);
144
145	// Return user info without the token — the browser receives the token
146	// via the Set-Cookie header, not the response body.
147	Ok(LoginResponse {
148		token: String::new(),
149		username: user_info.username,
150		user_id: user_info.user_id,
151		is_staff: user_info.is_staff,
152		is_superuser: user_info.is_superuser,
153	})
154}
155
156#[cfg(all(test, server))]
157mod tests {
158	use super::*;
159
160	#[test]
161	fn test_login_response_serialization() {
162		// Arrange
163		let response = LoginResponse {
164			token: "eyJhbGciOiJIUzI1NiJ9.test".to_string(),
165			username: "admin".to_string(),
166			user_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
167			is_staff: true,
168			is_superuser: false,
169		};
170
171		// Act
172		let json = serde_json::to_string(&response).expect("serialization should succeed");
173		let deserialized: LoginResponse =
174			serde_json::from_str(&json).expect("deserialization should succeed");
175
176		// Assert
177		assert_eq!(deserialized.token, response.token);
178		assert_eq!(deserialized.username, response.username);
179		assert_eq!(deserialized.user_id, response.user_id);
180		assert!(deserialized.is_staff);
181		assert!(!deserialized.is_superuser);
182	}
183}