use crank_core::{ LoginOutcome, MembershipRole, User, UserId, UserSessionId, UserStatus, WorkspaceId, }; use crank_registry::{AdminSecurityAuditRequest, ConsumeAdminBootstrapContractRequest}; use serde_json::json; use time::OffsetDateTime; use tracing::instrument; use crate::{ auth::{ AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie, hash_csrf_token, hash_password, hash_session_secret, verify_password, }, error::ApiError, service::{ AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret, map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email, }, }; impl AdminService { pub async fn bootstrap_status(&self) -> Result { Ok(BootstrapStatusResponse { bootstrap_required: !self.registry.has_password_admin().await?, }) } pub async fn complete_bootstrap( &self, payload: CompleteBootstrapPayload, ) -> Result<(SessionCookie, SessionResponse), ApiError> { if payload.token.len() < 32 || payload.token.len() > 256 { self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap") .await; return Err(Self::constant_bootstrap_error()); } if payload.password.len() < 12 || payload.password.len() > 256 { self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap") .await; return Err(Self::constant_bootstrap_error()); } let now = OffsetDateTime::now_utc(); let bootstrap_token_hash = hash_access_secret(&format!("bootstrap:{}", payload.token)); if !self .registry .admin_bootstrap_contract_is_consumable(&bootstrap_token_hash, &now) .await? { self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap") .await; return Err(Self::constant_bootstrap_error()); } let password_hash = hash_password(&payload.password, &self.auth_settings.password_pepper)?; let user_id = self .registry .consume_admin_bootstrap_contract(ConsumeAdminBootstrapContractRequest { token_hash: &bootstrap_token_hash, password_hash: &password_hash, now: &now, }) .await .map_err(|_| Self::constant_bootstrap_error()); let user_id = match user_id { Ok(user_id) => user_id, Err(error) => { self.record_admin_security_audit( None, "bootstrap_rejected", "rejected", "bootstrap", ) .await; return Err(error); } }; self.record_admin_security_audit( Some(&user_id), "bootstrap_completed", "success", "bootstrap", ) .await; let user = self .registry .get_auth_user_by_id(&user_id) .await? .ok_or_else(|| ApiError::internal("bootstrap user was not found"))? .user; self.create_session_for_user(user).await } fn constant_bootstrap_error() -> ApiError { ApiError::unauthorized("bootstrap request is invalid or expired") } pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> { let password_hash = hash_password( &self.auth_settings.bootstrap_admin.password, &self.auth_settings.password_pepper, )?; let user_id = self .registry .ensure_bootstrap_user( &self.auth_settings.bootstrap_admin.email, &self.auth_settings.bootstrap_admin.display_name, &password_hash, ) .await?; self.registry .ensure_membership( &WorkspaceId::new("ws_default"), &user_id, MembershipRole::Owner, ) .await?; Ok(()) } pub async fn seed_demo_assets(&self) -> Result<(), ApiError> { let admin_user = self .registry .get_auth_user_by_email(&self.auth_settings.bootstrap_admin.email) .await? .ok_or_else(|| ApiError::internal("bootstrap admin user was not found"))?; let admin_user_id = admin_user.user.id.clone(); let default_workspace_id = WorkspaceId::new("ws_default"); self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id) .await?; Ok(()) } pub async fn get_session( &self, session_id: &UserSessionId, session_value: &str, ) -> Result, ApiError> { let secret_hash = hash_session_secret( session_id, session_value, &self.auth_settings.session_secret, ); let session = self .registry .get_user_session(session_id, &secret_hash) .await? .map(|record| AuthenticatedSession { session_id: record.session_id, user: record.user, memberships: record.memberships, current_workspace_id: record.current_workspace_id, }); Ok(session) } pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> { self.registry.touch_user_session(session_id).await?; Ok(()) } pub async fn verify_session_csrf( &self, session_id: &UserSessionId, csrf_hash: &str, ) -> Result { Ok(self .registry .verify_session_csrf(session_id, csrf_hash) .await?) } pub async fn login( &self, payload: LoginPayload, ) -> Result<(SessionCookie, SessionResponse), ApiError> { self.login_with_client_bucket(payload, "anonymous").await } pub async fn login_with_client_bucket( &self, payload: LoginPayload, client_bucket: &str, ) -> Result<(SessionCookie, SessionResponse), ApiError> { let scope_hash = hash_access_secret(&format!( "login:{}:{}", client_bucket, payload.email.trim().to_ascii_lowercase() )); if let Some(locked_until) = self .registry .login_backoff_locked_until(&scope_hash) .await? { let retry_after_ms = (locked_until - OffsetDateTime::now_utc()) .whole_milliseconds() .clamp(1, 300_000); return Err(ApiError::rate_limited_with_context( "login temporarily unavailable", json!({ "retry_after_ms": retry_after_ms, "error_code": "login_throttled", "recovery": "retry_after_delay" }), )); } let authenticated = match self.authenticate_login(&payload).await { Ok(authenticated) => authenticated, Err(error) => { let _ = self .registry .record_login_failure(&scope_hash, &OffsetDateTime::now_utc()) .await; self.record_admin_security_audit(None, "login_rejected", "rejected", "login") .await; return Err(error); } }; self.registry.reset_login_backoff(&scope_hash).await?; self.record_admin_security_audit( Some(&authenticated.user.id), "login_succeeded", "success", "login", ) .await; self.create_session_for_user(authenticated.user).await } async fn create_session_for_user( &self, user: User, ) -> Result<(SessionCookie, SessionResponse), ApiError> { let session_cookie = create_session_cookie(&self.auth_settings)?; let csrf_token = create_csrf_token_value(); let secret_hash = hash_session_secret( &session_cookie.session_id, &session_cookie.value, &self.auth_settings.session_secret, ); let csrf_hash = hash_csrf_token( &session_cookie.session_id, &csrf_token, &self.auth_settings.session_secret, ); let memberships = self.registry.list_workspaces_for_user(&user.id).await?; let default_workspace_id = memberships .iter() .find(|membership| membership.workspace.id.as_str() == "ws_default") .map(|membership| membership.workspace.id.as_str().to_owned()); let current_workspace_id = default_workspace_id.or_else(|| { memberships .first() .map(|membership| membership.workspace.id.as_str().to_owned()) }); let current_workspace_ref = current_workspace_id .as_ref() .map(|workspace_id| WorkspaceId::new(workspace_id.clone())); self.registry .create_user_session( &session_cookie.session_id, &user.id, current_workspace_ref.as_ref(), &secret_hash, Some(&csrf_hash), &session_cookie.expires_at, ) .await?; Ok(( session_cookie, SessionResponse { user, memberships, current_workspace_id, csrf_token, }, )) } async fn authenticate_login( &self, payload: &LoginPayload, ) -> Result { if let Some(identity_provider) = &self.identity_provider { return match identity_provider .login_password(crank_core::LoginPayload { email: payload.email.clone(), password: payload.password.clone(), }) .await { Ok(LoginOutcome::Authenticated(identity)) => Ok(identity), Err(error) => Err(map_identity_error(error)), }; } let user = self .registry .get_auth_user_by_email(&payload.email) .await? .ok_or_else(|| { let _ = hash_password(&payload.password, &self.auth_settings.password_pepper); ApiError::unauthorized("invalid email or password") })?; if user.user.status != UserStatus::Active { let _ = verify_password( &payload.password, &self.auth_settings.password_pepper, &user.password_hash, ); return Err(ApiError::unauthorized("invalid email or password")); } if !verify_password( &payload.password, &self.auth_settings.password_pepper, &user.password_hash, ) { return Err(ApiError::unauthorized("invalid email or password")); } Ok(crank_core::AuthenticatedIdentity { user: user.user, memberships: vec![], current_workspace_id: None, }) } pub async fn logout( &self, session_id: &UserSessionId, _session_value: &str, ) -> Result<(), ApiError> { self.registry.revoke_user_session(session_id).await?; self.record_admin_security_audit(None, "logout", "success", "session") .await; Ok(()) } #[instrument(skip(self))] pub async fn session_response( &self, session_id: &UserSessionId, session_value: &str, ) -> Result, ApiError> { let Some(session) = self.get_session(session_id, session_value).await? else { return Ok(None); }; Ok(Some(SessionResponse { user: session.user, memberships: session.memberships, current_workspace_id: session .current_workspace_id .map(|id| id.as_str().to_owned()), csrf_token: String::new(), })) } pub async fn update_profile( &self, user_id: &crank_core::UserId, session_id: &UserSessionId, current_workspace_id: Option<&WorkspaceId>, payload: UpdateProfilePayload, ) -> Result { let display_name = validate_profile_display_name(&payload.display_name)?; let email = validate_profile_email(&payload.email)?; let user = self .registry .update_user_profile(user_id, &email, &display_name) .await?; let memberships = self.registry.list_workspaces_for_user(user_id).await?; Ok(SessionResponse { user, memberships, current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()), csrf_token: self.rotate_session_csrf_token(session_id).await?, }) } pub async fn change_password( &self, user_id: &crank_core::UserId, _current_session_id: &UserSessionId, payload: ChangePasswordPayload, ) -> Result<(), ApiError> { if payload.new_password.len() < 12 || payload.new_password.len() > 256 { return Err(ApiError::validation( "new password must be between 12 and 256 characters long", )); } let user = self .registry .get_auth_user_by_id(user_id) .await? .ok_or_else(|| { ApiError::not_found_with_context( format!("user {} was not found", user_id.as_str()), json!({ "user_id": user_id.as_str() }), ) })?; if !verify_password( &payload.current_password, &self.auth_settings.password_pepper, &user.password_hash, ) { return Err(ApiError::unauthorized("current password is invalid")); } let password_hash = hash_password(&payload.new_password, &self.auth_settings.password_pepper)?; self.registry .update_user_password_and_revoke_all_sessions(user_id, &password_hash) .await?; self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password") .await; Ok(()) } async fn record_admin_security_audit( &self, actor_user_id: Option<&UserId>, action: &'static str, outcome: &'static str, source: &'static str, ) { let (request_id, trace_id) = crank_observability::current_request_correlation(); let audit_id = new_prefixed_id("audit"); let _ = self .registry .record_admin_security_audit(AdminSecurityAuditRequest { id: &audit_id, action, outcome, actor_user_id, session_id: None, request_id: request_id.as_deref(), trace_id: trace_id.as_deref(), source, }) .await; } pub async fn rotate_session_csrf_token( &self, session_id: &UserSessionId, ) -> Result { let csrf_token = create_csrf_token_value(); let csrf_hash = hash_csrf_token(session_id, &csrf_token, &self.auth_settings.session_secret); self.registry .update_user_session_csrf(session_id, &csrf_hash) .await?; Ok(csrf_token) } }