use chrono::{DateTime, Utc}; use rand::RngCore; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::models::common::{EventType, JsonValue, Provider, Scope}; use crate::models::users::{UserDevice, UserSecurityLog}; use crate::service::UserService; use crate::session::Session; use super::util::{ensure_affected, sha256_hex}; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserSessionInfo { pub id: Uuid, pub ip_address: Option, pub user_agent: Option, pub last_active_at: DateTime, pub expires_at: DateTime, pub revoked_at: Option>, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserOAuthInfo { pub id: Uuid, pub provider: Provider, pub provider_user_id: String, pub provider_username: Option, pub provider_email: Option, pub token_expires_at: Option>, pub linked_at: DateTime, pub last_used_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserPersonalAccessTokenInfo { pub id: Uuid, pub name: String, pub scopes: Vec, pub last_used_at: Option>, pub expires_at: Option>, pub revoked_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] struct UserSessionRow { id: Uuid, ip_address: Option, user_agent: Option, last_active_at: DateTime, expires_at: DateTime, revoked_at: Option>, created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] struct UserOAuthRow { id: Uuid, provider: Provider, provider_user_id: String, provider_username: Option, provider_email: Option, token_expires_at: Option>, linked_at: DateTime, last_used_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] struct UserPersonalAccessTokenRow { id: Uuid, name: String, scopes: Vec, last_used_at: Option>, expires_at: Option>, revoked_at: Option>, created_at: DateTime, updated_at: DateTime, } impl UserService { pub async fn user_devices( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); sqlx::query_as::<_, UserDevice>( "SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \ trusted, last_seen_at, created_at, updated_at FROM user_device \ WHERE user_id = $1 ORDER BY last_seen_at DESC NULLS LAST, created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_delete_device( &self, ctx: &Session, device_uid: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query("DELETE FROM user_device WHERE id = $1 AND user_id = $2") .bind(device_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "device not found") } pub async fn user_sessions( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); let rows = sqlx::query_as::<_, UserSessionRow>( "SELECT id, ip_address, user_agent, last_active_at, expires_at, revoked_at, created_at \ FROM user_session WHERE user_id = $1 ORDER BY last_active_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_revoke_session( &self, ctx: &Session, session_uid: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; // Use transaction with SELECT FOR UPDATE to prevent race conditions let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; let session = sqlx::query( "SELECT id FROM user_session WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL FOR UPDATE", ) .bind(session_uid) .bind(user_uid) .fetch_optional(&mut *txn) .await .map_err(AppError::Database)?; if session.is_none() { return Err(AppError::NotFound("session not found".into())); } sqlx::query("UPDATE user_session SET revoked_at = $1 WHERE id = $2 AND user_id = $3") .bind(chrono::Utc::now()) .bind(session_uid) .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; // Also try to delete from Redis if this is a cookie session // The session key might be stored as the session id in Redis let _ = self.ctx.cache.delete(&session_uid.to_string()).await; Ok(()) } pub async fn user_oauth_accounts( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); let rows = sqlx::query_as::<_, UserOAuthRow>( "SELECT id, provider, provider_user_id, provider_username, provider_email, \ token_expires_at, linked_at, last_used_at FROM user_oauth \ WHERE user_id = $1 ORDER BY linked_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; // Use transaction with SELECT FOR UPDATE to prevent race condition // where concurrent unlink requests could remove the last login method let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; let has_password: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)") .bind(user_uid) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; let oauth_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_oauth WHERE user_id = $1 FOR UPDATE") .bind(user_uid) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; if !has_password && oauth_count <= 1 { return Err(AppError::BadRequest( "cannot unlink the last login method; please set a password first".into(), )); } let result = sqlx::query("DELETE FROM user_oauth WHERE id = $1 AND user_id = $2") .bind(oauth_uid) .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; if result.rows_affected() == 0 { return Err(AppError::NotFound("oauth account not found".into())); } txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } pub async fn user_security_logs( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); sqlx::query_as::<_, UserSecurityLog>( "SELECT id, user_id, event_type, description, ip_address, user_agent, metadata, created_at \ FROM user_security_log WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_log_security_event( &self, user_uid: Uuid, event_type: EventType, description: Option, ip_address: Option, user_agent: Option, metadata: Option, ) -> Result<(), AppError> { sqlx::query( "INSERT INTO user_security_log (id, user_id, event_type, description, ip_address, user_agent, metadata, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(Uuid::now_v7()) .bind(user_uid) .bind(event_type) .bind(description) .bind(ip_address) .bind(user_agent) .bind(metadata) .bind(chrono::Utc::now()) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; Ok(()) } pub async fn user_personal_access_tokens( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); let rows = sqlx::query_as::<_, UserPersonalAccessTokenRow>( "SELECT id, name, scopes, last_used_at, expires_at, revoked_at, created_at, updated_at \ FROM user_personal_access_token WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_revoke_personal_access_token( &self, ctx: &Session, token_uid: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query( "UPDATE user_personal_access_token SET revoked_at = $1, updated_at = $1 \ WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", ) .bind(chrono::Utc::now()) .bind(token_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "token not found") } pub async fn user_create_personal_access_token( &self, ctx: &Session, params: CreatePersonalAccessTokenParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let mut raw_bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut raw_bytes); let raw_token = raw_bytes .iter() .map(|b| format!("{b:02x}")) .collect::(); let token_hash = sha256_hex(raw_token.as_bytes()); let id = Uuid::now_v7(); let now = chrono::Utc::now(); sqlx::query( "INSERT INTO user_personal_access_token (id, user_id, name, token_hash, scopes, expires_at, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $7)", ) .bind(id) .bind(user_uid) .bind(¶ms.name) .bind(&token_hash) .bind(¶ms.scopes) .bind(params.expires_at) .bind(now) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; Ok(CreatePersonalAccessTokenResponse { id, name: params.name, scopes: params.scopes, token: raw_token, expires_at: params.expires_at, created_at: now, }) } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CreatePersonalAccessTokenParams { pub name: String, pub scopes: Vec, pub expires_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CreatePersonalAccessTokenResponse { pub id: Uuid, pub name: String, pub scopes: Vec, pub token: String, pub expires_at: Option>, pub created_at: DateTime, } impl From for UserSessionInfo { fn from(row: UserSessionRow) -> Self { Self { id: row.id, ip_address: row.ip_address, user_agent: row.user_agent, last_active_at: row.last_active_at, expires_at: row.expires_at, revoked_at: row.revoked_at, created_at: row.created_at, } } } impl From for UserOAuthInfo { fn from(row: UserOAuthRow) -> Self { Self { id: row.id, provider: row.provider, provider_user_id: row.provider_user_id, provider_username: row.provider_username, provider_email: row.provider_email, token_expires_at: row.token_expires_at, linked_at: row.linked_at, last_used_at: row.last_used_at, } } } impl From for UserPersonalAccessTokenInfo { fn from(row: UserPersonalAccessTokenRow) -> Self { Self { id: row.id, name: row.name, scopes: row.scopes, last_used_at: row.last_used_at, expires_at: row.expires_at, revoked_at: row.revoked_at, created_at: row.created_at, updated_at: row.updated_at, } } }