Sam Jeston
History
430 lines·
11.9 KB
·Rust
| 1 | //! Sign up, sign in, profile settings and SSH key management. |
| 2 | |
| 3 | use std::sync::LazyLock; |
| 4 | |
| 5 | use argon2::Argon2; |
| 6 | use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; |
| 7 | use askama::Template; |
| 8 | use axum::Form; |
| 9 | use axum::extract::{Path, Query, State}; |
| 10 | use serde::Deserialize; |
| 11 | |
| 12 | use super::ctx::{self, Ctx, SESSION_TTL_SECS}; |
| 13 | use super::{AppError, WebResult, redirect, redirect_flash, render, safe_next, with_cookie}; |
| 14 | use crate::db::{self, SshKey}; |
| 15 | use crate::state::AppState; |
| 16 | use crate::validate; |
| 17 | |
| 18 | /// Verified against when an account does not exist, to keep timing uniform. |
| 19 | static 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 | |
| 27 | pub 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 | |
| 38 | pub 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 | |
| 52 | async 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")] |
| 74 | struct LoginPage { |
| 75 | ctx: Ctx, |
| 76 | login: String, |
| 77 | next: String, |
| 78 | error: Option<String>, |
| 79 | can_register: bool, |
| 80 | } |
| 81 | |
| 82 | #[derive(Deserialize)] |
| 83 | pub struct NextQuery { |
| 84 | next: Option<String>, |
| 85 | } |
| 86 | |
| 87 | async fn can_register(state: &AppState) -> Result<bool, AppError> { |
| 88 | Ok(!state.config.disable_registration || db::count_users(&state.db).await? == 0) |
| 89 | } |
| 90 | |
| 91 | pub 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)] |
| 109 | pub struct LoginForm { |
| 110 | login: String, |
| 111 | password: String, |
| 112 | next: Option<String>, |
| 113 | } |
| 114 | |
| 115 | pub 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")] |
| 138 | struct RegisterPage { |
| 139 | ctx: Ctx, |
| 140 | username: String, |
| 141 | email: String, |
| 142 | error: Option<String>, |
| 143 | allowed: bool, |
| 144 | first_user: bool, |
| 145 | } |
| 146 | |
| 147 | pub 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)] |
| 163 | pub struct RegisterForm { |
| 164 | username: String, |
| 165 | email: String, |
| 166 | password: String, |
| 167 | } |
| 168 | |
| 169 | pub 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 | |
| 215 | pub 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 | |
| 226 | pub async fn settings_redirect() -> WebResult { |
| 227 | Ok(redirect("/settings/profile")) |
| 228 | } |
| 229 | |
| 230 | #[derive(Template)] |
| 231 | #[template(path = "settings/profile.html")] |
| 232 | struct 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 | |
| 242 | pub 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)] |
| 256 | pub struct ProfileForm { |
| 257 | #[serde(default)] |
| 258 | display_name: String, |
| 259 | email: String, |
| 260 | #[serde(default)] |
| 261 | bio: String, |
| 262 | } |
| 263 | |
| 264 | pub 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)] |
| 297 | pub struct PasswordForm { |
| 298 | current: String, |
| 299 | new: String, |
| 300 | confirm: String, |
| 301 | } |
| 302 | |
| 303 | pub 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")] |
| 342 | struct 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 | |
| 352 | async 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 | |
| 372 | pub 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)] |
| 377 | pub struct KeyForm { |
| 378 | #[serde(default)] |
| 379 | title: String, |
| 380 | key: String, |
| 381 | } |
| 382 | |
| 383 | pub 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 | |
| 420 | pub 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 | } |