samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
430 lines· 11.9 KB ·Rust
Raw
1//! Sign up, sign in, profile settings and SSH key management.
2
3use std::sync::LazyLock;
4
5use argon2::Argon2;
6use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
7use askama::Template;
8use axum::Form;
9use axum::extract::{Path, Query, State};
10use serde::Deserialize;
11
12use super::ctx::{self, Ctx, SESSION_TTL_SECS};
13use super::{AppError, WebResult, redirect, redirect_flash, render, safe_next, with_cookie};
14use crate::db::{self, SshKey};
15use crate::state::AppState;
16use crate::validate;
17
18/// Verified against when an account does not exist, to keep timing uniform.
19static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| {
20 let salt = SaltString::generate(&mut rand::rngs::OsRng);
21 Argon2::default()
22 .hash_password(b"openhub-dummy-password", &salt)
23 .map(|h| h.to_string())
24 .unwrap_or_default()
25});
26
27pub async fn hash_password(password: String) -> Result<String, AppError> {
28 tokio::task::spawn_blocking(move || {
29 let salt = SaltString::generate(&mut rand::rngs::OsRng);
30 Argon2::default()
31 .hash_password(password.as_bytes(), &salt)
32 .map(|h| h.to_string())
33 .map_err(|e| AppError::Internal(anyhow::anyhow!("hashing password: {e}")))
34 })
35 .await?
36}
37
38pub async fn verify_password(password: String, hash: String) -> bool {
39 tokio::task::spawn_blocking(move || {
40 PasswordHash::new(&hash)
41 .map(|parsed| {
42 Argon2::default()
43 .verify_password(password.as_bytes(), &parsed)
44 .is_ok()
45 })
46 .unwrap_or(false)
47 })
48 .await
49 .unwrap_or(false)
50}
51
52async fn start_session(state: &AppState, user_id: i64, to: &str) -> WebResult {
53 let token = ctx::new_token();
54 db::create_session(
55 &state.db,
56 &ctx::hash_token(&token),
57 user_id,
58 SESSION_TTL_SECS,
59 )
60 .await?;
61 let _ = db::purge_expired_sessions(&state.db).await;
62 Ok(with_cookie(
63 redirect(to),
64 ctx::session_cookie(token, state.config.secure_cookies()),
65 ))
66}
67
68// ---------------------------------------------------------------------------
69// Sign in / sign up
70// ---------------------------------------------------------------------------
71
72#[derive(Template)]
73#[template(path = "login.html")]
74struct LoginPage {
75 ctx: Ctx,
76 login: String,
77 next: String,
78 error: Option<String>,
79 can_register: bool,
80}
81
82#[derive(Deserialize)]
83pub struct NextQuery {
84 next: Option<String>,
85}
86
87async fn can_register(state: &AppState) -> Result<bool, AppError> {
88 Ok(!state.config.disable_registration || db::count_users(&state.db).await? == 0)
89}
90
91pub async fn login_form(
92 State(state): State<AppState>,
93 ctx: Ctx,
94 Query(q): Query<NextQuery>,
95) -> WebResult {
96 if ctx.user.is_some() {
97 return Ok(redirect(&safe_next(q.next.as_deref())));
98 }
99 render(&LoginPage {
100 ctx,
101 login: String::new(),
102 next: safe_next(q.next.as_deref()),
103 error: None,
104 can_register: can_register(&state).await?,
105 })
106}
107
108#[derive(Deserialize)]
109pub struct LoginForm {
110 login: String,
111 password: String,
112 next: Option<String>,
113}
114
115pub async fn login(State(state): State<AppState>, ctx: Ctx, Form(f): Form<LoginForm>) -> WebResult {
116 let next = safe_next(f.next.as_deref());
117 let user = db::user_by_login(&state.db, f.login.trim()).await?;
118 // Always run a verification so response timing does not reveal whether the account exists.
119 let hash = user
120 .as_ref()
121 .map(|u| u.password_hash.clone())
122 .unwrap_or_else(|| DUMMY_HASH.clone());
123 let ok = verify_password(f.password, hash).await;
124 match user {
125 Some(u) if ok => start_session(&state, u.id, &next).await,
126 _ => render(&LoginPage {
127 ctx,
128 login: f.login,
129 next,
130 error: Some("Incorrect username or password.".into()),
131 can_register: can_register(&state).await?,
132 }),
133 }
134}
135
136#[derive(Template)]
137#[template(path = "register.html")]
138struct RegisterPage {
139 ctx: Ctx,
140 username: String,
141 email: String,
142 error: Option<String>,
143 allowed: bool,
144 first_user: bool,
145}
146
147pub async fn register_form(State(state): State<AppState>, ctx: Ctx) -> WebResult {
148 if ctx.user.is_some() {
149 return Ok(redirect("/"));
150 }
151 let users = db::count_users(&state.db).await?;
152 render(&RegisterPage {
153 ctx,
154 username: String::new(),
155 email: String::new(),
156 error: None,
157 allowed: can_register(&state).await?,
158 first_user: users == 0,
159 })
160}
161
162#[derive(Deserialize)]
163pub struct RegisterForm {
164 username: String,
165 email: String,
166 password: String,
167}
168
169pub async fn register(
170 State(state): State<AppState>,
171 ctx: Ctx,
172 Form(f): Form<RegisterForm>,
173) -> WebResult {
174 let users = db::count_users(&state.db).await?;
175 if !can_register(&state).await? {
176 return Err(AppError::Forbidden(
177 "Registration is disabled on this server.".into(),
178 ));
179 }
180 let username = f.username.trim().to_string();
181 let email = f.email.trim().to_string();
182 let mut error = validate::username(&username)
183 .and(validate::email(&email))
184 .and(validate::password(&f.password))
185 .err()
186 .map(str::to_string);
187 if error.is_none() && db::user_by_username(&state.db, &username).await?.is_some() {
188 error = Some("That username is already taken.".into());
189 }
190 if error.is_none() && db::email_taken(&state.db, &email, -1).await? {
191 error = Some("An account with that email already exists.".into());
192 }
193 if let Some(error) = error {
194 return render(&RegisterPage {
195 ctx,
196 username,
197 email,
198 error: Some(error),
199 allowed: true,
200 first_user: users == 0,
201 });
202 }
203 let hash = hash_password(f.password).await?;
204 let id = db::create_user(&state.db, &username, &email, &hash, users == 0).await?;
205 let resp = start_session(&state, id, "/settings/keys").await?;
206 Ok(with_cookie(
207 resp,
208 ctx::flash_cookie(
209 "success",
210 "Welcome to Openhub! Add an SSH key so you can push code.",
211 ),
212 ))
213}
214
215pub async fn logout(State(state): State<AppState>, ctx: Ctx) -> WebResult {
216 if let Some(hash) = &ctx.session_hash {
217 db::delete_session(&state.db, hash).await?;
218 }
219 Ok(with_cookie(redirect("/"), ctx::clear_session_cookie()))
220}
221
222// ---------------------------------------------------------------------------
223// Settings
224// ---------------------------------------------------------------------------
225
226pub async fn settings_redirect() -> WebResult {
227 Ok(redirect("/settings/profile"))
228}
229
230#[derive(Template)]
231#[template(path = "settings/profile.html")]
232struct ProfilePage {
233 ctx: Ctx,
234 tab: &'static str,
235 display_name: String,
236 email: String,
237 bio: String,
238 error: Option<String>,
239 password_error: Option<String>,
240}
241
242pub async fn profile_form(ctx: Ctx) -> WebResult {
243 let user = ctx.require_user()?.clone();
244 render(&ProfilePage {
245 ctx,
246 tab: "profile",
247 display_name: user.display_name,
248 email: user.email,
249 bio: user.bio,
250 error: None,
251 password_error: None,
252 })
253}
254
255#[derive(Deserialize)]
256pub struct ProfileForm {
257 #[serde(default)]
258 display_name: String,
259 email: String,
260 #[serde(default)]
261 bio: String,
262}
263
264pub async fn update_profile(
265 State(state): State<AppState>,
266 ctx: Ctx,
267 Form(f): Form<ProfileForm>,
268) -> WebResult {
269 let user = ctx.require_user()?.clone();
270 let display_name = f.display_name.trim().chars().take(100).collect::<String>();
271 let bio = f.bio.trim().chars().take(500).collect::<String>();
272 let email = f.email.trim().to_string();
273 let mut error = validate::email(&email).err().map(str::to_string);
274 if error.is_none() && db::email_taken(&state.db, &email, user.id).await? {
275 error = Some("That email is used by another account.".into());
276 }
277 if let Some(error) = error {
278 return render(&ProfilePage {
279 ctx,
280 tab: "profile",
281 display_name,
282 email,
283 bio,
284 error: Some(error),
285 password_error: None,
286 });
287 }
288 db::update_profile(&state.db, user.id, &display_name, &email, &bio).await?;
289 Ok(redirect_flash(
290 "/settings/profile",
291 "success",
292 "Profile updated.",
293 ))
294}
295
296#[derive(Deserialize)]
297pub struct PasswordForm {
298 current: String,
299 new: String,
300 confirm: String,
301}
302
303pub async fn change_password(
304 State(state): State<AppState>,
305 ctx: Ctx,
306 Form(f): Form<PasswordForm>,
307) -> WebResult {
308 let user = ctx.require_user()?.clone();
309 let mut error = None;
310 if !verify_password(f.current, user.password_hash.clone()).await {
311 error = Some("Current password is incorrect.".to_string());
312 } else if let Err(e) = validate::password(&f.new) {
313 error = Some(e.to_string());
314 } else if f.new != f.confirm {
315 error = Some("New passwords do not match.".to_string());
316 }
317 if let Some(error) = error {
318 return render(&ProfilePage {
319 ctx,
320 tab: "profile",
321 display_name: user.display_name,
322 email: user.email,
323 bio: user.bio,
324 error: None,
325 password_error: Some(error),
326 });
327 }
328 let hash = hash_password(f.new).await?;
329 db::update_password(&state.db, user.id, &hash).await?;
330 if let Some(keep) = &ctx.session_hash {
331 db::delete_other_sessions(&state.db, user.id, keep).await?;
332 }
333 Ok(redirect_flash(
334 "/settings/profile",
335 "success",
336 "Password changed. Other sessions have been signed out.",
337 ))
338}
339
340#[derive(Template)]
341#[template(path = "settings/keys.html")]
342struct KeysPage {
343 ctx: Ctx,
344 tab: &'static str,
345 keys: Vec<SshKey>,
346 test_command: String,
347 title: String,
348 key: String,
349 error: Option<String>,
350}
351
352async fn keys_page(
353 state: &AppState,
354 ctx: Ctx,
355 title: String,
356 key: String,
357 error: Option<String>,
358) -> WebResult {
359 let user = ctx.require_user()?;
360 let keys = db::list_keys(&state.db, user.id).await?;
361 render(&KeysPage {
362 ctx,
363 tab: "keys",
364 keys,
365 test_command: state.config.ssh_test_command(),
366 title,
367 key,
368 error,
369 })
370}
371
372pub async fn keys(State(state): State<AppState>, ctx: Ctx) -> WebResult {
373 keys_page(&state, ctx, String::new(), String::new(), None).await
374}
375
376#[derive(Deserialize)]
377pub struct KeyForm {
378 #[serde(default)]
379 title: String,
380 key: String,
381}
382
383pub async fn add_key(State(state): State<AppState>, ctx: Ctx, Form(f): Form<KeyForm>) -> WebResult {
384 let user = ctx.require_user()?.clone();
385 let parsed = match validate::ssh_public_key(&f.key) {
386 Ok(p) => p,
387 Err(e) => return keys_page(&state, ctx, f.title, f.key, Some(e.into())).await,
388 };
389 if db::fingerprint_exists(&state.db, &parsed.fingerprint).await? {
390 return keys_page(
391 &state,
392 ctx,
393 f.title,
394 f.key,
395 Some("This key is already in use by an account.".into()),
396 )
397 .await;
398 }
399 let title = match f.title.trim() {
400 "" if !parsed.comment.is_empty() => parsed.comment.clone(),
401 "" => parsed.key_type.clone(),
402 t => t.chars().take(100).collect(),
403 };
404 db::add_key(
405 &state.db,
406 user.id,
407 &title,
408 &parsed.fingerprint,
409 &parsed.normalized,
410 &parsed.key_type,
411 )
412 .await?;
413 Ok(redirect_flash(
414 "/settings/keys",
415 "success",
416 &format!("SSH key \u{201c}{title}\u{201d} added."),
417 ))
418}
419
420pub async fn delete_key(State(state): State<AppState>, ctx: Ctx, Path(id): Path<i64>) -> WebResult {
421 let user = ctx.require_user()?;
422 if !db::delete_key(&state.db, id, user.id).await? {
423 return Err(AppError::NotFound);
424 }
425 Ok(redirect_flash(
426 "/settings/keys",
427 "success",
428 "SSH key deleted.",
429 ))
430}