Files
gitks/service/user/security.rs
T
zhenyi 420dedbc1e feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum
  tags, integrations, invitations, repo links, slash commands, stages,
  voice, webhooks
- Add PR service modules: review requests, templates
- Add repo service modules: contributors, release assets, git extras
  (archive, branch rename, commit extras, diff/merge, tag, tree)
- Add user service: social (follow/block)
- Add internal auth service
- Update existing service modules with expanded functionality
- Remove deleted IM modules: articles, delivery trace, drafts,
  follows, messages, polls, presence, reactions, threads
2026-06-10 18:49:32 +08:00

454 lines
14 KiB
Rust

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<String>,
pub user_agent: Option<String>,
pub last_active_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub revoked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[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<String>,
pub provider_email: Option<String>,
pub token_expires_at: Option<DateTime<Utc>>,
pub linked_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserPersonalAccessTokenInfo {
pub id: Uuid,
pub name: String,
pub scopes: Vec<Scope>,
pub last_used_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
struct UserSessionRow {
id: Uuid,
ip_address: Option<String>,
user_agent: Option<String>,
last_active_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
revoked_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
struct UserOAuthRow {
id: Uuid,
provider: Provider,
provider_user_id: String,
provider_username: Option<String>,
provider_email: Option<String>,
token_expires_at: Option<DateTime<Utc>>,
linked_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
struct UserPersonalAccessTokenRow {
id: Uuid,
name: String,
scopes: Vec<Scope>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
revoked_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl UserService {
pub async fn user_devices(
&self,
ctx: &Session,
limit: i64,
offset: i64,
) -> Result<Vec<UserDevice>, 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<Vec<UserSessionInfo>, 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<Vec<UserOAuthInfo>, 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<Vec<UserSecurityLog>, 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<String>,
ip_address: Option<String>,
user_agent: Option<String>,
metadata: Option<JsonValue>,
) -> 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<Vec<UserPersonalAccessTokenInfo>, 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<CreatePersonalAccessTokenResponse, AppError> {
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::<String>();
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(&params.name)
.bind(&token_hash)
.bind(&params.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<Scope>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CreatePersonalAccessTokenResponse {
pub id: Uuid,
pub name: String,
pub scopes: Vec<Scope>,
pub token: String,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
impl From<UserSessionRow> 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<UserOAuthRow> 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<UserPersonalAccessTokenRow> 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,
}
}
}