Skip to main content

sl_map_web/routes/
auth.rs

1//! JSON handlers for the authentication endpoints.
2
3use axum::Json;
4use axum::extract::State;
5use axum::response::{IntoResponse as _, Response};
6use axum_extra::extract::cookie::SignedCookieJar;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::auth::{
12    self, CurrentUser, LslBearer, SetPasswordTokenRow, generate_token, hash_password, hash_token,
13    removal_cookie, verify_password,
14};
15use crate::client_ip::ClientIp;
16use crate::error::Error;
17use crate::state::AppState;
18
19/// Minimum length of a new password, in bytes. Long-and-simple beats
20/// short-and-complex; we don't enforce complexity rules (per NIST 800-63B
21/// guidance) but we want enough characters that brute force is not
22/// trivial.
23const MIN_PASSWORD_LENGTH: usize = 12;
24
25/// Maximum length of a new password, in bytes. Bounds the Argon2 input so
26/// an attacker can't burn CPU per attempt by submitting megabyte-scale
27/// "passwords". 128 bytes is far longer than any human-typed passphrase.
28const MAX_PASSWORD_LENGTH: usize = 128;
29
30/// Body sent by the LSL script to register a new avatar or refresh the
31/// names of an existing one.
32#[derive(Debug, Deserialize)]
33pub struct RegisterRequest {
34    /// the avatar's UUID (the SL "agent key").
35    pub agent_key: Uuid,
36    /// the legacy "Firstname Lastname" form.
37    pub legacy_name: String,
38    /// the modern `firstname.lastname` username.
39    pub username: String,
40}
41
42/// Response returned to the LSL script — carries the one-time link the
43/// script chats back to the user in-world.
44#[derive(Debug, Serialize)]
45pub struct RegisterResponse {
46    /// full URL the user should open to set their password.
47    pub set_password_url: String,
48    /// when the token in the URL stops being valid.
49    pub expires_at: DateTime<Utc>,
50    /// the persisted user record.
51    pub user: UserView,
52}
53
54/// Public view of a user row, used in API responses.
55#[derive(Debug, Serialize)]
56pub struct UserView {
57    /// the SL avatar UUID.
58    pub user_id: Uuid,
59    /// `firstname.lastname` username.
60    pub username: String,
61    /// "Firstname Lastname" legacy display name.
62    pub legacy_name: String,
63    /// the user's preferred route colour as `#rrggbb`, or `None` if
64    /// they have never picked one. Stored on the `users` row so the
65    /// preference follows the account across browsers/devices.
66    pub route_color: Option<String>,
67}
68
69/// `POST /api/auth/register` — LSL-facing registration / password-reset
70/// trigger. Bearer-authenticated.
71///
72/// # Errors
73///
74/// Returns [`Error`] on auth failure, validation failure or DB error.
75pub async fn register(
76    State(state): State<AppState>,
77    _bearer: LslBearer,
78    Json(req): Json<RegisterRequest>,
79) -> Result<Json<RegisterResponse>, Error> {
80    let legacy_name = req.legacy_name.trim();
81    let username = req.username.trim();
82    if legacy_name.is_empty() || username.is_empty() {
83        return Err(Error::BadRequest(
84            "legacy_name and username must be non-empty".to_owned(),
85        ));
86    }
87    let now = Utc::now();
88    let user_uuid = req.agent_key;
89    let user_id_bytes = user_uuid.as_bytes().to_vec();
90
91    // Generate a fresh single-use token, store only the hash.
92    let (raw_token, token_hash) = generate_token();
93    let expires_at = now
94        .checked_add_signed(chrono::Duration::seconds(
95            state.config.set_password_token_ttl_seconds,
96        ))
97        .unwrap_or(chrono::DateTime::<Utc>::MAX_UTC);
98
99    let mut tx = state.db.begin().await.map_err(|err| {
100        tracing::error!("register begin tx failed: {err}");
101        Error::Database
102    })?;
103
104    // UPSERT: on a fresh UUID create the row; on a re-register update the
105    // name fields (the user may have paid SL to change their username or
106    // legacy display).
107    sqlx::query(
108        "INSERT INTO users (user_id, legacy_name, username, created_at, updated_at) \
109            VALUES (?1, ?2, ?3, ?4, ?4) \
110         ON CONFLICT(user_id) DO UPDATE SET \
111            legacy_name = excluded.legacy_name, \
112            username = excluded.username, \
113            updated_at = excluded.updated_at",
114    )
115    .bind(&user_id_bytes)
116    .bind(legacy_name)
117    .bind(username)
118    .bind(now)
119    .execute(&mut *tx)
120    .await
121    .map_err(|err| {
122        tracing::error!("register UPSERT failed: {err}");
123        Error::Database
124    })?;
125
126    // Invalidate any still-live unused tokens this user already has, so the
127    // table only ever carries one outstanding token per user. The periodic
128    // sweeper in `auth::run_cleanup` reaps expired-or-used rows on its own
129    // schedule; this DELETE bounds the in-between window where repeated
130    // re-registers would otherwise accumulate live tokens.
131    sqlx::query("DELETE FROM set_password_tokens WHERE user_id = ?1 AND used_at IS NULL")
132        .bind(&user_id_bytes)
133        .execute(&mut *tx)
134        .await
135        .map_err(|err| {
136            tracing::error!("prior token prune failed: {err}");
137            Error::Database
138        })?;
139
140    sqlx::query(
141        "INSERT INTO set_password_tokens (token_hash, user_id, expires_at, created_at) \
142            VALUES (?1, ?2, ?3, ?4)",
143    )
144    .bind(&token_hash)
145    .bind(&user_id_bytes)
146    .bind(expires_at)
147    .bind(now)
148    .execute(&mut *tx)
149    .await
150    .map_err(|err| {
151        tracing::error!("set-password token insert failed: {err}");
152        Error::Database
153    })?;
154
155    tx.commit().await.map_err(|err| {
156        tracing::error!("register commit failed: {err}");
157        Error::Database
158    })?;
159
160    let base = state.config.public_base_url.trim_end_matches('/');
161    let set_password_url = format!("{base}/set-password?token={raw_token}");
162    Ok(Json(RegisterResponse {
163        set_password_url,
164        expires_at,
165        user: UserView {
166            user_id: user_uuid,
167            username: username.to_owned(),
168            legacy_name: legacy_name.to_owned(),
169            route_color: None,
170        },
171    }))
172}
173
174/// Body for `POST /api/auth/set-password`.
175#[derive(Debug, Deserialize)]
176pub struct SetPasswordRequest {
177    /// the raw token from the URL query string.
178    pub token: String,
179    /// the chosen password (plain text over HTTPS; hashed before storage).
180    pub new_password: String,
181}
182
183/// `POST /api/auth/set-password` — consume a one-time token and store the
184/// password hash.
185///
186/// On success all existing sessions for the user are deleted so a reset
187/// invalidates other browsers immediately.
188///
189/// # Errors
190///
191/// Returns [`Error::InvalidOrExpiredToken`] for any token-validity failure
192/// (missing, wrong length, unknown hash, already used, expired). Other
193/// errors come from the DB or hash subsystem.
194pub async fn set_password(
195    State(state): State<AppState>,
196    Json(req): Json<SetPasswordRequest>,
197) -> Result<Response, Error> {
198    if req.new_password.len() < MIN_PASSWORD_LENGTH {
199        return Err(Error::BadRequest(format!(
200            "password must be at least {MIN_PASSWORD_LENGTH} characters"
201        )));
202    }
203    if req.new_password.len() > MAX_PASSWORD_LENGTH {
204        return Err(Error::BadRequest(format!(
205            "password must be at most {MAX_PASSWORD_LENGTH} characters"
206        )));
207    }
208    let Some(token_hash) = hash_token(&req.token) else {
209        return Err(Error::InvalidOrExpiredToken);
210    };
211
212    let row: Option<SetPasswordTokenRow> = sqlx::query_as(
213        "SELECT user_id, expires_at, used_at \
214         FROM set_password_tokens \
215         WHERE token_hash = ?1",
216    )
217    .bind(&token_hash)
218    .fetch_optional(&state.db)
219    .await
220    .map_err(|err| {
221        tracing::error!("token lookup failed: {err}");
222        Error::Database
223    })?;
224
225    let Some((user_id_bytes, expires_at, used_at)) = row else {
226        return Err(Error::InvalidOrExpiredToken);
227    };
228    let now = Utc::now();
229    if used_at.is_some() || expires_at <= now {
230        return Err(Error::InvalidOrExpiredToken);
231    }
232
233    let password_hash = hash_password(&req.new_password)?;
234
235    let mut tx = state.db.begin().await.map_err(|err| {
236        tracing::error!("failed to begin tx: {err}");
237        Error::Database
238    })?;
239    sqlx::query("UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE user_id = ?3")
240        .bind(&password_hash)
241        .bind(now)
242        .bind(&user_id_bytes)
243        .execute(&mut *tx)
244        .await
245        .map_err(|err| {
246            tracing::error!("password update failed: {err}");
247            Error::Database
248        })?;
249    sqlx::query("UPDATE set_password_tokens SET used_at = ?1 WHERE token_hash = ?2")
250        .bind(now)
251        .bind(&token_hash)
252        .execute(&mut *tx)
253        .await
254        .map_err(|err| {
255            tracing::error!("token consume failed: {err}");
256            Error::Database
257        })?;
258    // also invalidate any *other* unused tokens for this user so a stolen
259    // link cannot be used after the legitimate one has been clicked.
260    sqlx::query(
261        "DELETE FROM set_password_tokens WHERE user_id = ?1 AND token_hash != ?2 AND used_at IS NULL",
262    )
263    .bind(&user_id_bytes)
264    .bind(&token_hash)
265    .execute(&mut *tx)
266    .await
267    .map_err(|err| {
268        tracing::error!("token cleanup failed: {err}");
269        Error::Database
270    })?;
271    // wipe existing sessions on password change.
272    sqlx::query("DELETE FROM sessions WHERE user_id = ?1")
273        .bind(&user_id_bytes)
274        .execute(&mut *tx)
275        .await
276        .map_err(|err| {
277            tracing::error!("session wipe on password change failed: {err}");
278            Error::Database
279        })?;
280    tx.commit().await.map_err(|err| {
281        tracing::error!("failed to commit tx: {err}");
282        Error::Database
283    })?;
284    Ok((axum::http::StatusCode::OK, "password set").into_response())
285}
286
287/// Body for `POST /api/auth/login`.
288#[derive(Debug, Deserialize)]
289pub struct LoginRequest {
290    /// Avatar UUID, `firstname.lastname` username, or legacy "Firstname
291    /// Lastname". Resolved to a single user row.
292    pub identifier: String,
293    /// the user's password.
294    pub password: String,
295}
296
297/// Response from a successful login.
298#[derive(Debug, Serialize)]
299pub struct LoginResponse {
300    /// the now-logged-in user.
301    pub user: UserView,
302}
303
304/// `POST /api/auth/login` — verify credentials, issue a session cookie.
305///
306/// # Errors
307///
308/// Returns [`Error::InvalidCredentials`] if the user is unknown, the
309/// password is wrong, or the user has not yet completed the set-password
310/// flow.
311pub async fn login(
312    State(state): State<AppState>,
313    jar: SignedCookieJar,
314    ClientIp(client_ip): ClientIp,
315    Json(req): Json<LoginRequest>,
316) -> Result<(SignedCookieJar, Json<LoginResponse>), Error> {
317    let identifier = req.identifier.trim();
318    if identifier.is_empty() {
319        return Err(Error::InvalidCredentials);
320    }
321
322    let row = auth::lookup_user_by_identifier(&state.db, identifier).await?;
323    let Some((user_id_bytes, legacy_name, username, password_hash)) = row else {
324        return Err(Error::InvalidCredentials);
325    };
326    let Some(stored_hash) = password_hash else {
327        return Err(Error::InvalidCredentials);
328    };
329    if !verify_password(&req.password, &stored_hash)? {
330        return Err(Error::InvalidCredentials);
331    }
332    let user_id = auth::uuid_from_bytes(&user_id_bytes).ok_or(Error::InvalidCredentials)?;
333    // Retire any session row whose cookie the client brought to this
334    // request before we mint a new one. Defends against a pre-login
335    // leaked id surviving the re-login on the same browser — see L17.
336    // Only the cookie-carried session is touched; other browsers /
337    // devices keep their independent sessions.
338    if let Some(session_id) = auth::session_id_from_jar(&jar, &state.config.cookie_name) {
339        auth::delete_session(&state.db, &session_id).await?;
340    }
341    let cookie = auth::create_session(
342        &state.db,
343        &state.config,
344        user_id,
345        Some(client_ip.to_string()),
346    )
347    .await?;
348    Ok((
349        jar.add(cookie),
350        Json(LoginResponse {
351            user: UserView {
352                user_id,
353                username,
354                legacy_name,
355                route_color: None,
356            },
357        }),
358    ))
359}
360
361/// `POST /api/auth/logout` — delete the server-side session row and clear
362/// the cookie on the client.
363///
364/// # Errors
365///
366/// Returns [`Error::Database`] on delete failure.
367pub async fn logout(
368    State(state): State<AppState>,
369    jar: SignedCookieJar,
370) -> Result<(SignedCookieJar, Response), Error> {
371    if let Some(session_id) = auth::session_id_from_jar(&jar, &state.config.cookie_name) {
372        auth::delete_session(&state.db, &session_id).await?;
373    }
374    let cleared = jar.add(removal_cookie(&state.config));
375    Ok((
376        cleared,
377        (axum::http::StatusCode::OK, "logged out").into_response(),
378    ))
379}
380
381/// `GET /api/auth/me` — return the currently authenticated user, used by
382/// the front-end to populate the header bar.
383///
384/// # Errors
385///
386/// Returns [`Error::Database`] if the `route_color` lookup fails.
387pub async fn me(
388    user: CurrentUser,
389    State(state): State<crate::state::AppState>,
390) -> Result<Json<UserView>, Error> {
391    let row: Option<(Option<String>,)> =
392        sqlx::query_as("SELECT route_color FROM users WHERE user_id = ?1")
393            .bind(user.user_id.as_bytes().to_vec())
394            .fetch_optional(&state.db)
395            .await
396            .map_err(|err| {
397                tracing::error!("route_color lookup failed: {err}");
398                Error::Database
399            })?;
400    let route_color = row.and_then(|(c,)| c);
401    Ok(Json(UserView {
402        user_id: user.user_id,
403        username: user.username,
404        legacy_name: user.legacy_name,
405        route_color,
406    }))
407}