Skip to content

Commit f85cc49

Browse files
authored
feat: session revoking (#7755)
* feat(labrinth): user lock db schema * feat(labrinth): check user lock state * feat(frontend): user locking * fix(labrinth): use deleted user if staff account is deleted * fix(labrinth): error text * fix(labrinth): allow perform analytics * fix(labrinth): require user auth write for creating oauth links * fix(labrinth): prevent linking paypal account if user is locked * chore(frontend): reword warning * fix: only allow admins to lock users * refactor(labrinth): user is_admin * feat(labrinth): revoke session * feat(frontend): session revoking * fix(labrinth): reorder cache purge * fix(frontend): don't show revoke session on own page * style(labrinth): fmt * fix: only allow admins to revoke sessions * refactor(labrinth): use is_admin * style(labrinth): remove unused import * remove(frontend): locked banner * fix(labrinth): also prevent reads for locked accounts * refactor(labrinth): remove redundant auth checks * style(labrinth): fmt * feat(labrinth): revoke pats for user * refactor(labrinth): endpoint mount point * refactor(labrinth): move session revoke to admin
1 parent 6b22dce commit f85cc49

13 files changed

Lines changed: 344 additions & 14 deletions

File tree

‎apps/labrinth/.sqlx/query-020b692a64fb8eef0252621a2df47f07e97cbe0b616dba4fb639525b590cf62f.json‎

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎apps/labrinth/src/database/models/pat_item.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,4 +260,38 @@ impl DBPersonalAccessToken {
260260

261261
Ok(Some(()))
262262
}
263+
264+
pub async fn remove_all_for_user(
265+
user_id: DBUserId,
266+
transaction: &mut PgTransaction<'_>,
267+
) -> Result<Vec<(DBPatId, String)>> {
268+
let pats = sqlx::query!(
269+
"
270+
DELETE FROM pats WHERE user_id = $1 RETURNING id, access_token
271+
",
272+
user_id.0
273+
)
274+
.fetch(&mut *transaction)
275+
.map_ok(|x| (DBPatId(x.id), x.access_token))
276+
.try_collect()
277+
.await
278+
.wrap_err("removing user personal access tokens")?;
279+
280+
Ok(pats)
281+
}
282+
283+
pub async fn clear_user_pats_cache(
284+
user_id: DBUserId,
285+
pats: Vec<(DBPatId, String)>,
286+
redis: &RedisPool,
287+
) -> Result<()> {
288+
Self::clear_cache(
289+
pats.into_iter()
290+
.map(|(id, token)| (Some(id), Some(token), None))
291+
.chain(std::iter::once((None, None, Some(user_id))))
292+
.collect(),
293+
redis,
294+
)
295+
.await
296+
}
263297
}

‎apps/labrinth/src/database/models/session_item.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,4 +349,20 @@ impl DBSession {
349349

350350
Ok(sessions)
351351
}
352+
353+
pub async fn clear_user_sessions_cache(
354+
user_id: DBUserId,
355+
sessions: Vec<(DBSessionId, String)>,
356+
redis: &RedisPool,
357+
) -> Result<()> {
358+
Self::clear_cache(
359+
sessions
360+
.into_iter()
361+
.map(|(id, session)| (Some(id), Some(session), None))
362+
.chain(std::iter::once((None, None, Some(user_id))))
363+
.collect(),
364+
redis,
365+
)
366+
.await
367+
}
352368
}

‎apps/labrinth/src/routes/internal/admin.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,19 @@ use tracing::trace;
2525
use xredis::RedisPool;
2626

2727
pub mod user_lock;
28+
pub mod user_sessions;
2829

2930
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
3031
cfg.service(
3132
web::scope("/admin")
3233
.service(count_download)
3334
.service(force_reindex)
3435
.service(force_reindex_project)
35-
.service(web::scope("/user").configure(user_lock::config)),
36+
.service(
37+
web::scope("/user")
38+
.configure(user_lock::config)
39+
.configure(user_sessions::config),
40+
),
3641
);
3742
}
3843

‎apps/labrinth/src/routes/internal/admin/user_lock.rs‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use xredis::RedisPool;
66
use crate::auth::get_user_from_headers;
77
use crate::database::PgPool;
88
use crate::database::models::DBUser;
9+
use crate::database::models::session_item::DBSession;
910
use crate::database::models::user_lock_item::DBUserLock;
1011
use crate::models::pats::Scopes;
1112
use crate::models::users::Role;
@@ -22,7 +23,8 @@ pub struct LockUserRequest {
2223
pub reason: String,
2324
}
2425

25-
/// Locks a user's account, preventing it from performing any write operations.
26+
/// Locks a user's account, preventing it from performing any write operations,
27+
/// and revokes all of its sessions.
2628
///
2729
/// Locking an already locked user replaces the existing lock's reason.
2830
#[utoipa::path(
@@ -69,14 +71,31 @@ pub async fn lock_user(
6971
return Err(ApiError::Auth(eyre!("cannot lock a staff account")));
7072
}
7173

72-
DBUserLock::upsert(target.id, user.id.into(), reason, &**pool)
74+
let mut txn = pool
75+
.begin()
76+
.await
77+
.wrap_internal_err("starting database transaction")?;
78+
79+
DBUserLock::upsert(target.id, user.id.into(), reason, &mut txn)
7380
.await
7481
.wrap_internal_err("locking user")?;
7582

83+
let sessions = DBSession::remove_all_for_user(target.id, &mut txn)
84+
.await
85+
.wrap_internal_err("revoking user sessions")?;
86+
87+
txn.commit()
88+
.await
89+
.wrap_internal_err("committing database transaction")?;
90+
7691
DBUser::clear_caches(&[(target.id, Some(target.username))], &redis)
7792
.await
7893
.wrap_internal_err("clearing user cache")?;
7994

95+
DBSession::clear_user_sessions_cache(target.id, sessions, &redis)
96+
.await
97+
.wrap_internal_err("clearing session cache")?;
98+
8099
Ok(())
81100
}
82101

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
use actix_web::{HttpRequest, delete, web};
2+
use eyre::eyre;
3+
use xredis::RedisPool;
4+
5+
use crate::auth::get_user_from_headers;
6+
use crate::database::PgPool;
7+
use crate::database::models::DBUser;
8+
use crate::database::models::pat_item::DBPersonalAccessToken;
9+
use crate::database::models::session_item::DBSession;
10+
use crate::models::pats::Scopes;
11+
use crate::queue::session::AuthQueue;
12+
use crate::routes::ApiError;
13+
use crate::util::error::Context as _;
14+
15+
pub fn config(cfg: &mut web::ServiceConfig) {
16+
cfg.service(revoke_user_sessions);
17+
}
18+
19+
/// Revokes all sessions and personal access tokens of a user, signing them out everywhere.
20+
#[utoipa::path(
21+
context_path = "/admin/user",
22+
tag = "moderation",
23+
responses((status = NO_CONTENT))
24+
)]
25+
#[delete("/{id}/sessions")]
26+
pub async fn revoke_user_sessions(
27+
req: HttpRequest,
28+
path: web::Path<(String,)>,
29+
pool: web::Data<PgPool>,
30+
redis: web::Data<RedisPool>,
31+
session_queue: web::Data<AuthQueue>,
32+
) -> Result<(), ApiError> {
33+
let user = get_user_from_headers(
34+
&req,
35+
&**pool,
36+
&redis,
37+
&session_queue,
38+
Scopes::SESSION_ACCESS,
39+
)
40+
.await
41+
.wrap_auth_err("authenticating API request")?
42+
.1;
43+
44+
if !user.role.is_admin() {
45+
return Err(ApiError::Auth(eyre!(
46+
"only admins can revoke user sessions"
47+
)));
48+
}
49+
50+
let target = DBUser::get(&path.into_inner().0, &**pool, &redis)
51+
.await
52+
.wrap_internal_err("fetching user from database")?
53+
.wrap_not_found_err("user not found")?;
54+
55+
let mut transaction = pool
56+
.begin()
57+
.await
58+
.wrap_internal_err("starting database transaction")?;
59+
60+
let sessions = DBSession::remove_all_for_user(target.id, &mut transaction)
61+
.await
62+
.wrap_internal_err("revoking user sessions")?;
63+
64+
let pats =
65+
DBPersonalAccessToken::remove_all_for_user(target.id, &mut transaction)
66+
.await
67+
.wrap_internal_err("revoking user personal access tokens")?;
68+
69+
transaction
70+
.commit()
71+
.await
72+
.wrap_internal_err("committing database transaction")?;
73+
74+
DBSession::clear_user_sessions_cache(target.id, sessions, &redis)
75+
.await
76+
.wrap_internal_err("clearing session cache")?;
77+
78+
DBPersonalAccessToken::clear_user_pats_cache(target.id, pats, &redis)
79+
.await
80+
.wrap_internal_err("clearing personal access token cache")?;
81+
82+
Ok(())
83+
}

‎apps/labrinth/src/routes/internal/flows.rs‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3896,16 +3896,9 @@ pub async fn authenticate_passkey_finish(
38963896
.commit()
38973897
.await
38983898
.wrap_internal_err("committing database transaction")?;
3899-
DBSession::clear_cache(
3900-
sessions
3901-
.into_iter()
3902-
.map(|(id, session)| (Some(id), Some(session), None))
3903-
.chain(std::iter::once((
3904-
None,
3905-
None,
3906-
Some(db_passkey.user_id),
3907-
)))
3908-
.collect(),
3899+
DBSession::clear_user_sessions_cache(
3900+
db_passkey.user_id,
3901+
sessions,
39093902
&redis,
39103903
)
39113904
.await

‎apps/labrinth/src/routes/internal/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
143143
moderation::external_license::update_license,
144144
admin::user_lock::lock_user,
145145
admin::user_lock::unlock_user,
146+
admin::user_sessions::revoke_user_sessions,
146147
affiliate::ingest_click,
147148
affiliate::get_all,
148149
affiliate::create,

‎packages/api-client/src/modules/labrinth/moderation/internal.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ export class LabrinthModerationInternalModule extends AbstractModule {
104104
})
105105
}
106106

107+
public async revokeUserSessions(userId: string): Promise<void> {
108+
return this.client.request<void>(`/admin/user/${userId}/sessions`, {
109+
api: 'labrinth',
110+
version: 'internal',
111+
method: 'DELETE',
112+
})
113+
}
114+
107115
public async setProjectJudgements(
108116
judgements: Labrinth.Moderation.Internal.ProjectJudgements,
109117
): Promise<void> {

‎packages/ui/src/components/user/UserPageHeader.vue‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import {
109109
InfoIcon,
110110
LockIcon,
111111
LockOpenIcon,
112+
LogOutIcon,
112113
MoreVerticalIcon,
113114
ReportIcon,
114115
} from '@modrinth/assets'
@@ -196,6 +197,10 @@ const messages = defineMessages({
196197
id: 'profile.button.unlock',
197198
defaultMessage: 'Unlock account',
198199
},
200+
revokeSessionsButton: {
201+
id: 'profile.button.revoke-sessions',
202+
defaultMessage: 'Revoke all sessions',
203+
},
199204
})
200205
201206
const props = withDefaults(
@@ -247,6 +252,7 @@ const emit = defineEmits<{
247252
openAnalytics: []
248253
editUser: []
249254
toggleLock: []
255+
revokeSessions: []
250256
}>()
251257
252258
const { formatMessage } = useVIntl()
@@ -331,6 +337,14 @@ const moreActions = computed<ButtonMenuOption[]>(() => [
331337
tone: 'red',
332338
shown: props.showStaffActions && props.isAdmin && props.user.role === 'developer',
333339
},
340+
{
341+
id: 'revoke-sessions',
342+
label: formatMessage(messages.revokeSessionsButton),
343+
icon: LogOutIcon,
344+
action: () => emit('revokeSessions'),
345+
tone: 'red',
346+
shown: props.showStaffActions && props.isAdmin && !props.isSelf,
347+
},
334348
{
335349
id: 'open-shared-instances',
336350
label: formatMessage(messages.sharedInstancesButton),

0 commit comments

Comments
 (0)