main
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<String>,
pub user_agent_hash: Option<String>,
pub device_id: Option<Uuid>,
pub last_seen_at: OffsetDateTime,
pub created_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
pub is_revoked: bool,
pub revoked_at: Option<OffsetDateTime>,
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<SessionKind>,
pub include_revoked: Option<bool>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
impl SessionQuery {
fn page(&self) -> PageQuery {
PageQuery {
limit: self.limit,
offset: self.offset,
}
}
}
pub(crate) fn session_list(
listed: ListUserSessionsResponse,
current: Option<&str>,
) -> Result<Vec<SessionDto>, 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<SessionDto, ApiError> {
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<String> {
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<Page<SessionDto>>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>)
)
)]
pub async fn list_sessions(
State(service): State<AuthService>,
session: Session,
AuthedUser(uid): AuthedUser,
Query(query): Query<SessionQuery>,
) -> Result<AppResponseData<Page<SessionDto>>, 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<CountResp>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>)
)
)]
pub async fn revoke_all_sessions(
State(service): State<AuthService>,
session: Session,
AuthedUser(uid): AuthedUser,
) -> Result<AppResponseData<CountResp>, 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<NoData>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>),
(status = 404, description = "not found", body = AppResponseData<NoData>)
)
)]
pub async fn revoke_session(
State(service): State<AuthService>,
AuthedUser(uid): AuthedUser,
Path(session_id): Path<Uuid>,
) -> Result<AppResponseData<NoData>, 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())
}