1use 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
19const MIN_PASSWORD_LENGTH: usize = 12;
24
25const MAX_PASSWORD_LENGTH: usize = 128;
29
30#[derive(Debug, Deserialize)]
33pub struct RegisterRequest {
34 pub agent_key: Uuid,
36 pub legacy_name: String,
38 pub username: String,
40}
41
42#[derive(Debug, Serialize)]
45pub struct RegisterResponse {
46 pub set_password_url: String,
48 pub expires_at: DateTime<Utc>,
50 pub user: UserView,
52}
53
54#[derive(Debug, Serialize)]
56pub struct UserView {
57 pub user_id: Uuid,
59 pub username: String,
61 pub legacy_name: String,
63 pub route_color: Option<String>,
67}
68
69pub 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 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 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 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#[derive(Debug, Deserialize)]
176pub struct SetPasswordRequest {
177 pub token: String,
179 pub new_password: String,
181}
182
183pub 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 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 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#[derive(Debug, Deserialize)]
289pub struct LoginRequest {
290 pub identifier: String,
293 pub password: String,
295}
296
297#[derive(Debug, Serialize)]
299pub struct LoginResponse {
300 pub user: UserView,
302}
303
304pub 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 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
361pub 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
381pub 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}