feat(session): handle sessions for admin
This commit is contained in:
@@ -17,7 +17,8 @@ use crate::application::dtos::plugin_dto::{
|
||||
};
|
||||
use crate::application::dtos::settings_dto::{
|
||||
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, DriveKindUsageDto,
|
||||
ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto,
|
||||
ListSessionsQueryDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto,
|
||||
SaveStorageSettingsDto,
|
||||
SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto,
|
||||
TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto,
|
||||
};
|
||||
@@ -108,6 +109,11 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
.route("/users", post(create_user))
|
||||
.route("/users/{id}", get(get_user))
|
||||
.route("/users/{id}", delete(delete_user))
|
||||
// Session management (DPoP admin panel — see docs/plan/dpop.md
|
||||
// Gate 10). List is global cross-user with `?user_id=` narrow;
|
||||
// revoke sets `revoked=true` (row stays for audit).
|
||||
.route("/sessions", get(list_sessions))
|
||||
.route("/sessions/{id}", delete(revoke_session))
|
||||
.route("/users/{id}/role", put(update_user_role))
|
||||
.route("/users/{id}/active", put(update_user_active))
|
||||
.route("/users/{id}/quota", put(update_user_quota))
|
||||
@@ -1189,6 +1195,106 @@ pub async fn delete_user(
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/admin/sessions?user_id=&include_revoked=&limit=&offset= — list sessions
|
||||
///
|
||||
/// Global cross-user listing by default. `user_id` narrows to one
|
||||
/// user; omit for cross-user. `include_revoked=true` opts into
|
||||
/// showing revoked / expired rows for forensics (default hides).
|
||||
/// Response is `{sessions, limit, offset}` — no total count (would
|
||||
/// require a second scan; the panel paginates on presence of
|
||||
/// exactly `limit` rows returned).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/admin/sessions",
|
||||
params(
|
||||
("user_id" = Option<String>, Query, description = "Narrow to one user (UUID); omit for cross-user"),
|
||||
("include_revoked" = Option<bool>, Query, description = "Include revoked + expired rows (default false — active only)"),
|
||||
("limit" = Option<i64>, Query, description = "Max rows to return (default 100, max 500)"),
|
||||
("offset" = Option<i64>, Query, description = "Pagination offset")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of sessions"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required")
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn list_sessions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Query(query): Query<ListSessionsQueryDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
||||
|
||||
let limit = query.limit.unwrap_or(100).min(500);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
let include_revoked = query.include_revoked.unwrap_or(false);
|
||||
let user_id_filter = match query.user_id.as_deref() {
|
||||
Some(s) => Some(Uuid::parse_str(s).map_err(|_| AppError::bad_request("Invalid user_id"))?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let sessions = auth
|
||||
.auth_application_service
|
||||
.admin_list_sessions_with_perms(
|
||||
state.authorization.as_ref(),
|
||||
auth_user.id,
|
||||
user_id_filter,
|
||||
include_revoked,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"sessions": sessions,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/sessions/:id — revoke a session
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/admin/sessions/{id}",
|
||||
params(("id" = String, Path, description = "Session UUID")),
|
||||
responses(
|
||||
(status = 200, description = "Session revoked"),
|
||||
(status = 400, description = "Invalid UUID"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 404, description = "Session not found")
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn revoke_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let session_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
let auth = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
||||
|
||||
auth.auth_application_service
|
||||
.admin_revoke_session_with_perms(state.authorization.as_ref(), auth_user.id, session_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "message": "Session revoked" })),
|
||||
))
|
||||
}
|
||||
|
||||
/// PUT /api/admin/users/:id/role — change user role
|
||||
#[utoipa::path(
|
||||
put,
|
||||
|
||||
@@ -393,10 +393,19 @@ pub async fn login(
|
||||
));
|
||||
}
|
||||
|
||||
// Extract the User-Agent once — the audit lines already carry
|
||||
// `client_ip` on the request-scope span; passing both to the
|
||||
// service lets `create_session` capture them on the row so the
|
||||
// admin panel can show *who logged in from where*.
|
||||
let user_agent = headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
// Try the normal login process
|
||||
match auth_service
|
||||
.auth_application_service
|
||||
.login(dto.clone())
|
||||
.login(dto.clone(), Some(client_ip.clone()), user_agent.clone())
|
||||
.await
|
||||
{
|
||||
Ok(auth_response) => {
|
||||
@@ -547,6 +556,7 @@ pub async fn login(
|
||||
)]
|
||||
pub async fn refresh_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -568,9 +578,19 @@ pub async fn refresh_token(
|
||||
refresh_token: refresh_tok,
|
||||
};
|
||||
|
||||
// Refresh rotates the session row — capture current IP + UA so the
|
||||
// NEW row's `ip_address`/`user_agent` reflect the latest observed
|
||||
// client (see `sessions.rotate_session`). Old row keeps its own
|
||||
// capture from creation time.
|
||||
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
|
||||
let user_agent = headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
let auth_response = auth_service
|
||||
.auth_application_service
|
||||
.refresh_token(dto)
|
||||
.refresh_token(dto, Some(client_ip), user_agent)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Token refresh successful, new token issued");
|
||||
@@ -1560,6 +1580,8 @@ pub async fn oidc_unlink(
|
||||
)]
|
||||
pub async fn oidc_callback(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<OidcCallbackQueryDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state
|
||||
@@ -1579,6 +1601,16 @@ pub async fn oidc_callback(
|
||||
|
||||
tracing::info!("OIDC callback received with code");
|
||||
|
||||
// Capture IP + UA so the OIDC-minted session row lands populated
|
||||
// (admin panel would otherwise show "—" for SSO logins). Callback
|
||||
// is a browser-initiated GET after the IdP redirect, so peer is
|
||||
// the browser and User-Agent is the browser's.
|
||||
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
|
||||
let user_agent = headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
// Exchange code, validate state/nonce/PKCE, authenticate user.
|
||||
// Any Err path (expired state on refresh, consumed code on replay,
|
||||
// anti-takeover email refusal, etc.) is caught below and turned
|
||||
@@ -1587,7 +1619,13 @@ pub async fn oidc_callback(
|
||||
// mid-navigation from the IdP, not the SPA. The SPA login page
|
||||
// renders localized copy per key.
|
||||
let result = match auth_app
|
||||
.oidc_callback(&query.code, &query.state, &state.locale_registry)
|
||||
.oidc_callback(
|
||||
&query.code,
|
||||
&query.state,
|
||||
&state.locale_registry,
|
||||
Some(client_ip),
|
||||
user_agent,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
|
||||
@@ -148,6 +148,7 @@ struct RedeemQuery {
|
||||
)]
|
||||
async fn redeem_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
|
||||
Path(token): Path<String>,
|
||||
Query(query): Query<RedeemQuery>,
|
||||
RequestLocale(locale): RequestLocale,
|
||||
@@ -171,12 +172,26 @@ async fn redeem_magic_link(
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
// Capture IP + UA for the newly minted session row (admin sessions
|
||||
// panel renders these; NULLs would show as "—").
|
||||
let client_ip = crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(
|
||||
&headers,
|
||||
Some(peer),
|
||||
false,
|
||||
);
|
||||
let user_agent = headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
match auth_svc
|
||||
.auth_application_service
|
||||
.redeem_magic_link(
|
||||
&token,
|
||||
incoming_challenge.as_deref(),
|
||||
cross_browser_confirmed,
|
||||
Some(client_ip),
|
||||
user_agent,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -682,6 +682,8 @@ pub async fn login_ke1(
|
||||
)]
|
||||
pub async fn login_ke3(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(dto): Json<OpaqueLoginKe3Dto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let _svc = require_opaque_service(&state)?;
|
||||
@@ -764,12 +766,23 @@ pub async fn login_ke3(
|
||||
invalid_credentials()
|
||||
})?;
|
||||
|
||||
// Capture client IP + User-Agent so `sessions.ip_address` /
|
||||
// `user_agent` land populated instead of NULL (admin panel would
|
||||
// otherwise render "—"). Both are per-session and only refresh
|
||||
// on rotation, matching the login pattern.
|
||||
let client_ip =
|
||||
crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(&headers, Some(peer), false);
|
||||
let user_agent = headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
// Mint the session BEFORE stamping opaque_migrated_at — if the
|
||||
// session mint fails (rare, but not impossible under DB failure),
|
||||
// we don't want to have flipped the migration flag for a user
|
||||
// whose login didn't actually complete.
|
||||
let session = auth
|
||||
.mint_session_for_authenticated_user(user, dto.dpop_jkt)
|
||||
.mint_session_for_authenticated_user(user, dto.dpop_jkt, Some(client_ip), user_agent)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user