use crate::protos::auth::common::v1::{PageRequest, SessionInfo}; use crate::protos::auth::v1::ListUserSessionsResponse; use ::session::{AuthedUser, Session}; use api::http::{Path, Query, State}; use api::pagination::{Page, PageQuery, paginate}; use api::rest::{AppResponseData, NoData}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::AuthService; use crate::errors::ApiError; use crate::share::account_helpers; const SESSION_PAGE_SIZE: i32 = 100; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub enum SessionKind { WebLogin, BearerAccess, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SessionDto { pub id: Uuid, pub kind: SessionKind, pub client_ip_hash: Option, pub user_agent_hash: Option, pub device_id: Option, pub last_seen_at: OffsetDateTime, pub created_at: OffsetDateTime, pub expires_at: OffsetDateTime, pub is_revoked: bool, pub revoked_at: Option, pub is_current: bool, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CountResp { pub count: i64, } #[derive(Debug, Deserialize, utoipa::IntoParams)] #[serde(deny_unknown_fields)] pub struct SessionQuery { pub kind: Option, pub include_revoked: Option, pub limit: Option, pub offset: Option, } impl SessionQuery { fn page(&self) -> PageQuery { PageQuery { limit: self.limit, offset: self.offset, } } } pub(crate) fn session_list( listed: ListUserSessionsResponse, current: Option<&str>, ) -> Result, ApiError> { let mut out = Vec::with_capacity(listed.sessions.len()); for info in listed.sessions { out.push(session_dto(info, current)?); } Ok(out) } fn session_dto(info: SessionInfo, current: Option<&str>) -> Result { let created_at = info .created_at .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos)) .ok_or_else(|| ApiError::internal("auth service returned a session without created_at"))?; let last_seen_at = info .last_seen_at .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos)) .unwrap_or(created_at); let expires_at = info .expires_at .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos)) .unwrap_or(created_at); Ok(SessionDto { id: Uuid::parse_str(&info.session_id) .map_err(|_| ApiError::internal("auth service returned a malformed session id"))?, kind: SessionKind::WebLogin, client_ip_hash: hash_field(&info.client_ip), user_agent_hash: hash_field(&info.user_agent), device_id: None, is_current: current == Some(info.session_id.as_str()), is_revoked: info.revoked_at.is_some(), revoked_at: info .revoked_at .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos)), last_seen_at, created_at, expires_at, }) } pub(crate) fn hash_field(value: &str) -> Option { if value.trim().is_empty() { None } else { Some(account_helpers::digest(value)) } } #[utoipa::path( get, path = "/me/sessions", tag = "user", description = "列出当前账号的登录会话,并标记当前会话。", params(SessionQuery), responses( (status = 200, description = "sessions", body = AppResponseData>), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn list_sessions( State(service): State, session: Session, AuthedUser(uid): AuthedUser, Query(query): Query, ) -> Result>, ApiError> { let listed = service .accounts .list_user_sessions( uid, query.include_revoked.unwrap_or(false), true, PageRequest { page_size: SESSION_PAGE_SIZE, page_token: String::new(), }, ) .await .map_err(|error| ApiError::internal(error.to_string()))?; let current_session = session.session_id(); let sessions = session_list(listed, current_session.as_deref())?; Ok(AppResponseData::ok(paginate(sessions, &query.page())?)) } #[utoipa::path( delete, path = "/me/sessions", tag = "user", description = "吊销当前账号除当前会话以外的全部会话。", responses( (status = 200, description = "sessions revoked", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn revoke_all_sessions( State(service): State, session: Session, AuthedUser(uid): AuthedUser, ) -> Result, ApiError> { let keep = session.session_id().unwrap_or_default(); let revoked = service .accounts .revoke_user_sessions(uid, keep.as_str(), "user_logout", false) .await .map_err(|error| ApiError::internal(error.to_string()))?; Ok(AppResponseData::ok(CountResp { count: i64::from(revoked.revoked_count), })) } #[utoipa::path( delete, path = "/me/sessions/{session_id}", tag = "user", description = "吊销指定会话;会话不存在或不属于当前账号时返回 404。", params(("session_id" = Uuid, Path)), responses( (status = 200, description = "session revoked", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData), (status = 404, description = "not found", body = AppResponseData) ) )] pub async fn revoke_session( State(service): State, AuthedUser(uid): AuthedUser, Path(session_id): Path, ) -> Result, ApiError> { let revoked = service .accounts .revoke_session(uid, &session_id.to_string(), "user_revoked") .await .map_err(|error| ApiError::internal(error.to_string()))?; let _ = revoked; Ok(AppResponseData::empty()) }