Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
48005dd
Logout Everywhere
fm3 Aug 12, 2025
84d3847
changelog
fm3 Aug 12, 2025
fa0a757
Merge branch 'master' into logout-everywhere
fm3 Aug 13, 2025
cd8603c
adapt test db
fm3 Aug 13, 2025
db00d9d
Merge branch 'master' into logout-everywhere
knollengewaechs Aug 20, 2025
aa2ddcf
add account setting card to logout everywhere
knollengewaechs Aug 20, 2025
5601188
after resetting pw and email, logout user everywhere
knollengewaechs Aug 20, 2025
26c8704
rename account password to account security page
knollengewaechs Aug 20, 2025
9c1ed36
catch error
knollengewaechs Aug 21, 2025
98c4eac
add error toast for 401 hppt codes
knollengewaechs Aug 21, 2025
569259c
move error toast to tsx module
knollengewaechs Aug 21, 2025
a1b1d8c
fix imports
knollengewaechs Aug 21, 2025
2974cce
move react using function to new module
knollengewaechs Aug 21, 2025
4ee7c36
Merge branch 'master' into logout-everywhere
knollengewaechs Aug 21, 2025
0607eee
dont show error toast for sharing link requests
knollengewaechs Aug 21, 2025
2ba5c9a
clean up code
knollengewaechs Aug 21, 2025
85dc08b
address coderabbit review
knollengewaechs Aug 21, 2025
f4164ed
use replace for legacy routes
knollengewaechs Aug 21, 2025
d1fa0f8
When logging out everywhere, also invalidate DataStore tokens
fm3 Aug 25, 2025
ad16619
address review: add comment and use confirm rather than modal
knollengewaechs Aug 27, 2025
86661fb
Merge branch 'master' into logout-everywhere
knollengewaechs Aug 27, 2025
fe47435
add replace for every <navigate>
knollengewaechs Aug 27, 2025
f622925
fix publications redirect
knollengewaechs Aug 28, 2025
8d5df9b
exchange SecuredAction for UserAwareAction when creating shortLink
knollengewaechs Aug 28, 2025
ef2e01e
remove application.conf edit and fix replace for keyboard shortcuts
knollengewaechs Aug 28, 2025
4cd0f7a
fix login redirect
knollengewaechs Aug 28, 2025
7385b22
Merge branch 'master' into logout-everywhere
fm3 Sep 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ class AuthenticationController @Inject()(
organizationDAO: OrganizationDAO,
analyticsService: AnalyticsService,
userDAO: UserDAO,
tokenDAO: TokenDAO,
multiUserDAO: MultiUserDAO,
defaultMails: DefaultMails,
conf: WkConf,
Expand Down Expand Up @@ -957,6 +958,14 @@ class AuthenticationController @Inject()(
(errors, fN, lN)
}

def logoutEverywhere: Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
_ <- userDAO.logOutEverywhereByMultiUserId(request.identity._multiUser)
userIds <- userDAO.findIdsByMultiUserId(request.identity._multiUser)
_ = userIds.map(userService.removeUserFromCache)
_ <- tokenDAO.deleteDataStoreTokensForMultiUser(request.identity._multiUser)
} yield Ok
}
}

case class InviteParameters(
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/ShortLinkController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class ShortLinkController @Inject()(shortLinkDAO: ShortLinkDAO, sil: Silhouette[
extends Controller
with FoxImplicits {

def create: Action[String] = sil.SecuredAction.async(validateJson[String]) { implicit request =>
def create: Action[String] = sil.UserAwareAction.async(validateJson[String]) { implicit request =>
val longLink = request.body
val _id = ObjectId.generate
val key = RandomIDGenerator.generateBlocking(12)
Expand Down
18 changes: 12 additions & 6 deletions app/models/user/User.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ case class User(
isUnlisted: Boolean,
created: Instant = Instant.now,
lastTaskTypeId: Option[ObjectId] = None,
loggedOutEverywhereTime: Option[Instant] = None,
isDeleted: Boolean = false
) extends DBAccessContextPayload
with Identity
Expand Down Expand Up @@ -118,6 +119,7 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
r.isunlisted,
Instant.fromSql(r.created),
r.lasttasktypeid.map(ObjectId(_)),
r.loggedouteverywheretime.map(Instant.fromSql),
r.isdeleted
)
}
Expand Down Expand Up @@ -163,6 +165,9 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
parsed <- parseAll(r)
} yield parsed

def findIdsByMultiUserId(multiUserId: ObjectId): Fox[Seq[ObjectId]] =
run(q"SELECT _id FROM $existingCollectionName WHERE _multiUser = $multiUserId".as[ObjectId])

def buildSelectionPredicates(isEditableOpt: Option[Boolean],
isTeamManagerOrAdminOpt: Option[Boolean],
isAdminOpt: Option[Boolean],
Expand Down Expand Up @@ -465,26 +470,22 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
result <- resultList.headOption.toFox
} yield result

def countIdentitiesForMultiUser(multiUserId: ObjectId): Fox[Int] =
for {
resultList <- run(q"SELECT COUNT(*) FROM $existingCollectionName WHERE _multiUser = $multiUserId".as[Int])
result <- resultList.headOption.toFox
} yield result

def insertOne(u: User): Fox[Unit] =
for {
_ <- run(q"""INSERT INTO webknossos.users(
_id, _multiUser, _organization, firstName, lastName,
lastActivity, userConfiguration,
isDeactivated, isAdmin, isOrganizationOwner,
isDatasetManager, isUnlisted,
loggedOutEverywhereTime,
created, isDeleted
)
VALUES(
${u._id}, ${u._multiUser}, ${u._organization}, ${u.firstName}, ${u.lastName},
${u.lastActivity}, ${u.userConfiguration},
${u.isDeactivated}, ${u.isAdmin}, ${u.isOrganizationOwner},
${u.isDatasetManager}, ${u.isUnlisted},
${u.loggedOutEverywhereTime},
${u.created}, ${u.isDeleted}
)""".asUpdate)
} yield ()
Expand Down Expand Up @@ -586,6 +587,11 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
parsed <- Fox.combined(r.toList.map(parse))
} yield parsed

def logOutEverywhereByMultiUserId(multiUserId: ObjectId): Fox[Unit] =
for {
_ <- run(
q"""UPDATE webknossos.users SET loggedOutEverywhereTime = ${Instant.now} WHERE _multiUser = $multiUserId""".asUpdate)
} yield ()
}

class UserExperiencesDAO @Inject()(sqlClient: SqlClient, userDAO: UserDAO)(implicit ec: ExecutionContext)
Expand Down
2 changes: 1 addition & 1 deletion app/models/user/UserService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ class UserService @Inject()(conf: WkConf,
} yield updated
}

private def removeUserFromCache(userId: ObjectId): Unit =
def removeUserFromCache(userId: ObjectId): Unit =
userCache.clear(idAndAccessContextString => idAndAccessContextString._1 == userId)

def getPasswordInfo(passwordOpt: Option[String]): PasswordInfo =
Expand Down
20 changes: 18 additions & 2 deletions app/security/CombinedAuthenticatorService.scala
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package security

import com.scalableminds.util.time.Instant
import play.silhouette.api._
import play.silhouette.api.crypto.Base64AuthenticatorEncoder
import play.silhouette.api.services.{AuthenticatorResult, AuthenticatorService}
import play.silhouette.api.util.{Clock, ExtractableRequest, FingerprintGenerator, IDGenerator}
import play.silhouette.crypto.{JcaSigner, JcaSignerSettings}
import play.silhouette.impl.authenticators._
import play.silhouette.impl.authenticators.{CookieAuthenticator, _}
import models.user.UserService
import play.api.mvc._
import utils.WkConf
Expand Down Expand Up @@ -71,9 +72,24 @@ case class CombinedAuthenticatorService(cookieSettings: CookieAuthenticatorSetti
override def retrieve[B](implicit request: ExtractableRequest[B]): Future[Option[CombinedAuthenticator]] =
for {
optionCookie <- cookieAuthenticatorService.retrieve(request)
optionCookieUnlessSignedOutEverywhere <- cookieUnlessSignedOutEverywhere(optionCookie)
optionToken <- tokenAuthenticatorService.retrieve(request)
} yield {
optionCookie.map(CombinedAuthenticator(_)).orElse { optionToken.map(CombinedAuthenticator(_)) }
optionCookieUnlessSignedOutEverywhere.map(CombinedAuthenticator(_)).orElse {
optionToken.map(CombinedAuthenticator(_))
}
}

private def cookieUnlessSignedOutEverywhere(
optionCookie: Option[CookieAuthenticator]): Future[Option[CookieAuthenticator]] =
optionCookie match {
case None => Future.successful(None)
case Some(cookie) =>
for {
userOpt <- userService.retrieve(cookie.loginInfo)
loggedOutEverywhereTime = userOpt.flatMap(_.loggedOutEverywhereTime).getOrElse(Instant.zero)
cookieLastUsedTime = Instant(cookie.lastUsedDateTime.toInstant.toEpochMilli)
} yield if (cookieLastUsedTime > loggedOutEverywhereTime) Some(cookie) else None
}

// only called in token case
Expand Down
12 changes: 12 additions & 0 deletions app/security/Token.scala
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ class TokenDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
for { _ <- run(query.update(true)) } yield ()
}

def deleteDataStoreTokensForMultiUser(multiUserId: ObjectId): Fox[Unit] =
for {
_ <- run(q"""UPDATE webknossos.tokens
SET isDeleted = ${true}
WHERE tokenType = ${TokenType.DataStore}
AND loginInfo_providerKey IN (
SELECT _id
FROM webknossos.users_
WHERE _multiUser = $multiUserId
)""".asUpdate)
} yield ()

def updateEmail(oldEmail: String, newEmail: String): Fox[Unit] =
for {
_ <- run(q"""UPDATE webknossos.tokens
Expand Down
24 changes: 24 additions & 0 deletions conf/evolutions/139-logout-everywhere.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
START TRANSACTION;

do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 138, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;

DROP VIEW webknossos.userInfos;
DROP VIEW webknossos.users_;

ALTER TABLE webknossos.users ADD COLUMN loggedOutEverywhereTime TIMESTAMPTZ;

CREATE VIEW webknossos.users_ AS SELECT * FROM webknossos.users WHERE NOT isDeleted;

CREATE VIEW webknossos.userInfos AS
SELECT
u._id AS _user, m.email, u.firstName, u.lastname, o.name AS organization_name,
u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser,
u._organization, o._id AS organization_id, u.created AS user_created,
m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity, m.isEmailVerified
FROM webknossos.users_ u
JOIN webknossos.organizations_ o ON u._organization = o._id
JOIN webknossos.multiUsers_ m on u._multiUser = m._id;

UPDATE webknossos.releaseInformation SET schemaVersion = 139;

COMMIT TRANSACTION;
24 changes: 24 additions & 0 deletions conf/evolutions/reversions/139-logout-everywhere.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
START TRANSACTION;

do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 139, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;

DROP VIEW webknossos.userInfos;
DROP VIEW webknossos.users_;

ALTER TABLE webknossos.users DROP COLUMN loggedOutEverywhereTime;

CREATE VIEW webknossos.users_ AS SELECT * FROM webknossos.users WHERE NOT isDeleted;

CREATE VIEW webknossos.userInfos AS
SELECT
u._id AS _user, m.email, u.firstName, u.lastname, o.name AS organization_name,
u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser,
u._organization, o._id AS organization_id, u.created AS user_created,
m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity, m.isEmailVerified
FROM webknossos.users_ u
JOIN webknossos.organizations_ o ON u._organization = o._id
JOIN webknossos.multiUsers_ m on u._multiUser = m._id;

UPDATE webknossos.releaseInformation SET schemaVersion = 138;

COMMIT TRANSACTION;
3 changes: 2 additions & 1 deletion conf/webknossos.latest.routes
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ POST /auth/sendInvites
POST /auth/startResetPassword controllers.AuthenticationController.handleStartResetPassword()
POST /auth/changePassword controllers.AuthenticationController.changePassword()
POST /auth/resetPassword controllers.AuthenticationController.handleResetPassword()
GET /auth/logout controllers.AuthenticationController.logout()
POST /auth/logout controllers.AuthenticationController.logout()
POST /auth/logoutEverywhere controllers.AuthenticationController.logoutEverywhere()
GET /auth/sso controllers.AuthenticationController.singleSignOn(sso: String, sig: String)
GET /auth/oidc/login controllers.AuthenticationController.loginViaOpenIdConnect()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
import { EditOutlined, LockOutlined } from "@ant-design/icons";
import { changePassword, logoutUser } from "admin/rest_api";
import { Alert, Button, Col, Form, Input, Row, Space } from "antd";
import { changePassword, logoutUserEverywhere } from "admin/rest_api";
import { Alert, App, Button, Col, Form, Input, Row, Space } from "antd";
import features from "features";
import Toast from "libs/toast";
import messages from "messages";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { logoutUserAction } from "viewer/model/actions/user_actions";
import Store from "viewer/store";
import { SettingsCard } from "./helpers/settings_card";
import { SettingsTitle } from "./helpers/settings_title";
const FormItem = Form.Item;
const { Password } = Input;
import PasskeysView from "../auth/passkeys_view";
import { SettingsCard, type SettingsCardProps } from "./helpers/settings_card";

const MIN_PASSWORD_LENGTH = 8;

function AccountPasswordView() {
function AccountSecurityView() {
const navigate = useNavigate();
const [form] = Form.useForm();
const modal = App.useApp();
const confirm = modal.modal.confirm;
const [isResetPasswordVisible, setResetPasswordVisible] = useState(false);

function onFinish(formValues: Record<string, any>) {
changePassword(formValues)
.then(async () => {
Toast.success(messages["auth.reset_pw_confirmation"]);
await logoutUser();
Store.dispatch(logoutUserAction());
navigate("/auth/login");
})
.catch((error) => {
console.error("Password change failed:", error);
Toast.error("Failed to change password. Please try again.");
});
async function onFinish(formValues: Record<string, any>) {
try {
await changePassword(formValues);
Toast.success(messages["auth.reset_pw_confirmation"]);
await logoutUserEverywhere();
Store.dispatch(logoutUserAction());
navigate("/auth/login");
} catch (error) {
console.error("Password change failed:", error);
Toast.error("Failed to change password. Please try again.");
}
}

function checkPasswordsAreMatching(value: string, otherPasswordFieldKey: string[]) {
Expand Down Expand Up @@ -153,31 +154,67 @@ function AccountPasswordView() {
);
}

const handleLogoutEverywhere = () => {
confirm({
title: "Confirm Logout",
content: <p>Are you sure you want to log out on all devices?</p>,
onOk: handleLogout,
});
};
const securityItems: SettingsCardProps[] = [
{
title: "Password",
content: getPasswordComponent(),
action: (
<Button
type="default"
shape="circle"
icon={<EditOutlined />}
size="small"
onClick={handleResetPassword}
/>
),
},
{
title: "Log out everywhere",
content: (
<Button type="default" onClick={handleLogoutEverywhere}>
Log out on all devices
</Button>
),
},
];

function handleResetPassword() {
setResetPasswordVisible(!isResetPasswordVisible);
}

async function handleLogout() {
logoutUserEverywhere()
.then(() => {
Store.dispatch(logoutUserAction());
navigate("/auth/login");
})
.catch((error) => {
Toast.error("Failed to log out. See console for more details");
console.error("Logout failed:", error);
});
}

const { passkeysEnabled } = features();

return (
<div>
<SettingsTitle title="Password" description="Manage and update your password" />
<SettingsTitle
title="Security"
description="Manage your password and logins across devices"
/>
<Row gutter={[24, 24]} style={{ marginBottom: 24 }}>
<Col span={12}>
<SettingsCard
title="Password"
content={getPasswordComponent()}
action={
<Button
type="default"
shape="circle"
icon={<EditOutlined />}
size="small"
onClick={handleResetPassword}
/>
}
/>
</Col>
{securityItems.map((item) => (
<Col span={12} key={item.title}>
<SettingsCard title={item.title} content={item.content} action={item.action} />
</Col>
))}
</Row>

{passkeysEnabled && (
Expand All @@ -194,4 +231,4 @@ function AccountPasswordView() {
);
}

export default AccountPasswordView;
export default AccountSecurityView;
6 changes: 3 additions & 3 deletions frontend/javascripts/admin/account/account_settings_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const { Sider, Content } = Layout;

const BREADCRUMB_LABELS = {
token: "Auth Token",
password: "Password",
security: "Security",
profile: "Profile",
};

Expand All @@ -22,9 +22,9 @@ const MENU_ITEMS: MenuItemGroupType[] = [
label: "Profile",
},
{
key: "password",
key: "security",
icon: <SafetyOutlined />,
label: "Password",
label: "Security",
},
],
},
Expand Down
Loading