forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.7 KiB
1//! Page: first-run setup. Creates the initial admin while the user table is empty.
2
3use forge_core::AuditAction;
4use forge_core::names::validate_username;
5use serde::Deserialize;
6use topcoat::Result;
7use topcoat::context::{Cx, app_context};
8use topcoat::router::content::Form;
9use topcoat::router::error::{forbidden, not_found, see_other};
10use topcoat::router::page;
11use topcoat::view::{View, component, view};
12
13use crate::audit::{self, Actor, Target};
14use crate::auth::{check_pre_csrf, clear_pre_csrf, hash_password_async, login, pre_csrf_token};
15use crate::i18n::{self, Key, Lang, password_too_long, password_too_short, t};
16use crate::state::AppState;
17use crate::views::{alert, shell, user_facing_message};
18
19const MIN_PASSWORD_CHARS: usize = 12;
20const MAX_PASSWORD_BYTES: usize = 1024;
21
22#[derive(Deserialize)]
23struct SetupForm {
24 #[serde(default)]
25 username: String,
26 #[serde(default)]
27 email: String,
28 #[serde(default)]
29 password: String,
30 #[serde(default)]
31 confirm: String,
32 #[serde(default)]
33 precsrf: String,
34}
35
36#[component]
37async fn setup_form(
38 lang: Lang,
39 username: &str,
40 email: &str,
41 precsrf: &str,
42 error: Option<String>,
43) -> Result<impl View> {
44 Ok(view! {
45 <section class="card narrow">
46 <h1>(t(lang, Key::WelcomeToForge))</h1>
47 <p class="muted">(t(lang, Key::SetupSub))</p>
48 if let Some(message) = error {
49 alert(message: message.as_str())
50 }
51 <form data-hard="true" method="post" action="/setup" class="stack">
52 <input type="hidden" name="precsrf" value=(precsrf)>
53 <label>
54 <span>(t(lang, Key::Username))</span>
55 <input type="text" name="username" value=(username) autocomplete="username" required=(true)>
56 </label>
57 <label>
58 <span>(t(lang, Key::EmailOptional))</span>
59 <input type="email" name="email" value=(email) autocomplete="email">
60 </label>
61 <label>
62 <span>(t(lang, Key::Password))</span>
63 <input type="password" name="password" autocomplete="new-password" required=(true)>
64 <small class="muted">(t(lang, Key::AtLeast12Chars))</small>
65 </label>
66 <label>
67 <span>(t(lang, Key::ConfirmPassword))</span>
68 <input type="password" name="confirm" autocomplete="new-password" required=(true)>
69 </label>
70 <button type="submit" class="primary">(t(lang, Key::CreateAdmin))</button>
71 </form>
72 </section>
73 })
74}
75
76#[page("/setup")]
77async fn setup_page(cx: &Cx) -> Result<impl View> {
78 let state = app_context::<AppState>(cx);
79 if state.store.user_count().await? != 0 {
80 return Err(not_found().into());
81 }
82 let precsrf = pre_csrf_token(cx);
83 let lang = i18n::lang(cx, None);
84 Ok(view! {
85 shell(title: t(lang, Key::Setup), viewer: None, setup_form(lang: lang, username: "", email: "", precsrf: precsrf.as_str(), error: None))
86 })
87}
88
89#[page(POST "/setup")]
90async fn setup_submit(cx: &Cx, Form(form): Form<SetupForm>) -> Result<impl View> {
91 // Login CSRF: the pre-session token must match the cookie before anything else runs.
92 if !check_pre_csrf(cx, &form.precsrf) {
93 return Err(forbidden().into());
94 }
95 let state = app_context::<AppState>(cx);
96 if state.store.user_count().await? != 0 {
97 return Err(not_found().into());
98 }
99
100 let lang = i18n::lang(cx, None);
101 let problem = match validate_username(&form.username) {
102 Err(e) => user_facing_message(&e),
103 Ok(_) if form.password.chars().count() < MIN_PASSWORD_CHARS => {
104 Some(password_too_short(lang, MIN_PASSWORD_CHARS))
105 }
106 Ok(_) if form.password.len() > MAX_PASSWORD_BYTES => {
107 Some(password_too_long(lang, MAX_PASSWORD_BYTES))
108 }
109 Ok(_) if form.password != form.confirm => Some(t(lang, Key::ErrPasswordsDiffer).to_owned()),
110 Ok(_) => None,
111 };
112 if let Some(message) = problem {
113 let (username, email) = (form.username.clone(), form.email.clone());
114 let precsrf = form.precsrf.clone();
115 return Ok(view! {
116 shell(title: t(lang, Key::Setup), viewer: None, setup_form(lang: lang, username: username.as_str(), email: email.as_str(), precsrf: precsrf.as_str(), error: Some(message)))
117 });
118 }
119
120 let (username, _) = validate_username(&form.username)?;
121 let email = form.email.trim();
122 let email = (!email.is_empty()).then_some(email);
123 let hash = hash_password_async(form.password.clone()).await?;
124 // Atomic check-and-insert: if another request won the race, this is a 404 like the GET.
125 let Some(user) = state
126 .store
127 .create_first_admin(&username, email, &hash)
128 .await?
129 else {
130 return Err(not_found().into());
131 };
132 if let Some(address) = email {
133 // The first admin confirms the address like anyone else. Nothing waits on the mail.
134 crate::mail::account::send_verify(state, &user, address).await?;
135 }
136 audit::record(
137 &state.store,
138 Some(cx),
139 Actor::User(&user),
140 AuditAction::UserCreated,
141 Target {
142 kind: "user",
143 id: &user.id,
144 name: &user.username,
145 },
146 serde_json::json!({ "setup": true, "admin": true }),
147 )
148 .await;
149 login(cx, &user).await?;
150 clear_pre_csrf(cx);
151 Err(see_other("/").into())
152}
153
154#[cfg(test)]
155mod tests {
156 use std::path::Path;
157
158 use topcoat::router::request::Request;
159 use topcoat::router::{Body, Router, to_bytes};
160
161 use crate::state::AppState;
162
163 async fn app(dir: &Path) -> (Router, AppState) {
164 let state = crate::test_support::test_state(dir).await;
165 (crate::test_support::app_router(state.clone()), state)
166 }
167
168 async fn send(
169 router: &Router,
170 method: &str,
171 uri: &str,
172 cookie: Option<&str>,
173 form: Option<&str>,
174 ) -> (u16, http::HeaderMap, String) {
175 let mut builder = Request::builder().method(method).uri(uri);
176 if let Some(c) = cookie {
177 builder = builder.header("cookie", c);
178 }
179 let body = match form {
180 Some(f) => {
181 builder = builder.header("content-type", "application/x-www-form-urlencoded");
182 Body::from(f.to_owned())
183 }
184 None => Body::empty(),
185 };
186 let res = router.handle(builder.body(body).unwrap()).await;
187 let status = res.status().as_u16();
188 let headers = res.headers().clone();
189 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
190 (
191 status,
192 headers,
193 String::from_utf8_lossy(&bytes).into_owned(),
194 )
195 }
196
197 /// The value of a hidden `<input name=... value=...>` field.
198 fn field_from(html: &str, name: &str) -> String {
199 let marker = format!("name=\"{name}\" value=\"");
200 let start = html.find(&marker).unwrap_or_else(|| panic!("{name} field")) + marker.len();
201 let end = html[start..].find('"').unwrap() + start;
202 html[start..end].to_owned()
203 }
204
205 /// The `name=value` pair of a named Set-Cookie header, if present.
206 fn cookie_pair(headers: &http::HeaderMap, name: &str) -> Option<String> {
207 headers
208 .get_all("set-cookie")
209 .iter()
210 .filter_map(|v| v.to_str().ok())
211 .find(|raw| raw.starts_with(name))
212 .map(|raw| raw.split(';').next().unwrap().to_owned())
213 }
214
215 const PRECSRF_COOKIE: &str = "__Host-forge_precsrf";
216
217 #[tokio::test]
218 async fn setup_login_repo_flow_escapes_and_enforces_csrf() {
219 let tmp = tempfile::tempdir().expect("tempdir");
220 let (router, state) = app(tmp.path()).await;
221
222 // Setup is open while there are no users, and issues a pre-session CSRF cookie + field.
223 let (status, headers, html) = send(&router, "GET", "/setup", None, None).await;
224 assert_eq!(status, 200);
225 assert!(html.contains("Welcome to forge"));
226 assert!(
227 headers["content-security-policy"]
228 .to_str()
229 .unwrap()
230 .contains("frame-ancestors 'none'")
231 );
232 let precsrf_cookie = cookie_pair(&headers, PRECSRF_COOKIE).expect("precsrf cookie");
233 let precsrf = field_from(&html, "precsrf");
234 assert_eq!(precsrf_cookie, format!("{PRECSRF_COOKIE}={precsrf}"));
235 let raw_precsrf = headers["set-cookie"].to_str().unwrap();
236 assert!(raw_precsrf.contains("HttpOnly") && raw_precsrf.contains("SameSite=Strict"));
237 assert!(raw_precsrf.contains("Max-Age=3600") && raw_precsrf.contains("Secure"));
238
239 // Without the matching token, setup is refused and creates nothing.
240 let creds =
241 "username=Alice&email=&password=correct-horse-battery&confirm=correct-horse-battery";
242 let (status, _, _) = send(
243 &router,
244 "POST",
245 "/setup",
246 Some(&precsrf_cookie),
247 Some(creds),
248 )
249 .await;
250 assert_eq!(status, 403);
251 let (status, _, _) = send(
252 &router,
253 "POST",
254 "/setup",
255 None,
256 Some(&format!("{creds}&precsrf={precsrf}")),
257 )
258 .await;
259 assert_eq!(status, 403);
260 assert_eq!(state.store.user_count().await.unwrap(), 0);
261
262 let (status, headers, body) = send(
263 &router,
264 "POST",
265 "/setup",
266 Some(&precsrf_cookie),
267 Some(&format!("{creds}&precsrf={precsrf}")),
268 )
269 .await;
270 assert_eq!(
271 status,
272 303,
273 "{}",
274 body.split("class=\"alert\"").nth(1).unwrap_or("no alert")
275 );
276 assert_eq!(headers["location"], "/");
277 let cookie = cookie_pair(&headers, "__Host-forge_session=").expect("session cookie");
278 let set_cookie = headers
279 .get_all("set-cookie")
280 .iter()
281 .filter_map(|v| v.to_str().ok())
282 .find(|v| v.starts_with("__Host-forge_session="))
283 .expect("session set-cookie");
284 assert!(
285 set_cookie.contains("HttpOnly")
286 && set_cookie.contains("Secure")
287 && set_cookie.contains("SameSite=Lax")
288 );
289
290 // Setup closes once a user exists.
291 let (status, _, _) = send(&router, "GET", "/setup", None, None).await;
292 assert_eq!(status, 404);
293
294 // The pre-session cookie is cleared once setup succeeds.
295 assert!(
296 cookie_pair(&headers, PRECSRF_COOKIE)
297 .is_some_and(|p| p == format!("{PRECSRF_COOKIE}="))
298 );
299
300 // The session cookie authenticates the dashboard.
301 let (status, _, home) = send(&router, "GET", "/", Some(&cookie), None).await;
302 assert_eq!(status, 200);
303 assert!(home.contains("Your repositories"));
304 let csrf = field_from(&home, "csrf");
305
306 // Wrong CSRF token is refused.
307 let (status, _, _) = send(
308 &router,
309 "POST",
310 "/new",
311 Some(&cookie),
312 Some("csrf=nope&name=site"),
313 )
314 .await;
315 assert_eq!(status, 403);
316
317 // Create a repo whose description is markup. It must come back escaped.
318 let description = "%3Cscript%3Ealert(1)%3C%2Fscript%3E";
319 let form = format!(
320 "csrf={csrf}&name=site&description={description}&visibility=public&default_branch=main"
321 );
322 let (status, headers, _) = send(&router, "POST", "/new", Some(&cookie), Some(&form)).await;
323 assert_eq!(status, 303);
324 let location = headers["location"].to_str().unwrap().to_owned();
325 assert!(location.ends_with("/site"), "location {location}");
326
327 let owner = state
328 .store
329 .user_by_username("alice")
330 .await
331 .unwrap()
332 .unwrap();
333 let repo = state
334 .store
335 .repo_by_owner_and_name("alice", "site")
336 .await
337 .unwrap()
338 .unwrap();
339 assert_eq!(repo.owner_id, owner.id);
340 assert_eq!(repo.description, "<script>alert(1)</script>");
341
342 // The public repo page shows the escaped description and both clone URLs.
343 let (status, _, page) = send(&router, "GET", &location, None, None).await;
344 assert_eq!(status, 200);
345 assert!(page.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
346 assert!(!page.contains("<script>alert(1)"));
347 assert!(page.contains("https://forge.test/Alice/site.git"));
348 assert!(page.contains("git@forge.test:Alice/site.git"));
349 assert!(page.contains("This repository is empty"));
350
351 // Anonymous visitors cannot see the admin page.
352 let (status, _, _) = send(&router, "GET", "/admin/users", None, None).await;
353 assert_eq!(status, 404);
354
355 // Logging out with a bad token is refused; with the right token the cookie is cleared.
356 let (status, _, _) =
357 send(&router, "POST", "/logout", Some(&cookie), Some("csrf=nope")).await;
358 assert_eq!(status, 403);
359 let (status, headers, _) = send(
360 &router,
361 "POST",
362 "/logout",
363 Some(&cookie),
364 Some(&format!("csrf={csrf}")),
365 )
366 .await;
367 assert_eq!(status, 303);
368 assert!(
369 headers["set-cookie"]
370 .to_str()
371 .unwrap()
372 .contains("__Host-forge_session=")
373 );
374 let (status, _, _) = send(&router, "GET", "/", Some(&cookie), None).await;
375 assert_eq!(status, 200);
376
377 // Login is protected the same way: a mismatched pre-session token is refused.
378 let (status, headers, html) = send(&router, "GET", "/login", None, None).await;
379 assert_eq!(status, 200);
380 let login_cookie = cookie_pair(&headers, PRECSRF_COOKIE).expect("login precsrf cookie");
381 let login_token = field_from(&html, "precsrf");
382 let bad = "username=alice&password=correct-horse-battery&precsrf=nope";
383 let (status, _, _) = send(&router, "POST", "/login", Some(&login_cookie), Some(bad)).await;
384 assert_eq!(status, 403);
385
386 // With the matching token the login succeeds and the pre-session cookie is cleared.
387 let good = format!("username=alice&password=correct-horse-battery&precsrf={login_token}");
388 let (status, headers, _) =
389 send(&router, "POST", "/login", Some(&login_cookie), Some(&good)).await;
390 assert_eq!(status, 303);
391 assert!(cookie_pair(&headers, "__Host-forge_session=").is_some());
392 assert!(
393 cookie_pair(&headers, PRECSRF_COOKIE)
394 .is_some_and(|p| p == format!("{PRECSRF_COOKIE}="))
395 );
396 }
397}