//! Authentication API handlers. use axum::{Json, extract::State}; use sqlx::PgPool; use tower_sessions::Session; use chattyness_db::{ models::{ AccountStatus, AgeCategory, AuthenticatedUser, CurrentUserResponse, GenderPreference, GuestLoginRequest, GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse, LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary, RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary, }, queries::{memberships, realms, users}, }; use chattyness_error::AppError; use chattyness_shared::{AgeConfig, GenderConfig, SignupConfig}; use crate::auth::{ AuthUser, OptionalAuthUser, session::{ SESSION_CURRENT_REALM_KEY, SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, SESSION_USER_ID_KEY, }, }; /// Get current user info. pub async fn get_current_user( State(pool): State, OptionalAuthUser(user): OptionalAuthUser, ) -> Result, AppError> { match user { Some(user) => { // Get staff role if any let staff_role = memberships::get_user_staff_role(&pool, user.id).await?; Ok(Json(CurrentUserResponse { user: Some(AuthenticatedUser { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, staff_role, }), })) } None => Ok(Json(CurrentUserResponse { user: None })), } } /// Login handler. pub async fn login( rls_conn: crate::auth::RlsConn, State(pool): State, session: Session, Json(req): Json, ) -> Result, AppError> { // Validate the request req.validate()?; // Verify credentials let user = users::verify_password_with_reset_flag(&pool, &req.username, &req.password) .await? .ok_or(AppError::InvalidCredentials)?; // Set RLS context to the authenticated user for subsequent operations rls_conn .set_user_id(user.id) .await .map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?; // Check account status if user.status != AccountStatus::Active { return Err(AppError::AccountSuspended); } // Create user summary for response let user_summary = UserSummary { id: user.id, username: user.username.clone(), display_name: user.display_name.clone(), avatar_url: user.avatar_url.clone(), }; // Handle based on login type match req.login_type { LoginType::Staff => { // Verify user is a staff member let staff_role = memberships::get_user_staff_role(&pool, user.id) .await? .ok_or(AppError::NotStaffMember)?; // Store session data session .insert(SESSION_USER_ID_KEY, user.id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session .insert(SESSION_LOGIN_TYPE_KEY, "staff") .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; // Check for forced password reset if user.force_pw_reset { session .insert(SESSION_ORIGINAL_DEST_KEY, "/staff") .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; return Ok(Json(LoginResponse { user: user_summary, redirect_url: "/password-reset".to_string(), requires_pw_reset: true, is_member: None, original_destination: Some("/staff".to_string()), staff_role: Some(staff_role), realm: None, })); } Ok(Json(LoginResponse { user: user_summary, redirect_url: "/staff".to_string(), requires_pw_reset: false, is_member: None, original_destination: None, staff_role: Some(staff_role), realm: None, })) } LoginType::Realm => { let realm_slug = req.realm_slug.as_ref().ok_or_else(|| { AppError::Validation("Realm slug is required for realm login".to_string()) })?; // Get the realm let realm = realms::get_realm_by_slug(&pool, realm_slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", realm_slug)))?; // Check if user is a member let is_member = memberships::is_member(&pool, user.id, realm.id).await?; // Store session data session .insert(SESSION_USER_ID_KEY, user.id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session .insert(SESSION_LOGIN_TYPE_KEY, "realm") .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session .insert(SESSION_CURRENT_REALM_KEY, realm.id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; let redirect_url = format!("/realms/{}", realm.slug); // Check for forced password reset if user.force_pw_reset { session .insert(SESSION_ORIGINAL_DEST_KEY, redirect_url.clone()) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; return Ok(Json(LoginResponse { user: user_summary, redirect_url: "/password-reset".to_string(), requires_pw_reset: true, is_member: Some(is_member), original_destination: Some(redirect_url), staff_role: None, realm: Some(RealmSummary { id: realm.id, name: realm.name, slug: realm.slug, tagline: realm.tagline, privacy: realm.privacy, is_nsfw: realm.is_nsfw, thumbnail_path: realm.thumbnail_path, member_count: realm.member_count, current_user_count: realm.current_user_count, }), })); } // If not a member, include realm info for join confirmation if !is_member { return Ok(Json(LoginResponse { user: user_summary, redirect_url: redirect_url.clone(), requires_pw_reset: false, is_member: Some(false), original_destination: None, staff_role: None, realm: Some(RealmSummary { id: realm.id, name: realm.name, slug: realm.slug, tagline: realm.tagline, privacy: realm.privacy, is_nsfw: realm.is_nsfw, thumbnail_path: realm.thumbnail_path, member_count: realm.member_count, current_user_count: realm.current_user_count, }), })); } // User is a member, update last visited (using RLS connection) let mut conn = rls_conn.acquire().await; memberships::update_last_visited_conn(&mut *conn, user.id, realm.id).await?; Ok(Json(LoginResponse { user: user_summary, redirect_url, requires_pw_reset: false, is_member: Some(true), original_destination: None, staff_role: None, realm: None, })) } } } /// Logout response. #[derive(Debug, serde::Serialize)] pub struct LogoutResponse { pub success: bool, pub redirect_url: String, } /// Logout handler. pub async fn logout(session: Session) -> Result, AppError> { // Flush the session (removes all data and invalidates the session) session .flush() .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; Ok(Json(LogoutResponse { success: true, redirect_url: "/".to_string(), })) } /// Signup handler. pub async fn signup( rls_conn: crate::auth::RlsConn, State(pool): State, State(signup_config): State, session: Session, Json(req): Json, ) -> Result, AppError> { // Validate the request req.validate()?; // Check username availability (can use pool for read-only checks) if users::username_exists(&pool, &req.username).await? { return Err(AppError::Conflict("Username already taken".to_string())); } // Check email availability if provided if let Some(ref email) = req.email { let email_trimmed = email.trim(); if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? { return Err(AppError::Conflict("Email already registered".to_string())); } } // Get the realm let realm = realms::get_realm_by_slug(&pool, &req.realm_slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?; // Determine gender preference based on config let gender_preference = match signup_config.gender { GenderConfig::Ask => req.gender_preference, GenderConfig::DefaultNeutral => Some(GenderPreference::GenderNeutral), GenderConfig::DefaultMale => Some(GenderPreference::GenderMale), GenderConfig::DefaultFemale => Some(GenderPreference::GenderFemale), }; // Determine age category based on config let age_category = match signup_config.age { AgeConfig::Ask => req.age_category, AgeConfig::Infer => { // Infer age from birthday if provided if let Some(birthday) = req.birthday { Some(infer_age_category_from_birthday(birthday)) } else { Some(AgeCategory::Adult) // Default to adult if no birthday } } AgeConfig::DefaultAdult => Some(AgeCategory::Adult), AgeConfig::DefaultChild => Some(AgeCategory::Child), }; // Create the user using RLS connection let email_opt = req.email.as_ref().and_then(|e| { let trimmed = e.trim(); if trimmed.is_empty() { None } else { Some(trimmed) } }); let mut conn = rls_conn.acquire().await; let user_id = users::create_user_with_preferences_conn( &mut *conn, &req.username, email_opt, req.display_name.trim(), &req.password, req.birthday, gender_preference, age_category, ) .await?; drop(conn); // Set RLS context to the new user for membership creation rls_conn .set_user_id(user_id) .await .map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?; // Create membership using RLS connection (now has user context) let mut conn = rls_conn.acquire().await; let membership_id = memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member) .await?; // Set up session (user is logged in) session .insert(SESSION_USER_ID_KEY, user_id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session .insert(SESSION_LOGIN_TYPE_KEY, "realm") .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session .insert(SESSION_CURRENT_REALM_KEY, realm.id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; let redirect_url = format!("/realms/{}", realm.slug); Ok(Json(SignupResponse { user: UserSummary { id: user_id, username: req.username, display_name: req.display_name.trim().to_string(), avatar_url: None, }, redirect_url, membership_id, })) } /// Guest login handler. /// /// Creates a real user account with the 'guest' tag. Guests are regular users /// with limited capabilities (no prop pickup, etc.) that can be reaped after 24 hours. pub async fn guest_login( State(pool): State, session: Session, Json(req): Json, ) -> Result, AppError> { // Validate the request req.validate()?; // Get the realm let realm = realms::get_realm_by_slug(&pool, &req.realm_slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?; // Check if realm allows guest access if !realm.allow_guest_access { return Err(AppError::Forbidden( "This realm does not allow guest access".to_string(), )); } // Generate guest name let guest_name = users::generate_guest_name(); // Create guest user (no password) - trigger creates avatar automatically let user_id = users::create_guest_user(&pool, &guest_name).await?; // Set up tower session (same as regular user login) session .insert(SESSION_USER_ID_KEY, user_id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session .insert(SESSION_LOGIN_TYPE_KEY, "guest") .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session .insert(SESSION_CURRENT_REALM_KEY, realm.id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; let redirect_url = format!("/realms/{}", realm.slug); Ok(Json(GuestLoginResponse { guest_name, user_id, redirect_url, realm: RealmSummary { id: realm.id, name: realm.name, slug: realm.slug, tagline: realm.tagline, privacy: realm.privacy, is_nsfw: realm.is_nsfw, thumbnail_path: realm.thumbnail_path, member_count: realm.member_count, current_user_count: realm.current_user_count, }, })) } /// Join realm handler. pub async fn join_realm( rls_conn: crate::auth::RlsConn, State(pool): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result, AppError> { // Get the realm to verify it exists and check privacy let realm = realms::get_realm_by_id(&pool, req.realm_id) .await? .ok_or_else(|| AppError::NotFound("Realm not found".to_string()))?; // Check if user is already a member let is_member = memberships::is_member(&pool, user.id, realm.id).await?; if is_member { return Err(AppError::Conflict( "Already a member of this realm".to_string(), )); } // For private realms, don't allow direct join (would need invitation) if realm.privacy == chattyness_db::models::RealmPrivacy::Private { return Err(AppError::Forbidden( "Cannot join private realms without an invitation".to_string(), )); } // Create the membership using RLS connection (policy requires user_id = current_user_id) let mut conn = rls_conn.acquire().await; let membership_id = memberships::create_membership_conn(&mut *conn, user.id, realm.id, RealmRole::Member) .await?; Ok(Json(JoinRealmResponse { success: true, membership_id, redirect_url: format!("/realms/{}", realm.slug), })) } /// Password reset handler. pub async fn reset_password( rls_conn: crate::auth::RlsConn, session: Session, AuthUser(user): AuthUser, Json(req): Json, ) -> Result, AppError> { // Validate the request req.validate()?; // Update the password using RLS connection (required for RLS policy) let mut conn = rls_conn.acquire().await; users::update_password_conn(&mut *conn, user.id, &req.new_password).await?; // Get the original destination from session let original_dest: Option = session .get(SESSION_ORIGINAL_DEST_KEY) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; // Clear the original destination from session session .remove::(SESSION_ORIGINAL_DEST_KEY) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; let redirect_url = original_dest.unwrap_or_else(|| "/".to_string()); Ok(Json(PasswordResetResponse { success: true, redirect_url, })) } /// Register guest handler. /// /// Upgrades a guest user to a full user account with username and password. pub async fn register_guest( rls_conn: crate::auth::RlsConn, State(pool): State, session: Session, AuthUser(user): AuthUser, Json(req): Json, ) -> Result, AppError> { // Validate the request req.validate()?; // Verify user is a guest if !user.is_guest() { return Err(AppError::Forbidden( "Only guests can register for an account".to_string(), )); } // Check username availability if users::username_exists(&pool, &req.username).await? { return Err(AppError::Conflict("Username already taken".to_string())); } // Check email availability if provided if let Some(ref email) = req.email { let email_trimmed = email.trim(); if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? { return Err(AppError::Conflict("Email already registered".to_string())); } } // Get email as Option<&str> let email_opt = req.email.as_ref().and_then(|e| { let trimmed = e.trim(); if trimmed.is_empty() { None } else { Some(trimmed) } }); // Upgrade the guest to a full user using RLS connection let mut conn = rls_conn.acquire().await; users::upgrade_guest_to_user_conn(&mut *conn, user.id, &req.username, &req.password, email_opt).await?; // Update session login type from 'guest' to 'realm' session .insert(SESSION_LOGIN_TYPE_KEY, "realm") .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; Ok(Json(RegisterGuestResponse { success: true, username: req.username, })) } /// Request to update user preferences. #[derive(Debug, serde::Deserialize)] pub struct UpdatePreferencesRequest { #[serde(default)] pub birthday: Option, #[serde(default)] pub gender_preference: Option, #[serde(default)] pub age_category: Option, } /// Response after updating preferences. #[derive(Debug, serde::Serialize)] pub struct UpdatePreferencesResponse { pub success: bool, } /// Update user preferences handler. /// /// Updates the user's birthday, gender preference, and/or age category. /// These preferences are used for default avatar selection. pub async fn update_preferences( rls_conn: crate::auth::RlsConn, AuthUser(user): AuthUser, Json(req): Json, ) -> Result, AppError> { let mut conn = rls_conn.acquire().await; // Update user preferences (requires RLS for auth.users UPDATE policy) users::update_user_preferences_conn( &mut *conn, user.id, req.birthday, req.gender_preference, req.age_category, ) .await?; Ok(Json(UpdatePreferencesResponse { success: true })) } /// Infer age category from birthday. /// /// Returns `Adult` if 18 years or older, `Child` otherwise. fn infer_age_category_from_birthday(birthday: chrono::NaiveDate) -> AgeCategory { use chrono::Datelike; let today = chrono::Utc::now().date_naive(); let age = today.year() - birthday.year(); let had_birthday_this_year = (today.month(), today.day()) >= (birthday.month(), birthday.day()); let actual_age = if had_birthday_this_year { age } else { age - 1 }; if actual_age >= 18 { AgeCategory::Adult } else { AgeCategory::Child } }