* make guest status a flag on users * add logout handlers * add logout notification for other users
623 lines
21 KiB
Rust
623 lines
21 KiB
Rust
//! 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<PgPool>,
|
|
OptionalAuthUser(user): OptionalAuthUser,
|
|
) -> Result<Json<CurrentUserResponse>, 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<PgPool>,
|
|
session: Session,
|
|
Json(req): Json<LoginRequest>,
|
|
) -> Result<Json<LoginResponse>, 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<Json<LogoutResponse>, 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<PgPool>,
|
|
State(signup_config): State<SignupConfig>,
|
|
session: Session,
|
|
Json(req): Json<SignupRequest>,
|
|
) -> Result<Json<SignupResponse>, 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<PgPool>,
|
|
session: Session,
|
|
Json(req): Json<GuestLoginRequest>,
|
|
) -> Result<Json<GuestLoginResponse>, 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<PgPool>,
|
|
AuthUser(user): AuthUser,
|
|
Json(req): Json<JoinRealmRequest>,
|
|
) -> Result<Json<JoinRealmResponse>, 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<PasswordResetRequest>,
|
|
) -> Result<Json<PasswordResetResponse>, 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<String> = session
|
|
.get(SESSION_ORIGINAL_DEST_KEY)
|
|
.await
|
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
|
|
|
// Clear the original destination from session
|
|
session
|
|
.remove::<String>(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<PgPool>,
|
|
session: Session,
|
|
AuthUser(user): AuthUser,
|
|
Json(req): Json<RegisterGuestRequest>,
|
|
) -> Result<Json<RegisterGuestResponse>, 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<chrono::NaiveDate>,
|
|
#[serde(default)]
|
|
pub gender_preference: Option<chattyness_db::models::GenderPreference>,
|
|
#[serde(default)]
|
|
pub age_category: Option<chattyness_db::models::AgeCategory>,
|
|
}
|
|
|
|
/// 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<UpdatePreferencesRequest>,
|
|
) -> Result<Json<UpdatePreferencesResponse>, 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
|
|
}
|
|
}
|