use crate::api::email; use crate::api::error::ApiError; use crate::api::middleware::{sha256_token, AuthedUser}; use crate::api::state::AppState; use crate::api::users::{verify_password, UserPayload}; use crate::database::UserRow; use axum::extract::Extension; use axum::http::StatusCode; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use std::sync::Arc; use totp_rs::{Algorithm, Secret, TOTP}; const EMAIL_CODE_TTL_SECS: i64 = 600; /// `LoginRequest` mirrors the Flutter client at /// flutter/lib/common/hbbs/hbbs.dart:133. M1 only consults `username`, /// `password`, and `type`; the other fields are tolerated for forward-compat. #[derive(Debug, Deserialize)] pub struct LoginRequest { #[serde(default)] pub username: Option, #[serde(default)] pub password: Option, #[serde(default)] pub id: Option, #[serde(default)] pub uuid: Option, #[serde(default, rename = "type")] pub kind: Option, #[serde(default, rename = "deviceInfo")] pub device_info: Option, // Tolerated, ignored in M1: #[serde(default)] pub auto_login: Option, #[serde(default, rename = "verificationCode")] pub verification_code: Option, #[serde(default, rename = "tfaCode")] pub tfa_code: Option, #[serde(default)] pub secret: Option, } #[derive(Debug, Deserialize)] pub struct IdUuidBody { #[serde(default)] pub id: Option, #[serde(default)] pub uuid: Option, } pub async fn login_options_head() -> StatusCode { StatusCode::OK } pub async fn login_options(Extension(state): Extension>) -> Json> { // Static base set from config (account / email_code), plus a dynamic // `oidc/` entry per enabled provider in the DB. Recomputed per // request so adding a provider via SQL takes effect without a restart. let mut out = state.cfg.login_options.clone(); if !state.cfg.public_base_url.is_empty() { if let Ok(providers) = state.db.oidc_provider_list_enabled().await { for p in providers { out.push(format!("oidc/{}", p.name)); } } } Json(out) } const TFA_CHALLENGE_TTL_SECS: i64 = 300; pub async fn login( Extension(state): Extension>, Json(req): Json, ) -> Result, ApiError> { // The desktop client reuses the email-code dialog for the TOTP second // leg: it POSTs `type: "email_code"` with `tfaCode` set (and the email // `verificationCode` field also set, but we ignore that when tfaCode is // present). Detect that shape up-front and route to the TOTP verifier; // otherwise dispatch on the declared `type`. let has_tfa = req.tfa_code.as_deref().is_some_and(|s| !s.is_empty()) && req.secret.as_deref().is_some_and(|s| !s.is_empty()); if has_tfa { return login_tfa_code(state, req).await; } let kind = req.kind.as_deref().unwrap_or("account"); match kind { "account" | "" => login_account(state, req).await, "tfa_code" => login_tfa_code(state, req).await, "email_code" => login_email_code(state, req).await, other => Err(ApiError::BadRequest(format!( "unsupported login type: {}", other ))), } } /// Two-leg passwordless login by email. Leg 1 (no `verificationCode`) mints a /// fresh 6-digit code and emails it to the user (or logs to stdout when SMTP /// is unconfigured). Leg 2 (with `verificationCode`) verifies the code, /// consumes it, and issues an access token. async fn login_email_code( state: Arc, req: LoginRequest, ) -> Result, ApiError> { // The Flutter client passes the email/username in the `username` field; // accept it either as a literal email or as a username we can map to one. let identifier = req .username .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| ApiError::BadRequest("username (email) required".into()))?; let user = resolve_user_by_identifier(&state, identifier).await?; let email = if !user.email.is_empty() { user.email.clone() } else if user.username.contains('@') { // Operator bootstraps users with email-as-username — accept that. user.username.clone() } else { return Err(ApiError::BadRequest( "user has no email address on file".into(), )); }; if let Some(code) = req .verification_code .as_deref() .filter(|s| !s.is_empty()) { // Leg 2: verify. let supplied_hash = sodiumoxide::crypto::hash::sha256::hash(code.as_bytes()) .as_ref() .to_vec(); let ok = state .db .email_code_verify(&email, &supplied_hash) .await .map_err(|e| ApiError::Internal(e.to_string()))?; if !ok { return Err(ApiError::BadCredentials); } if user.status == 0 { return Err(ApiError::AccountDisabled); } if user.status == -1 { return Err(ApiError::Unverified); } return issue_session(&state, &req, &user).await; } // Leg 1: mint + send a fresh code. let (code, code_hash) = email::mint_code(); state .db .email_code_create(&email, &code_hash, EMAIL_CODE_TTL_SECS) .await .map_err(|e| ApiError::Internal(e.to_string()))?; if let Err(e) = email::send_login_code(state.cfg.email.as_ref(), &email, &code).await { return Err(ApiError::Internal(e)); } Ok(Json(json!({ "type": "email_check" }))) } async fn resolve_user_by_identifier( state: &AppState, identifier: &str, ) -> Result { if identifier.contains('@') { if let Some(u) = state .db .user_find_by_email(identifier) .await .map_err(|e| ApiError::Internal(e.to_string()))? { return Ok(u); } } state .db .user_find_by_username(identifier) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::BadCredentials) } async fn login_account( state: Arc, req: LoginRequest, ) -> Result, ApiError> { let username = req .username .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| ApiError::BadRequest("username required".into()))?; let password = req .password .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| ApiError::BadRequest("password required".into()))?; let user = state .db .user_find_by_username(username) .await? .ok_or(ApiError::BadCredentials)?; let ok = verify_password(user.password_hash.clone(), password.to_string()).await?; if !ok { return Err(ApiError::BadCredentials); } if user.status == 0 { return Err(ApiError::AccountDisabled); } if user.status == -1 { return Err(ApiError::Unverified); } // 2FA gate: if the user has TOTP enrolled, mint a short-lived nonce and // tell the client we want the TOTP code in a follow-up POST. The client // echoes the nonce back as `secret`. // // Wire shape matches the Flutter client's expectations // (flutter/lib/common/widgets/login.dart:485): the outer `type` is the // generic `email_check` envelope (the dialog the client opens for any // second-leg challenge), and `tfa_type` distinguishes TOTP (`tfa_check`) // from email (`email_check`). Returning `type:"tfa_check"` directly // would miss the switch's only branch and surface as the unhelpful // "bad response from server" toast. if state.db.totp_get_secret(user.id).await?.is_some() { let nonce = state .db .tfa_challenge_create(user.id, TFA_CHALLENGE_TTL_SECS) .await?; return Ok(Json(json!({ "type": "email_check", "tfa_type": "tfa_check", "secret": nonce, }))); } issue_session(&state, &req, &user).await } async fn login_tfa_code( state: Arc, req: LoginRequest, ) -> Result, ApiError> { let nonce = req .secret .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| ApiError::BadRequest("secret required".into()))?; let code = req .tfa_code .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| ApiError::BadRequest("tfaCode required".into()))?; let user_id = state .db .tfa_challenge_lookup(nonce) .await? .ok_or_else(|| ApiError::BadRequest("invalid or expired challenge".into()))?; let secret_b32 = state .db .totp_get_secret(user_id) .await? .ok_or_else(|| ApiError::BadRequest("TOTP not enrolled".into()))?; if !verify_totp(&secret_b32, code)? { // Leave the challenge row alive — operators may want short retries. return Err(ApiError::BadCredentials); } state.db.tfa_challenge_consume(nonce).await?; let user = state .db .user_find_by_id(user_id) .await? .ok_or(ApiError::Unauthorized)?; issue_session(&state, &req, &user).await } /// Build and persist a fresh access token, claim the calling device, and /// return the standard logged-in response shape. Shared by the password, /// post-TOTP, post-email-code, and (later) post-OIDC paths. async fn issue_session( state: &AppState, req: &LoginRequest, user: &UserRow, ) -> Result, ApiError> { let token = mint_token(); let sha = sha256_token(&token); let device_info_json = req .device_info .as_ref() .map(|v| v.to_string()) .unwrap_or_default(); state .db .token_insert( user.id, &sha, req.id.as_deref().unwrap_or_default(), req.uuid.as_deref().unwrap_or_default(), &device_info_json, state.cfg.session_ttl_secs, ) .await?; // Bind the calling device to this user so /api/peers shows it correctly. state .db .device_claim( user.id, req.id.as_deref().unwrap_or_default(), req.uuid.as_deref().unwrap_or_default(), ) .await; Ok(Json(json!({ "access_token": token, "type": "access_token", "user": UserPayload::from(user), }))) } pub(crate) fn verify_totp(secret_b32: &str, code: &str) -> Result { let secret = Secret::Encoded(secret_b32.to_string()) .to_bytes() .map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?; let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, secret) .map_err(|e| ApiError::Internal(format!("TOTP init: {}", e)))?; totp.check_current(code) .map_err(|e| ApiError::Internal(format!("TOTP check: {}", e))) } pub async fn current_user( Extension(state): Extension>, user: AuthedUser, // Body is required by the client but its content is purely advisory. Json(_body): Json, ) -> Result, ApiError> { let row = state .db .user_find_by_id(user.user_id) .await? .ok_or(ApiError::Unauthorized)?; Ok(Json(UserPayload::from(&row))) } pub async fn logout( Extension(state): Extension>, headers: axum::http::HeaderMap, Json(_body): Json, ) -> StatusCode { // Best-effort: parse the bearer ourselves so a missing/invalid token still // returns 200 (matches the client's fire-and-forget logout flow). if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) { if let Ok(s) = auth.to_str() { if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) { if !tok.is_empty() { let sha = sha256_token(tok); let _ = state.db.token_delete(&sha).await; } } } } StatusCode::OK } pub(crate) fn mint_token() -> String { let bytes = sodiumoxide::randombytes::randombytes(32); base64::encode_config(bytes, base64::URL_SAFE_NO_PAD) }