1use std::time::Duration;
4
5use chrono::Utc;
6use serde_json::json;
7use sqlx::PgPool;
8use uuid::Uuid;
9
10use crate::{
11 auth::{self, Actor},
12 config::Config,
13 domain::{self, Role, TokenScope},
14 error::{AppError, AppResult},
15 models::{ApiToken, User, TOKEN_COLUMNS, USER_COLUMNS},
16 services::{
17 access, audit,
18 project_policy::{self, Interface},
19 },
20};
21
22fn actor_columns() -> String {
26 format!(
27 "u.id, u.username, u.display_name, u.role, u.skin, u.preferred_cad,
28 COALESCE((SELECT array_agg(pm.project_id) FROM project_members pm WHERE pm.user_id = u.id),
29 '{{}}'::uuid[]) AS projects,
30 {} AS hidden_projects",
31 access::hidden_of_user("u")
32 )
33}
34
35#[derive(sqlx::FromRow)]
36struct ActorRow {
37 id: Uuid,
38 username: String,
39 display_name: String,
40 role: String,
41 skin: String,
42 preferred_cad: String,
43 projects: Vec<Uuid>,
44 hidden_projects: Vec<Uuid>,
45}
46
47impl From<ActorRow> for Actor {
48 fn from(row: ActorRow) -> Self {
49 Actor {
50 id: row.id,
51 username: row.username,
52 display_name: row.display_name,
53 role: Role::parse(&row.role).unwrap_or(Role::Viewer),
54 skin: domain::skin_or_default(&row.skin).to_string(),
55 preferred_cad: if crate::cadformat::is_family(&row.preferred_cad) {
57 row.preferred_cad
58 } else {
59 String::new()
60 },
61 projects: row
64 .projects
65 .into_iter()
66 .filter(|p| !row.hidden_projects.contains(p))
67 .collect(),
68 hidden_projects: row.hidden_projects,
69 }
70 }
71}
72
73pub async fn actor(pool: &PgPool, user_id: Uuid) -> AppResult<Actor> {
75 let cols = actor_columns();
76 sqlx::query_as::<_, ActorRow>(&format!("SELECT {cols} FROM users u WHERE u.id = $1"))
77 .bind(user_id)
78 .fetch_optional(pool)
79 .await?
80 .map(Actor::from)
81 .ok_or_else(|| AppError::not_found("User not found."))
82}
83
84pub async fn active_actor(pool: &PgPool, user_id: Uuid) -> AppResult<Option<Actor>> {
89 let cols = actor_columns();
90 Ok(sqlx::query_as::<_, ActorRow>(&format!(
91 "SELECT {cols} FROM users u WHERE u.id = $1 AND u.active"
92 ))
93 .bind(user_id)
94 .fetch_optional(pool)
95 .await?
96 .map(Actor::from))
97}
98
99pub async fn actor_for_session(pool: &PgPool, token: &str) -> AppResult<Option<Actor>> {
104 Ok(session_actor(pool, token).await?.map(|(actor, _)| actor))
105}
106
107#[derive(sqlx::FromRow)]
108struct SessionActorRow {
109 #[sqlx(flatten)]
110 actor: ActorRow,
111 session_started: chrono::DateTime<Utc>,
112}
113
114pub async fn session_actor(
118 pool: &PgPool,
119 token: &str,
120) -> AppResult<Option<(Actor, chrono::DateTime<Utc>)>> {
121 let cols = actor_columns();
122 let row = sqlx::query_as::<_, SessionActorRow>(&format!(
123 "SELECT {cols}, s.created_at AS session_started
124 FROM sessions s JOIN users u ON u.id = s.user_id
125 WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active"
126 ))
127 .bind(auth::hash_token(token))
128 .fetch_optional(pool)
129 .await?;
130 Ok(row.map(|r| (Actor::from(r.actor), r.session_started)))
131}
132
133#[derive(sqlx::FromRow)]
134struct TokenActorRow {
135 #[sqlx(flatten)]
136 actor: ActorRow,
137 scope: String,
138 record_use: bool,
140 denied: Vec<Uuid>,
142}
143
144const TOKEN_USE_RECORDED_EVERY: &str = "1 minute";
148
149pub async fn actor_for_api_token(
158 pool: &PgPool,
159 token: &str,
160) -> AppResult<Option<(Actor, TokenScope)>> {
161 let token_hash = auth::hash_token(token);
162 let cols = actor_columns();
163 let denied = project_policy::denied_groups_sql(&[Interface::ApiTokens]);
166 let row = sqlx::query_as::<_, TokenActorRow>(&format!(
167 "SELECT {cols}, t.scope,
168 (t.last_used_at IS NULL
169 OR t.last_used_at < now() - interval '{TOKEN_USE_RECORDED_EVERY}') AS record_use,
170 COALESCE(ARRAY({denied}), '{{}}'::uuid[]) AS denied
171 FROM api_tokens t JOIN users u ON u.id = t.user_id
172 WHERE t.token_hash = $1 AND (t.expires_at IS NULL OR t.expires_at > now()) AND u.active"
173 ))
174 .bind(&token_hash)
175 .fetch_optional(pool)
176 .await?;
177 let Some(row) = row else { return Ok(None) };
178 if row.record_use {
179 sqlx::query(&format!(
181 "UPDATE api_tokens SET last_used_at = now(),
182 expires_at = CASE WHEN idle_days IS NULL THEN expires_at
183 ELSE now() + make_interval(days => idle_days) END
184 WHERE token_hash = $1
185 AND (last_used_at IS NULL
186 OR last_used_at < now() - interval '{TOKEN_USE_RECORDED_EVERY}')"
187 ))
188 .bind(&token_hash)
189 .execute(pool)
190 .await?;
191 }
192 let Some(scope) = TokenScope::parse(&row.scope) else {
194 return Ok(None);
195 };
196 Ok(Some((
197 Actor::from(row.actor).without_groups(&row.denied),
198 scope,
199 )))
200}
201
202pub async fn set_skin(pool: &PgPool, actor: &Actor, skin: &str) -> AppResult<()> {
204 if !domain::is_skin(skin) {
205 return Err(AppError::BadRequest(format!("Unknown skin '{skin}'.")));
206 }
207 sqlx::query("UPDATE users SET skin = $2 WHERE id = $1")
208 .bind(actor.id)
209 .bind(skin)
210 .execute(pool)
211 .await?;
212 Ok(())
213}
214
215pub async fn set_preferred_cad(pool: &PgPool, actor: &Actor, family: &str) -> AppResult<()> {
219 if !family.is_empty() && !crate::cadformat::is_family(family) {
220 return Err(AppError::BadRequest(format!(
221 "Unknown CAD program '{family}'."
222 )));
223 }
224 sqlx::query("UPDATE users SET preferred_cad = $2 WHERE id = $1")
225 .bind(actor.id)
226 .bind(family)
227 .execute(pool)
228 .await?;
229 Ok(())
230}
231
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234pub enum SignIn {
235 Password,
237 Sso,
239 Setup,
241}
242
243impl SignIn {
244 pub fn as_str(self) -> &'static str {
245 match self {
246 SignIn::Password => "password",
247 SignIn::Sso => "sso",
248 SignIn::Setup => "setup",
249 }
250 }
251}
252
253pub async fn create_session(
256 pool: &PgPool,
257 user_id: Uuid,
258 ttl: Duration,
259 how: SignIn,
260) -> AppResult<String> {
261 let me = actor(pool, user_id).await?;
262 let token = auth::generate_token(auth::SESSION_TOKEN_PREFIX);
263 let expires_at = Utc::now()
264 + chrono::Duration::from_std(ttl).unwrap_or_else(|_| chrono::Duration::hours(72));
265 let mut tx = pool.begin().await?;
266 sqlx::query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)")
267 .bind(auth::hash_token(&token))
268 .bind(user_id)
269 .bind(expires_at)
270 .execute(&mut *tx)
271 .await?;
272 sqlx::query("UPDATE users SET last_login_at = now() WHERE id = $1")
273 .bind(user_id)
274 .execute(&mut *tx)
275 .await?;
276 audit::record(
277 &mut tx,
278 Some(&me),
279 "user.sign_in",
280 "user",
281 Some(user_id),
282 format!("Signed in ({})", how.as_str()),
283 json!({ "method": how.as_str() }),
284 )
285 .await?;
286 tx.commit().await?;
287 Ok(token)
288}
289
290pub async fn delete_session(pool: &PgPool, token: &str) -> AppResult<()> {
293 let mut tx = pool.begin().await?;
294 let ended: Option<Uuid> =
295 sqlx::query_scalar("DELETE FROM sessions WHERE token_hash = $1 RETURNING user_id")
296 .bind(auth::hash_token(token))
297 .fetch_optional(&mut *tx)
298 .await?;
299 if let Some(user_id) = ended {
300 let me = actor(pool, user_id).await?;
301 audit::record(
302 &mut tx,
303 Some(&me),
304 "user.sign_out",
305 "user",
306 Some(user_id),
307 "Signed out",
308 json!({}),
309 )
310 .await?;
311 }
312 tx.commit().await?;
313 Ok(())
314}
315
316pub async fn record_failed_sign_in(pool: &PgPool, username: &str) -> AppResult<()> {
321 let typed = username.trim().to_ascii_lowercase();
322 let account: Option<(Uuid, String)> =
323 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
324 .bind(&typed)
325 .fetch_optional(pool)
326 .await?;
327 let mut tx = pool.begin().await?;
328 match &account {
329 Some((id, name)) => {
330 tracing::warn!(username = %name, "failed sign-in");
331 audit::record(
332 &mut tx,
333 None,
334 "user.sign_in_failed",
335 "user",
336 Some(*id),
337 format!("Failed sign-in as '{name}'"),
338 json!({ "known_account": true }),
339 )
340 .await?
341 }
342 None => {
343 tracing::warn!("failed sign-in with an unknown username");
344 audit::record(
345 &mut tx,
346 None,
347 "user.sign_in_failed",
348 "user",
349 None,
350 "Failed sign-in with an unknown username",
351 json!({ "known_account": false }),
352 )
353 .await?
354 }
355 }
356 tx.commit().await?;
357 Ok(())
358}
359
360pub async fn purge_expired(pool: &PgPool) -> AppResult<u64> {
362 let sessions = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
363 .execute(pool)
364 .await?
365 .rows_affected();
366 let states =
367 sqlx::query("DELETE FROM oidc_states WHERE created_at < now() - interval '15 minutes'")
368 .execute(pool)
369 .await?
370 .rows_affected();
371 let desktop_codes = super::desktop::purge_expired(pool).await?;
372 Ok(sessions + states + desktop_codes)
373}
374
375pub async fn list_tokens(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<ApiToken>> {
376 let tokens = sqlx::query_as::<_, ApiToken>(&format!(
377 "SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC"
378 ))
379 .bind(user_id)
380 .fetch_all(pool)
381 .await?;
382 Ok(tokens)
383}
384
385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
387pub enum TokenLifetime {
388 Unlimited,
390 Days(i64),
392 IdleDays(i64),
394}
395
396impl TokenLifetime {
397 pub fn fixed(days: Option<i64>) -> Self {
399 days.map_or(TokenLifetime::Unlimited, TokenLifetime::Days)
400 }
401
402 pub fn capped(self, idle: u32) -> Self {
406 let idle = i64::from(idle);
407 if idle == 0 {
408 return self;
409 }
410 match self {
411 TokenLifetime::Days(d) if d <= idle => self,
412 TokenLifetime::IdleDays(d) => TokenLifetime::IdleDays(d.min(idle)),
413 _ => TokenLifetime::IdleDays(idle),
414 }
415 }
416}
417
418pub async fn token_lifetime_for(
422 pool: &PgPool,
423 user: Uuid,
424 asked: TokenLifetime,
425 sso_idle_days: u32,
426) -> AppResult<TokenLifetime> {
427 if sso_idle_days == 0 {
428 return Ok(asked);
429 }
430 let sso: bool = sqlx::query_scalar(
431 "SELECT COALESCE((SELECT auth_source = 'oidc' FROM users WHERE id = $1), false)",
432 )
433 .bind(user)
434 .fetch_one(pool)
435 .await?;
436 if sso {
437 return Ok(asked.capped(sso_idle_days));
438 }
439 Ok(asked)
440}
441
442#[derive(Clone, Debug, serde::Serialize)]
444pub struct IdpLogout {
445 pub user_id: Uuid,
446 pub username: String,
447 pub sessions_ended: u64,
448}
449
450pub async fn idp_logout(pool: &PgPool, sub: &str) -> AppResult<Option<IdpLogout>> {
465 let mut tx = pool.begin().await?;
466 let user: Option<(Uuid, String)> = sqlx::query_as(
467 "SELECT id, username FROM users WHERE oidc_subject = $1 AND auth_source = 'oidc'
468 FOR UPDATE",
469 )
470 .bind(sub)
471 .fetch_optional(&mut *tx)
472 .await?;
473 let Some((user_id, username)) = user else {
474 tx.rollback().await?;
475 return Ok(None);
476 };
477 let sessions_ended = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
478 .bind(user_id)
479 .execute(&mut *tx)
480 .await?
481 .rows_affected();
482 sqlx::query("UPDATE users SET idp_logout_at = now() WHERE id = $1")
483 .bind(user_id)
484 .execute(&mut *tx)
485 .await?;
486 audit::record(
487 &mut tx,
488 None,
489 "user.idp_logout",
490 "user",
491 Some(user_id),
492 format!("The identity provider logged {username} out ({sessions_ended} sessions ended)"),
493 json!({ "sessions": sessions_ended }),
494 )
495 .await?;
496 tx.commit().await?;
497 Ok(Some(IdpLogout {
498 user_id,
499 username,
500 sessions_ended,
501 }))
502}
503
504pub async fn create_token(
518 pool: &PgPool,
519 actor: &Actor,
520 name: &str,
521 lifetime: TokenLifetime,
522 scope: TokenScope,
523) -> AppResult<(ApiToken, String)> {
524 let name = domain::require_text("Token name", name, 100)?;
525 let (days, idle_days) = match lifetime {
526 TokenLifetime::Unlimited => (None, None),
527 TokenLifetime::Days(d) => (Some(d), None),
528 TokenLifetime::IdleDays(d) => (Some(d), Some(d)),
529 };
530 if let Some(days) = days {
531 if !(1..=3650).contains(&days) {
532 return Err(AppError::BadRequest(
533 "Token lifetime must be between 1 and 3650 days.".into(),
534 ));
535 }
536 }
537 let token = auth::generate_token(auth::API_TOKEN_PREFIX);
538 let prefix: String = token
539 .chars()
540 .take(auth::API_TOKEN_PREFIX.len() + 6)
541 .collect();
542 let expires_at = days.map(|d| Utc::now() + chrono::Duration::days(d));
543 let mut tx = pool.begin().await?;
544 let record = sqlx::query_as::<_, ApiToken>(&format!(
545 "INSERT INTO api_tokens (id, user_id, name, token_hash, token_prefix, expires_at, scope, idle_days)
546 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
547 RETURNING {TOKEN_COLUMNS}"
548 ))
549 .bind(Uuid::new_v4())
550 .bind(actor.id)
551 .bind(&name)
552 .bind(auth::hash_token(&token))
553 .bind(&prefix)
554 .bind(expires_at)
555 .bind(scope.as_str())
556 .bind(idle_days.and_then(|d| i32::try_from(d).ok()))
557 .fetch_one(&mut *tx)
558 .await?;
559 audit::record(
560 &mut tx,
561 Some(actor),
562 "token.create",
563 "user",
564 Some(actor.id),
565 format!("Created API token '{name}' ({})", scope.label()),
566 json!({ "token_id": record.id, "expires_at": expires_at, "scope": scope.as_str(),
567 "idle_days": idle_days }),
568 )
569 .await?;
570 tx.commit().await?;
571 Ok((record, token))
572}
573
574pub async fn revoke_token(pool: &PgPool, actor: &Actor, token_id: Uuid) -> AppResult<()> {
585 let mut tx = pool.begin().await?;
586 let (name, owner) = sqlx::query_as::<_, (String, Uuid)>(
587 "DELETE FROM api_tokens WHERE id = $1 AND (user_id = $2 OR $3) RETURNING name, user_id",
588 )
589 .bind(token_id)
590 .bind(actor.id)
591 .bind(actor.is_admin())
592 .fetch_optional(&mut *tx)
593 .await?
594 .ok_or_else(|| AppError::not_found("API token not found."))?;
595 let summary = if owner == actor.id {
598 format!("Revoked API token '{name}'")
599 } else {
600 let username = sqlx::query_scalar::<_, String>("SELECT username FROM users WHERE id = $1")
601 .bind(owner)
602 .fetch_one(&mut *tx)
603 .await?;
604 format!("Revoked API token '{name}' of {username}")
605 };
606 audit::record(
607 &mut tx,
608 Some(actor),
609 "token.revoke",
610 "user",
611 Some(owner),
612 summary,
613 json!({ "token_id": token_id }),
614 )
615 .await?;
616 tx.commit().await?;
617 Ok(())
618}
619
620pub async fn count(pool: &PgPool) -> AppResult<i64> {
625 Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
626 .fetch_one(pool)
627 .await?)
628}
629
630pub async fn list(pool: &PgPool) -> AppResult<Vec<User>> {
631 Ok(sqlx::query_as::<_, User>(&format!(
632 "SELECT {USER_COLUMNS} FROM users ORDER BY active DESC, username"
633 ))
634 .fetch_all(pool)
635 .await?)
636}
637
638pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<User> {
639 sqlx::query_as::<_, User>(&format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1"))
640 .bind(id)
641 .fetch_optional(pool)
642 .await?
643 .ok_or_else(|| AppError::not_found("User not found."))
644}
645
646pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult<Option<User>> {
647 Ok(sqlx::query_as::<_, User>(&format!(
648 "SELECT {USER_COLUMNS} FROM users WHERE username = $1"
649 ))
650 .bind(username.trim().to_ascii_lowercase())
651 .fetch_optional(pool)
652 .await?)
653}
654
655pub async fn reviewer_candidates(pool: &PgPool) -> AppResult<Vec<User>> {
657 Ok(sqlx::query_as::<_, User>(&format!(
658 "SELECT {USER_COLUMNS} FROM users
659 WHERE active AND role IN ('editor', 'admin') ORDER BY display_name"
660 ))
661 .fetch_all(pool)
662 .await?)
663}
664
665#[derive(Clone, Debug)]
666pub struct NewLocalUser {
667 pub username: String,
668 pub display_name: String,
669 pub email: Option<String>,
670 pub role: Role,
671 pub password: String,
672}
673
674pub async fn create_local(
687 pool: &PgPool,
688 actor: Option<&Actor>,
689 input: NewLocalUser,
690) -> AppResult<User> {
691 if let Some(actor) = actor {
692 actor.require(Role::Admin)?;
693 }
694 let username = domain::normalize_username(&input.username)?;
695 let display_name = match input.display_name.trim() {
696 "" => username.clone(),
697 name => domain::limit_text("Display name", name, 128)?,
698 };
699 let email = clean_email(input.email.as_deref())?;
700 domain::validate_password(&input.password)?;
701 let hash = auth::hash_password_async(input.password).await?;
702
703 let mut tx = pool.begin().await?;
704 let user = sqlx::query_as::<_, User>(&format!(
705 "INSERT INTO users (id, username, display_name, email, auth_source, password_hash, role)
706 VALUES ($1, $2, $3, $4, 'local', $5, $6)
707 RETURNING {USER_COLUMNS}"
708 ))
709 .bind(Uuid::new_v4())
710 .bind(&username)
711 .bind(&display_name)
712 .bind(&email)
713 .bind(&hash)
714 .bind(input.role.as_str())
715 .fetch_one(&mut *tx)
716 .await
717 .map_err(|e| {
718 AppError::unique_violation(e, format!("The username '{username}' is already taken."))
719 })?;
720 audit::record(
721 &mut tx,
722 actor,
723 "user.create",
724 "user",
725 Some(user.id),
726 format!(
727 "Created local user '{}' with role {}",
728 user.username, user.role
729 ),
730 json!({ "role": user.role }),
731 )
732 .await?;
733 tx.commit().await?;
734 Ok(user)
735}
736
737pub async fn authenticate_local(
740 pool: &PgPool,
741 username: &str,
742 password: &str,
743) -> AppResult<Option<User>> {
744 #[derive(sqlx::FromRow)]
745 struct Row {
746 id: Uuid,
747 password_hash: Option<String>,
748 active: bool,
749 }
750 let username = username.trim().to_ascii_lowercase();
751 let row = sqlx::query_as::<_, Row>(
752 "SELECT id, password_hash, active FROM users WHERE username = $1 AND auth_source = 'local'",
753 )
754 .bind(&username)
755 .fetch_optional(pool)
756 .await?;
757 let (id, hash, active) = match row {
758 Some(r) => (Some(r.id), r.password_hash, r.active),
759 None => (None, None, false),
760 };
761 let valid = auth::verify_password_async(password.to_string(), hash).await;
762 match id {
763 Some(id) if valid && active => Ok(Some(get(pool, id).await?)),
764 _ => Ok(None),
765 }
766}
767
768#[derive(Clone, Debug)]
769pub struct UserUpdate {
770 pub display_name: String,
771 pub email: Option<String>,
772 pub role: Role,
773 pub active: bool,
774}
775
776pub async fn update(pool: &PgPool, actor: &Actor, id: Uuid, upd: UserUpdate) -> AppResult<User> {
794 actor.require(Role::Admin)?;
795 let display_name = domain::require_text("Display name", &upd.display_name, 128)?;
796 let email = clean_email(upd.email.as_deref())?;
797
798 let mut tx = pool.begin().await?;
799 let admins: Vec<Uuid> = sqlx::query_scalar(
804 "SELECT id FROM users WHERE role = 'admin' AND active ORDER BY id FOR UPDATE",
805 )
806 .fetch_all(&mut *tx)
807 .await?;
808 let current = sqlx::query_as::<_, User>(&format!(
809 "SELECT {USER_COLUMNS} FROM users WHERE id = $1 FOR UPDATE"
810 ))
811 .bind(id)
812 .fetch_optional(&mut *tx)
813 .await?
814 .ok_or_else(|| AppError::not_found("User not found."))?;
815
816 let was_admin = admins.contains(&id);
817 let stays_admin = upd.active && upd.role == Role::Admin;
818 if was_admin && !stays_admin && admins.len() == 1 {
819 return Err(AppError::Conflict(
820 "At least one active administrator must remain.".into(),
821 ));
822 }
823 let user = sqlx::query_as::<_, User>(&format!(
824 "UPDATE users SET display_name = $2, email = $3, role = $4, active = $5
825 WHERE id = $1 RETURNING {USER_COLUMNS}"
826 ))
827 .bind(id)
828 .bind(&display_name)
829 .bind(&email)
830 .bind(upd.role.as_str())
831 .bind(upd.active)
832 .fetch_one(&mut *tx)
833 .await?;
834 if !user.active {
835 sqlx::query("DELETE FROM sessions WHERE user_id = $1")
836 .bind(id)
837 .execute(&mut *tx)
838 .await?;
839 }
840 audit::record(
841 &mut tx,
842 Some(actor),
843 "user.update",
844 "user",
845 Some(id),
846 format!(
847 "Updated user '{}' (role {} → {}, active {} → {})",
848 user.username, current.role, user.role, current.active, user.active
849 ),
850 json!({ "role": user.role, "active": user.active }),
851 )
852 .await?;
853 tx.commit().await?;
854 Ok(user)
855}
856
857pub async fn set_password(
870 pool: &PgPool,
871 actor: Option<&Actor>,
872 id: Uuid,
873 password: &str,
874) -> AppResult<()> {
875 if let Some(actor) = actor {
876 actor.require(Role::Admin)?;
877 }
878 let user = get(pool, id).await?;
879 if user.auth_source != "local" {
880 return Err(AppError::BadRequest(
881 "This account signs in via single sign-on and has no local password.".into(),
882 ));
883 }
884 domain::validate_password(password)?;
885 let hash = auth::hash_password_async(password.to_string()).await?;
886 let mut tx = pool.begin().await?;
887 sqlx::query("UPDATE users SET password_hash = $2 WHERE id = $1")
888 .bind(id)
889 .bind(&hash)
890 .execute(&mut *tx)
891 .await?;
892 sqlx::query("DELETE FROM sessions WHERE user_id = $1")
893 .bind(id)
894 .execute(&mut *tx)
895 .await?;
896 audit::record(
897 &mut tx,
898 actor,
899 "user.password",
900 "user",
901 Some(id),
902 format!("Password of '{}' was reset", user.username),
903 json!({}),
904 )
905 .await?;
906 tx.commit().await?;
907 Ok(())
908}
909
910pub async fn change_own_password(
939 pool: &PgPool,
940 throttle: &auth::SignInThrottle,
941 actor: &Actor,
942 current: &str,
943 new: &str,
944 keep_session: Option<&str>,
945) -> AppResult<()> {
946 if get(pool, actor.id).await?.auth_source != "local" {
947 return Err(AppError::BadRequest(
948 "This account signs in via single sign-on and has no local password.".into(),
949 ));
950 }
951 let keys = auth::SignInThrottle::keys(&actor.username, None);
952 if let Some(wait) = throttle.blocked(&keys) {
953 let minutes = wait.as_secs().div_ceil(60).max(1);
954 tracing::warn!(username = %actor.username, "own password change throttled");
955 return Err(AppError::TooManyRequests {
956 message: format!(
957 "Too many failed attempts. Try again in {minutes} minute{}.",
958 if minutes == 1 { "" } else { "s" }
959 ),
960 retry_after: wait,
961 });
962 }
963 if authenticate_local(pool, &actor.username, current)
964 .await?
965 .is_none()
966 {
967 throttle.failed(&keys);
968 record_failed_password_change(pool, actor).await?;
969 return Err(AppError::BadRequest(
970 "The current password is wrong.".into(),
971 ));
972 }
973 throttle.succeeded(&actor.username);
974 domain::validate_password(new)?;
975 let hash = auth::hash_password_async(new.to_string()).await?;
976 let mut tx = pool.begin().await?;
977 sqlx::query("UPDATE users SET password_hash = $2 WHERE id = $1")
978 .bind(actor.id)
979 .bind(&hash)
980 .execute(&mut *tx)
981 .await?;
982 let ended =
983 sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND token_hash IS DISTINCT FROM $2")
984 .bind(actor.id)
985 .bind(keep_session.map(auth::hash_token))
986 .execute(&mut *tx)
987 .await?
988 .rows_affected();
989 audit::record(
990 &mut tx,
991 Some(actor),
992 "user.password",
993 "user",
994 Some(actor.id),
995 "Changed own password",
996 json!({ "other_sessions_ended": ended }),
997 )
998 .await?;
999 tx.commit().await?;
1000 Ok(())
1001}
1002
1003pub const REAUTH_WINDOW_MINUTES: i64 = 10;
1006
1007pub async fn confirm_own_password(
1017 pool: &PgPool,
1018 throttle: &auth::SignInThrottle,
1019 actor: &Actor,
1020 password: &str,
1021) -> AppResult<()> {
1022 if get(pool, actor.id).await?.auth_source != "local" {
1023 return Err(AppError::BadRequest(
1024 "This account signs in via single sign-on: confirm by signing in again.".into(),
1025 ));
1026 }
1027 let keys = auth::SignInThrottle::keys(&actor.username, None);
1028 if let Some(wait) = throttle.blocked(&keys) {
1029 let minutes = wait.as_secs().div_ceil(60).max(1);
1030 return Err(AppError::TooManyRequests {
1031 message: format!(
1032 "Too many failed attempts. Try again in {minutes} minute{}.",
1033 if minutes == 1 { "" } else { "s" }
1034 ),
1035 retry_after: wait,
1036 });
1037 }
1038 if authenticate_local(pool, &actor.username, password)
1039 .await?
1040 .is_none()
1041 {
1042 throttle.failed(&keys);
1043 record_failed_password_change(pool, actor).await?;
1044 return Err(AppError::BadRequest("The password is wrong.".into()));
1045 }
1046 throttle.succeeded(&actor.username);
1047 Ok(())
1048}
1049
1050pub async fn session_recently_reauthenticated(
1053 pool: &PgPool,
1054 user_id: Uuid,
1055 token: &str,
1056) -> AppResult<bool> {
1057 Ok(sqlx::query_scalar::<_, bool>(
1058 "SELECT EXISTS(SELECT 1 FROM sessions
1059 WHERE token_hash = $1 AND user_id = $2 AND expires_at > now()
1060 AND reauth_at > now() - make_interval(mins => $3::int))",
1061 )
1062 .bind(auth::hash_token(token))
1063 .bind(user_id)
1064 .bind(REAUTH_WINDOW_MINUTES as i32)
1065 .fetch_one(pool)
1066 .await?)
1067}
1068
1069pub async fn mark_session_reauthenticated(
1072 pool: &PgPool,
1073 user_id: Uuid,
1074 token_hash: &str,
1075) -> AppResult<bool> {
1076 let me = actor(pool, user_id).await?;
1077 let mut tx = pool.begin().await?;
1078 let marked = sqlx::query(
1079 "UPDATE sessions SET reauth_at = now()
1080 WHERE token_hash = $1 AND user_id = $2 AND expires_at > now()",
1081 )
1082 .bind(token_hash)
1083 .bind(user_id)
1084 .execute(&mut *tx)
1085 .await?
1086 .rows_affected()
1087 == 1;
1088 if marked {
1089 audit::record(
1090 &mut tx,
1091 Some(&me),
1092 "user.reauthenticate",
1093 "user",
1094 Some(user_id),
1095 "Signed in again with single sign-on to confirm a decision",
1096 json!({}),
1097 )
1098 .await?;
1099 }
1100 tx.commit().await?;
1101 Ok(marked)
1102}
1103
1104async fn record_failed_password_change(pool: &PgPool, actor: &Actor) -> AppResult<()> {
1108 tracing::warn!(username = %actor.username, "wrong current password at a password change");
1109 let mut tx = pool.begin().await?;
1110 audit::record(
1111 &mut tx,
1112 Some(actor),
1113 "user.password_failed",
1114 "user",
1115 Some(actor.id),
1116 "Wrong current password at a password change",
1117 json!({}),
1118 )
1119 .await?;
1120 tx.commit().await?;
1121 Ok(())
1122}
1123
1124#[derive(Clone, Debug)]
1126pub struct OidcIdentity {
1127 pub subject: String,
1128 pub username_hint: String,
1129 pub display_name: String,
1130 pub email: Option<String>,
1131 pub role: Role,
1132 pub groups: Vec<String>,
1134 pub claims: std::collections::BTreeMap<String, Vec<String>>,
1138}
1139
1140pub async fn upsert_oidc_user(pool: &PgPool, ident: OidcIdentity) -> AppResult<User> {
1157 let email = clean_email(ident.email.as_deref()).unwrap_or(None);
1158 let display_name: String = ident.display_name.trim().chars().take(128).collect();
1159 let mut tx = pool.begin().await?;
1160
1161 let existing = sqlx::query_as::<_, User>(&format!(
1162 "SELECT {USER_COLUMNS} FROM users WHERE oidc_subject = $1 FOR UPDATE"
1163 ))
1164 .bind(&ident.subject)
1165 .fetch_optional(&mut *tx)
1166 .await?;
1167
1168 let user = if let Some(existing) = existing {
1169 if !existing.active {
1170 return Err(AppError::Forbidden(
1171 "Your TeamCraft account has been deactivated by an administrator.".into(),
1172 ));
1173 }
1174 let user = sqlx::query_as::<_, User>(&format!(
1175 "UPDATE users SET display_name = $2, email = $3, role = $4, last_login_at = now()
1176 WHERE id = $1 RETURNING {USER_COLUMNS}"
1177 ))
1178 .bind(existing.id)
1179 .bind(if display_name.is_empty() {
1180 &existing.display_name
1181 } else {
1182 &display_name
1183 })
1184 .bind(&email)
1185 .bind(ident.role.as_str())
1186 .fetch_one(&mut *tx)
1187 .await?;
1188 if existing.role != user.role {
1189 audit::record(
1190 &mut tx,
1191 None,
1192 "user.role_sync",
1193 "user",
1194 Some(user.id),
1195 format!(
1196 "Role of '{}' changed from {} to {} (IdP groups)",
1197 user.username, existing.role, user.role
1198 ),
1199 json!({ "role": user.role }),
1200 )
1201 .await?;
1202 }
1203 user
1204 } else {
1205 let base = username_from_hint(&ident.username_hint);
1206 let mut username = base.clone();
1207 let mut suffix = 2;
1208 loop {
1209 let taken = sqlx::query_scalar::<_, bool>(
1210 "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)",
1211 )
1212 .bind(&username)
1213 .fetch_one(&mut *tx)
1214 .await?;
1215 if !taken {
1216 break;
1217 }
1218 if suffix > 99 {
1219 return Err(AppError::Conflict(format!(
1220 "Could not find a free username for '{base}'."
1221 )));
1222 }
1223 username = format!("{base}-{suffix}");
1224 suffix += 1;
1225 }
1226 let user = sqlx::query_as::<_, User>(&format!(
1227 "INSERT INTO users (id, username, display_name, email, auth_source, oidc_subject, role, last_login_at)
1228 VALUES ($1, $2, $3, $4, 'oidc', $5, $6, now())
1229 RETURNING {USER_COLUMNS}"
1230 ))
1231 .bind(Uuid::new_v4())
1232 .bind(&username)
1233 .bind(if display_name.is_empty() { &username } else { &display_name })
1234 .bind(&email)
1235 .bind(&ident.subject)
1236 .bind(ident.role.as_str())
1237 .fetch_one(&mut *tx)
1238 .await?;
1239 audit::record(
1240 &mut tx,
1241 None,
1242 "user.provision",
1243 "user",
1244 Some(user.id),
1245 format!(
1246 "Provisioned SSO user '{}' with role {}",
1247 user.username, user.role
1248 ),
1249 json!({ "role": user.role }),
1250 )
1251 .await?;
1252 user
1253 };
1254 sync_oidc_memberships(&mut tx, user.id, &ident.groups).await?;
1255 crate::services::membership_conditions::store_claims(&mut tx, user.id, &ident.claims).await?;
1256 tx.commit().await?;
1257 Ok(user)
1258}
1259
1260async fn sync_oidc_memberships(
1263 conn: &mut sqlx::PgConnection,
1264 user_id: Uuid,
1265 groups: &[String],
1266) -> AppResult<()> {
1267 let groups: Vec<String> = groups.iter().map(|g| g.to_ascii_lowercase()).collect();
1268 sqlx::query(
1269 "INSERT INTO project_members (project_id, user_id, source)
1270 SELECT p.id, $1, 'oidc' FROM projects p
1271 WHERE p.oidc_group IS NOT NULL AND lower(p.oidc_group) = ANY($2)
1272 ON CONFLICT (project_id, user_id) DO NOTHING",
1273 )
1274 .bind(user_id)
1275 .bind(&groups)
1276 .execute(&mut *conn)
1277 .await?;
1278 sqlx::query(
1279 "DELETE FROM project_members pm USING projects p
1280 WHERE pm.project_id = p.id AND pm.user_id = $1 AND pm.source = 'oidc'
1281 AND (p.oidc_group IS NULL OR NOT (lower(p.oidc_group) = ANY($2)))",
1282 )
1283 .bind(user_id)
1284 .bind(&groups)
1285 .execute(&mut *conn)
1286 .await?;
1287 Ok(())
1288}
1289
1290pub fn username_from_hint(hint: &str) -> String {
1292 let local_part = hint.split('@').next().unwrap_or(hint);
1293 let mut out: String = local_part
1294 .trim()
1295 .to_ascii_lowercase()
1296 .chars()
1297 .map(|c| {
1298 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
1299 c
1300 } else {
1301 '.'
1302 }
1303 })
1304 .take(60)
1305 .collect();
1306 out = out.trim_matches('.').to_string();
1307 if out.len() < 2 {
1308 out = "user".to_string();
1309 }
1310 out
1311}
1312
1313pub async fn create_first_admin(
1318 pool: &PgPool,
1319 username: &str,
1320 display_name: &str,
1321 password: &str,
1322) -> AppResult<User> {
1323 const SETUP_LOCK: i64 = 0x7465_616d_6372; let mut guard = pool.acquire().await?;
1325 sqlx::query("SELECT pg_advisory_lock($1)")
1326 .bind(SETUP_LOCK)
1327 .execute(&mut *guard)
1328 .await?;
1329 let result = async {
1330 if count(pool).await? > 0 {
1331 return Err(AppError::Conflict(
1332 "TeamCraft already has accounts – please sign in.".into(),
1333 ));
1334 }
1335 let display_name = match display_name.trim() {
1336 "" => "Administrator",
1337 name => name,
1338 };
1339 let user = create_local(
1340 pool,
1341 None,
1342 NewLocalUser {
1343 username: username.into(),
1344 display_name: display_name.into(),
1345 email: None,
1346 role: Role::Admin,
1347 password: password.into(),
1348 },
1349 )
1350 .await?;
1351 tracing::info!(username = %user.username, "created the first administrator (setup page)");
1352 Ok(user)
1353 }
1354 .await;
1355 sqlx::query("SELECT pg_advisory_unlock($1)")
1356 .bind(SETUP_LOCK)
1357 .execute(&mut *guard)
1358 .await?;
1359 result
1360}
1361
1362pub async fn ensure_bootstrap_admin(pool: &PgPool, config: &Config) -> AppResult<()> {
1363 if count(pool).await? > 0 {
1364 return Ok(());
1365 }
1366 match &config.bootstrap_admin {
1367 Some(admin) => {
1368 if !config.local_login {
1369 tracing::warn!(
1370 "TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD is set but local login is disabled; the account can only be used after enabling TEAMCRAFT_LOCAL_LOGIN"
1371 );
1372 }
1373 let user = create_local(
1374 pool,
1375 None,
1376 NewLocalUser {
1377 username: admin.username.clone(),
1378 display_name: "Administrator".into(),
1379 email: None,
1380 role: Role::Admin,
1381 password: admin.password.clone(),
1382 },
1383 )
1384 .await?;
1385 tracing::info!(username = %user.username, "created bootstrap administrator");
1386 }
1387 None if config.oidc.is_none() && config.bind_addr.ip().is_loopback() => tracing::info!(
1388 "the database has no users yet: open {} and create the administrator",
1389 config.public_url
1390 ),
1391 None if config.oidc.is_none() => tracing::warn!(
1392 "the database has no users; set TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD or run `teamcraft user create --role admin`"
1393 ),
1394 None => tracing::info!("the database has no users yet; the first SSO login provisions an account"),
1395 }
1396 Ok(())
1397}
1398
1399fn clean_email(email: Option<&str>) -> AppResult<Option<String>> {
1400 match email.map(str::trim).filter(|e| !e.is_empty()) {
1401 None => Ok(None),
1402 Some(e) if e.len() <= 254 && e.contains('@') && !e.contains(char::is_whitespace) => {
1403 Ok(Some(e.to_string()))
1404 }
1405 Some(e) => Err(AppError::BadRequest(format!(
1406 "'{e}' is not a valid e-mail address."
1407 ))),
1408 }
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413 use super::*;
1414
1415 #[test]
1416 fn usernames_from_idp_hints() {
1417 assert_eq!(username_from_hint("Max.Mustermann"), "max.mustermann");
1418 assert_eq!(username_from_hint("max@example.org"), "max");
1419 assert_eq!(username_from_hint("Jane Doe"), "jane.doe");
1420 assert_eq!(username_from_hint("ä"), "user");
1421 assert_eq!(username_from_hint(""), "user");
1422 assert!(domain::normalize_username(&username_from_hint("Ünïcødé Ñame")).is_ok());
1423 assert!(username_from_hint(&"x".repeat(200)).len() <= 60);
1424 }
1425
1426 #[test]
1427 fn email_cleanup() {
1428 assert_eq!(clean_email(None).unwrap(), None);
1429 assert_eq!(clean_email(Some(" ")).unwrap(), None);
1430 assert_eq!(
1431 clean_email(Some(" a@b.de ")).unwrap().as_deref(),
1432 Some("a@b.de")
1433 );
1434 assert!(clean_email(Some("not-an-email")).is_err());
1435 }
1436
1437 #[test]
1438 fn the_sso_idle_limit_caps_token_lifetimes() {
1439 use TokenLifetime::*;
1440 assert_eq!(Unlimited.capped(0), Unlimited);
1442 assert_eq!(Days(400).capped(0), Days(400));
1443 assert_eq!(Unlimited.capped(30), IdleDays(30));
1445 assert_eq!(Days(31).capped(30), IdleDays(30));
1446 assert_eq!(IdleDays(90).capped(30), IdleDays(30));
1447 assert_eq!(Days(30).capped(30), Days(30));
1449 assert_eq!(Days(7).capped(30), Days(7));
1450 assert_eq!(IdleDays(14).capped(30), IdleDays(14));
1451 }
1452}