From 1ac583fc677b385bf308ec31391c0eef9a7cbab7 Mon Sep 17 00:00:00 2001 From: Till Prochaska Date: Thu, 25 May 2023 14:45:05 +0200 Subject: [PATCH 01/29] Allow users to reset their API key Fix #1027 --- aleph/model/role.py | 4 ++ aleph/tests/test_roles_api.py | 30 +++++++++ aleph/views/roles_api.py | 36 ++++++++++ ui/src/actions/index.js | 8 ++- ui/src/actions/roleActions.js | 8 +++ ui/src/components/common/ClipboardInput.jsx | 25 ++++--- ui/src/reducers/roles.js | 4 +- .../screens/SettingsScreen/SettingsScreen.jsx | 67 ++++++++++++++++++- 8 files changed, 166 insertions(+), 16 deletions(-) diff --git a/aleph/model/role.py b/aleph/model/role.py index ae698ca1ce..9248b20e2b 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -150,6 +150,10 @@ def check_password(self, secret): digest = self.password_digest or "" return check_password_hash(digest, secret) + def reset_api_key(self): + """Resets the API key""" + self.api_key = make_token() + def to_dict(self): data = self.to_dict_dates() data.update( diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 1ca37f43fa..38d43d824f 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -183,3 +183,33 @@ def test_create_on_existing_email(self): res = self.client.post("/api/2/roles", data=payload) self.assertEqual(res.status_code, 409) + + def test_reset_api_key_auth(self): + url = f"/api/2/roles/{self.rolex.id}/reset_api_key" + + # Anonymous request + res = self.client.post(url) + self.assertEqual(res.status_code, 403) + + # Authenticated request, but for a different role + _, headers = self.login() + res = self.client.post(url, headers=headers) + self.assertEqual(res.status_code, 403) + + def test_reset_api_key(self): + role, headers = self.login() + old_key = role.api_key + + url = f"/api/2/roles/{role.id}/reset_api_key" + res = self.client.post(url, headers=headers) + new_key = res.json["api_key"] + + self.assertEqual(res.status_code, 200) + self.assertNotEqual(old_key, new_key) + + url = f"/api/2/roles/{role.id}" + res = self.client.get(url, headers={"Authorization": old_key}) + self.assertEqual(res.status_code, 403) + + res = self.client.get(url, headers={"Authorization": new_key}) + self.assertEqual(res.status_code, 200) diff --git a/aleph/views/roles_api.py b/aleph/views/roles_api.py index a22546ad9c..e2ba8644cc 100644 --- a/aleph/views/roles_api.py +++ b/aleph/views/roles_api.py @@ -244,3 +244,39 @@ def update(id): db.session.commit() update_role(role) return RoleSerializer.jsonify(role) + + +@blueprint.route("/api/2/roles//reset_api_key", methods=["POST"]) +def reset_api_key(id): + """Reset the role’s API key. + --- + post: + summary: Reset API key + description: > + Reset the role’s API key. This will invalidate the current + API key and generate a new one. + parameters: + - in: path + name: id + required: true + description: role ID + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + tags: + - Role + """ + role = obj_or_404(Role.by_id(id)) + require(request.authz.can_write_role(role.id)) + + role.reset_api_key() + db.session.add(role) + db.session.commit() + update_role(role) + return RoleSerializer.jsonify(role) diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index 48b2fb67e2..056846c1e4 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -1,6 +1,12 @@ import { createAction } from 'redux-act'; -export { queryRoles, fetchRole, suggestRoles, updateRole } from './roleActions'; +export { + queryRoles, + fetchRole, + suggestRoles, + updateRole, + resetApiKey, +} from './roleActions'; export { createAlert, deleteAlert, queryAlerts } from './alertActions'; export { queryNotifications } from './notificationActions'; export { setConfigValue, dismissHint } from './configActions'; diff --git a/ui/src/actions/roleActions.js b/ui/src/actions/roleActions.js index fd0c93d378..d022eecf82 100644 --- a/ui/src/actions/roleActions.js +++ b/ui/src/actions/roleActions.js @@ -32,3 +32,11 @@ export const updateRole = asyncActionCreator( }, { name: 'UPDATE_ROLE' } ); + +export const resetApiKey = asyncActionCreator( + (role) => async () => { + const response = await endpoint.post(`roles/${role.id}/reset_api_key`); + return { id: role.id, data: response.data }; + }, + { name: 'RESET_API_KEY' } +); diff --git a/ui/src/components/common/ClipboardInput.jsx b/ui/src/components/common/ClipboardInput.jsx index 79fcf944b3..145e6c754f 100644 --- a/ui/src/components/common/ClipboardInput.jsx +++ b/ui/src/components/common/ClipboardInput.jsx @@ -30,17 +30,20 @@ export default function ClipboardInput(props) { readOnly value={props.value} rightElement={ - - + + ); @@ -378,6 +437,8 @@ const mapStateToProps = (state) => ({ }); SettingsScreen = withRouter(SettingsScreen); -SettingsScreen = connect(mapStateToProps, { updateRole })(SettingsScreen); +SettingsScreen = connect(mapStateToProps, { updateRole, resetApiKey })( + SettingsScreen +); SettingsScreen = injectIntl(SettingsScreen); export default SettingsScreen; From a2a94ae0a64b6121920adf11f3a389a0a2c6fc9e Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Thu, 11 Apr 2024 19:56:23 +0200 Subject: [PATCH 02/29] Only return API key once after it has been generated --- aleph/model/role.py | 1 - aleph/tests/test_roles_api.py | 58 +++++++++++++++++++++++++++++++---- aleph/views/roles_api.py | 8 ++++- aleph/views/serializers.py | 3 +- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/aleph/model/role.py b/aleph/model/role.py index 9248b20e2b..039513e2e4 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -164,7 +164,6 @@ def to_dict(self): "label": self.label, "email": self.email, "locale": self.locale, - "api_key": self.api_key, "is_admin": self.is_admin, "is_muted": self.is_muted, "is_tester": self.is_tester, diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 38d43d824f..bea4306896 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -67,13 +67,59 @@ def test_suggest(self): assert res.json["total"] == 0 assert len(res.json["results"]) == 0 + def test_view_auth(self): + _, other_headers = self.login(foreign_id="other") + role, _ = self.login( + foreign_id="john", + name="John Doe", + email="john@example.org", + ) + + # Unauthenticated request + res = self.client.get(f"/api/2/roles/{role.id}") + assert res.status_code == 403 + + # Authenticated but unauthorized request + res = self.client.get(f"/api/2/roles/{role.id}", headers=other_headers) + assert res.status_code == 403 + def test_view(self): - res = self.client.get("/api/2/roles/%s" % self.rolex) - assert res.status_code == 404, res - role, headers = self.login() - res = self.client.get("/api/2/roles/%s" % role.id, headers=headers) - assert res.status_code == 200, res - # assert res.json['total'] >= 6, res.json + role, headers = self.login( + foreign_id="john", + name="John Doe", + email="john@example.org", + ) + + # Authenticated and authorized request + res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) + assert res.status_code == 200 + + assert set(res.json.keys()) == { + "id", + "type", + "name", + "email", + "label", + "created_at", + "updated_at", + "is_admin", + "is_muted", + "is_tester", + "has_password", + "counts", + "shallow", + "writeable", + "links", + } + + assert res.json["id"] == str(role.id) + assert res.json["type"] == "user" + assert res.json["name"] == "John Doe" + assert res.json["email"] == "john@example.org" + assert res.json["label"] == "John Doe " + assert res.json["is_admin"] is False + assert res.json["shallow"] is False + assert res.json["writeable"] is True def test_update(self): res = self.client.post("/api/2/roles/%s" % self.rolex) diff --git a/aleph/views/roles_api.py b/aleph/views/roles_api.py index e2ba8644cc..18d13c5415 100644 --- a/aleph/views/roles_api.py +++ b/aleph/views/roles_api.py @@ -279,4 +279,10 @@ def reset_api_key(id): db.session.add(role) db.session.commit() update_role(role) - return RoleSerializer.jsonify(role) + + data = RoleSerializer().serialize(role) + # The API key usually isn’t included in API responses, but we return it + # exactly once after it has been (re-)generated. + data["api_key"] = role.api_key + + return jsonify(data) diff --git a/aleph/views/serializers.py b/aleph/views/serializers.py index fb9e7856dd..a2e75111f1 100644 --- a/aleph/views/serializers.py +++ b/aleph/views/serializers.py @@ -122,15 +122,14 @@ def _serialize(self, obj): obj.pop("is_muted", None) obj.pop("is_tester", None) obj.pop("is_blocked", None) - obj.pop("api_key", None) obj.pop("email", None) obj.pop("locale", None) obj.pop("created_at", None) obj.pop("updated_at", None) if obj["type"] != Role.USER: - obj.pop("api_key", None) obj.pop("email", None) obj.pop("locale", None) + obj.pop("api_key", None) obj.pop("password", None) return obj From 399e85f8d513070964c6e6a15b26063adb2bfead Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Mon, 15 Apr 2024 23:22:09 +0200 Subject: [PATCH 03/29] Add new UI to reset and display API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit How API keys are reset and displayed has changed since the initial version of API keys: Users will be able to view an API key exactly once after it has been created/reset. This requires a slightly different user interface. We’re also planning a few more changes to API keys in the future, and these UI changes prepare for that. --- ui/src/components/Settings/ApiKeySettings.jsx | 100 ++++++++++++++++++ ui/src/reducers/roles.js | 2 +- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/Settings/ApiKeySettings.jsx diff --git a/ui/src/components/Settings/ApiKeySettings.jsx b/ui/src/components/Settings/ApiKeySettings.jsx new file mode 100644 index 0000000000..6a4b32a832 --- /dev/null +++ b/ui/src/components/Settings/ApiKeySettings.jsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Button, Intent, Dialog, DialogBody, Card } from '@blueprintjs/core'; +import { ClipboardInput } from 'components/common'; +import { selectCurrentRole } from 'selectors'; +import { resetApiKey } from 'actions'; +import { FormattedMessage } from 'react-intl'; + +export default function ApiKeySettings() { + const dispatch = useDispatch(); + const role = useSelector(selectCurrentRole); + + const [isLoading, setIsLoading] = useState(false); + const [showConfirmation, setShowConfirmation] = useState(false); + const [apiKey, setApiKey] = useState(null); + + const onReset = async () => { + setIsLoading(true); + const { data } = await dispatch(resetApiKey(role)); + setApiKey(data.api_key); + setIsLoading(false); + }; + + const onClose = async () => { + setShowConfirmation(false); + setApiKey(null); + }; + + const resetMessage = ( + + ); + + return ( + <> + +

+ +

+ +

+ +

+ + +
+ + + {apiKey === null ? ( + +

+ +

+ +
+ ) : ( + +

+ +

+

+ +

+
+ )} +
+ + ); +} diff --git a/ui/src/reducers/roles.js b/ui/src/reducers/roles.js index 1559202aa7..6a37d8dccc 100644 --- a/ui/src/reducers/roles.js +++ b/ui/src/reducers/roles.js @@ -21,7 +21,7 @@ export default createReducer( [updateRole.COMPLETE]: (state, { id, data }) => objectLoadComplete(state, id, data), [resetApiKey.COMPLETE]: (state, { id, data }) => - objectLoadComplete(state, id, data), + objectLoadComplete(state, id, { ...data, api_key: null }), }, initialState ); From 5d96021760324a3c56a37985e2e08280f1d33d72 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Tue, 16 Apr 2024 00:00:13 +0200 Subject: [PATCH 04/29] Refactor existing settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing settings UI was a little cluttered and unstructured. We’re going to add new settings in this PR and in follow-up PRs, so I took the time to clean up the UI (both visually and implementation-wise). --- .../components/Settings/LanguageSettings.jsx | 64 +++ .../Settings/NotificationSettings.jsx | 61 +++ .../components/Settings/PasswordSettings.jsx | 193 +++++++ .../components/Settings/ProfileSettings.jsx | 86 ++++ .../screens/SettingsScreen/SettingsScreen.jsx | 472 ++---------------- .../SettingsScreen/SettingsScreen.scss | 13 +- 6 files changed, 446 insertions(+), 443 deletions(-) create mode 100644 ui/src/components/Settings/LanguageSettings.jsx create mode 100644 ui/src/components/Settings/NotificationSettings.jsx create mode 100644 ui/src/components/Settings/PasswordSettings.jsx create mode 100644 ui/src/components/Settings/ProfileSettings.jsx diff --git a/ui/src/components/Settings/LanguageSettings.jsx b/ui/src/components/Settings/LanguageSettings.jsx new file mode 100644 index 0000000000..b137944b59 --- /dev/null +++ b/ui/src/components/Settings/LanguageSettings.jsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { Classes, Card, HTMLSelect } from '@blueprintjs/core'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateRole } from 'actions'; +import { selectMetadata, selectCurrentRole } from 'selectors'; +import { showSuccessToast } from 'app/toast'; + +export default function LanguageSettings() { + const dispatch = useDispatch(); + const metadata = useSelector(selectMetadata); + const currentRole = useSelector(selectCurrentRole); + const [isLoading, setIsLoading] = useState(false); + + const options = Object.entries(metadata.app.locales).map(([code, label]) => ({ + value: code, + label, + })); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.locale.success', + defaultMessage: 'Your language preference has been updated.', + }); + + const updateLocale = async (locale) => { + const role = { + id: currentRole.id, + locale, + }; + + setIsLoading(true); + await dispatch(updateRole(role)); + setIsLoading(false); + showSuccessToast(successMessage); + }; + + const localeMessage = ( + + ); + + return ( + +

{localeMessage}

+ +

+ +

+ + +
+ ); +} diff --git a/ui/src/components/Settings/NotificationSettings.jsx b/ui/src/components/Settings/NotificationSettings.jsx new file mode 100644 index 0000000000..3cfbbd5e17 --- /dev/null +++ b/ui/src/components/Settings/NotificationSettings.jsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Classes, Card, Switch } from '@blueprintjs/core'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateRole } from 'actions'; +import { selectCurrentRole } from 'selectors'; +import { showSuccessToast } from 'app/toast'; + +export default function NotificationSettings() { + const dispatch = useDispatch(); + const currentRole = useSelector(selectCurrentRole); + const [isLoading, setIsLoading] = useState(false); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.notifications.success', + defaultMessage: 'Your notification preferences have been updated.', + }); + + const updateNotifications = async (receiveNotifications) => { + const role = { + id: currentRole.id, + is_muted: !receiveNotifications, + }; + + setIsLoading(true); + await dispatch(updateRole(role)); + setIsLoading(false); + showSuccessToast(successMessage); + }; + + return ( + +

+ +

+ +

+ +

+ + + } + onChange={(event) => updateNotifications(event.target.checked)} + disabled={isLoading} + /> +
+ ); +} diff --git a/ui/src/components/Settings/PasswordSettings.jsx b/ui/src/components/Settings/PasswordSettings.jsx new file mode 100644 index 0000000000..d3c631d9c1 --- /dev/null +++ b/ui/src/components/Settings/PasswordSettings.jsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { + Button, + Card, + DialogBody, + FormGroup, + InputGroup, + Intent, +} from '@blueprintjs/core'; +import { Dialog } from 'react-ftm'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectCurrentRole } from 'selectors'; +import { updateRole } from 'actions'; +import { showSuccessToast, showWarningToast } from 'react-ftm/utils'; + +export default function PasswordSettings() { + const dispatch = useDispatch(); + const currentRole = useSelector(selectCurrentRole); + + const [showDialog, setShowDialog] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [passwordConfirmation, setPasswordConfirmation] = useState(''); + + const changeMessage = ( + + ); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.password.success', + defaultMessage: 'Your password has been updated.', + }); + + const isValidCurrentPassword = currentPassword !== ''; + const isValidNewPassword = newPassword && newPassword.length >= 6; + const isValidPasswordConfirmation = newPassword == passwordConfirmation; + + const newPasswordIntent = + newPassword && !isValidNewPassword ? Intent.DANGER : null; + const passwordConfirmationIntent = + passwordConfirmation && !isValidPasswordConfirmation ? Intent.DANGER : null; + + const onUpdate = async (event) => { + event.preventDefault(); + + const role = { + id: currentRole.id, + current_password: currentPassword, + password: newPassword, + }; + + setIsLoading(true); + + try { + await dispatch(updateRole(role)); + setShowDialog(false); + showSuccessToast(successMessage); + setCurrentPassword(''); + setNewPassword(''); + setPasswordConfirmation(''); + } catch (error) { + showWarningToast(error.message); + } + + setIsLoading(false); + }; + + return ( + <> + +

+ +

+ + +
+ + setShowDialog(false)} + > + +
+ + } + labelFor="current_password" + helperText={ + + } + > + setCurrentPassword(event.target.value)} + required + /> + + + + } + labelFor="new_password" + intent={newPasswordIntent} + helperText={ + + } + > + setNewPassword(event.target.value)} + intent={newPasswordIntent} + required + minLength={6} + /> + + + + } + labelFor="password_confirmation" + intent={passwordConfirmationIntent} + helperText={ + passwordConfirmation && + !isValidPasswordConfirmation && ( + + ) + } + > + setPasswordConfirmation(event.target.value)} + intent={passwordConfirmationIntent} + required + minLength={6} + /> + + + +
+
+
+ + ); +} diff --git a/ui/src/components/Settings/ProfileSettings.jsx b/ui/src/components/Settings/ProfileSettings.jsx new file mode 100644 index 0000000000..c871e846d0 --- /dev/null +++ b/ui/src/components/Settings/ProfileSettings.jsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { + Button, + Classes, + Card, + FormGroup, + InputGroup, +} from '@blueprintjs/core'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateRole } from 'actions'; +import { selectCurrentRole } from 'selectors'; +import { showSuccessToast } from 'app/toast'; + +export default function ProfileSettings() { + const dispatch = useDispatch(); + const currentRole = useSelector(selectCurrentRole); + const [isLoading, setIsLoading] = useState(false); + const [name, setName] = useState(currentRole.name); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.saved', + defaultMessage: 'It’s official, your profile is updated.', + }); + + const onUpdate = async (event) => { + event.preventDefault(); + + const role = { + id: currentRole.id, + name, + }; + + setIsLoading(true); + await dispatch(updateRole(role)); + setIsLoading(false); + showSuccessToast(successMessage); + }; + + return ( + +

+ +

+ +
+ } + labelFor="name" + > + setName(event.target.value)} + /> + + + + } + labelFor="email" + helperText={ + + } + > + + + + +
+
+ ); +} diff --git a/ui/src/screens/SettingsScreen/SettingsScreen.jsx b/ui/src/screens/SettingsScreen/SettingsScreen.jsx index f62d0a8118..d5f76fb1d7 100644 --- a/ui/src/screens/SettingsScreen/SettingsScreen.jsx +++ b/ui/src/screens/SettingsScreen/SettingsScreen.jsx @@ -1,444 +1,44 @@ -import React from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { - Button, - Intent, - FormGroup, - InputGroup, - Checkbox, - Alignment, - MenuItem, - Classes, - Dialog, - DialogBody, -} from '@blueprintjs/core'; -import { Tooltip2 as Tooltip } from '@blueprintjs/popover2'; -import { connect } from 'react-redux'; - -import withRouter from 'app/withRouter'; -import { showSuccessToast } from 'app/toast'; +import { useIntl } from 'react-intl'; +import { useSelector } from 'react-redux'; +import { selectCurrentRole } from 'selectors'; import Screen from 'components/Screen/Screen'; import Dashboard from 'components/Dashboard/Dashboard'; -import ClipboardInput from 'components/common/ClipboardInput'; -import { updateRole, resetApiKey } from 'actions'; -import { selectMetadata, selectLocale, selectCurrentRole } from 'selectors'; -import SelectWrapper from 'components/common/SelectWrapper'; +import ProfileSettings from 'components/Settings/ProfileSettings'; +import NotificationSettings from 'components/Settings/NotificationSettings'; +import LanguageSettings from 'components/Settings/LanguageSettings'; +import PasswordSettings from 'components/Settings/PasswordSettings'; +import ApiKeySettings from 'components/Settings/ApiKeySettings'; import './SettingsScreen.scss'; -const messages = defineMessages({ - title: { +export default function SettingsScreen() { + const currentRole = useSelector(selectCurrentRole); + + const intl = useIntl(); + const title = intl.formatMessage({ id: 'settings.title', defaultMessage: 'Settings', - }, - save_button: { - id: 'settings.save', - defaultMessage: 'Update', - }, - name: { - id: 'settings.name', - defaultMessage: 'Name', - }, - locale: { - id: 'settings.locale', - defaultMessage: 'Language', - }, - api_key: { - id: 'settings.api_key', - defaultMessage: 'API Secret Access Key', - }, - api_key_reset: { - id: 'settings.api_key.reset', - defaultMessage: 'Reset API key', - }, - api_key_reset_success: { - id: 'settings.api_key.reset.success', - defaultMessage: 'API key reset successfully.', - }, - api_key_help: { - id: 'profileinfo.api_desc', - defaultMessage: - 'Use the API key to read and write data via remote applications.', - }, - email: { - id: 'settings.email', - defaultMessage: 'E-mail Address', - }, - email_no_change: { - id: 'settings.email.no_change', - defaultMessage: 'Your e-mail address cannot be changed', - }, - email_muted: { - id: 'settings.email.muted', - defaultMessage: 'Receive daily notification e-mails', - }, - beta_tester: { - id: 'settings.email.tester', - defaultMessage: 'Test new features before they are finished', - }, - current_password: { - id: 'settings.current_password', - defaultMessage: 'Current password', - }, - current_explain: { - id: 'settings.current_explain', - defaultMessage: 'Enter your current password to set a new one.', - }, - new_password: { - id: 'settings.new_password', - defaultMessage: 'New password', - }, - confirm: { - id: 'settings.confirm', - defaultMessage: '(confirm)', - }, - password_rules: { - id: 'settings.password.rules', - defaultMessage: 'Use at least six characters', - }, - password_mismatch: { - id: 'settings.password.missmatch', - defaultMessage: 'Passwords do not match', - }, - saved: { - id: 'settings.saved', - defaultMessage: "It's official, your profile is updated.", - }, -}); - -export class SettingsScreen extends React.Component { - constructor(props) { - super(props); - this.state = { - role: props.role, - showApiKeyResetDialog: false, - }; - this.onSave = this.onSave.bind(this); - this.onChangeInput = this.onChangeInput.bind(this); - this.onToggleMuted = this.onToggleMuted.bind(this); - this.onToggleTester = this.onToggleTester.bind(this); - this.onSelectLocale = this.onSelectLocale.bind(this); - this.onResetApiKey = this.onResetApiKey.bind(this); - this.toggleApiKeyResetDialog = this.toggleApiKeyResetDialog.bind(this); - this.renderLocale = this.renderLocale.bind(this); - } - - static getDerivedStateFromProps(props) { - return { role: props.role }; - } - - async onSave() { - const { intl } = this.props; - const { role } = this.state; - if (this.valid()) { - if (role.password === null || role.password === '') { - delete role.password; - } - await this.props.updateRole(role); - showSuccessToast(intl.formatMessage(messages.saved)); - } - } - - async onResetApiKey() { - const { intl } = this.props; - const { role } = this.state; - await this.props.resetApiKey(role); - this.toggleApiKeyResetDialog(); - showSuccessToast(intl.formatMessage(messages.api_key_reset_success)); - } - - toggleApiKeyResetDialog() { - this.setState({ showApiKeyResetDialog: !this.state.showApiKeyResetDialog }); - } - - onChangeInput({ target }) { - const { role } = this.state; - role[target.id] = target.value; - this.setState({ role }); - } - - onToggleMuted() { - const { role } = this.state; - role.is_muted = !role.is_muted; - this.setState({ role }); - } - - onToggleTester() { - const { role } = this.state; - role.is_tester = !role.is_tester; - this.setState({ role }); - } - - onSelectLocale(locale, event) { - const { role } = this.state; - event.stopPropagation(); - role.locale = locale; - this.setState({ role }); - } - - validName() { - const { - role: { name }, - } = this.state; - return name !== undefined && name !== null && name.length > 2; - } - - validPassword() { - const { - role: { password }, - } = this.state; - // if (!this.state.role.has_password) return true; - if (password === undefined || password === null || password.length === 0) { - return true; - } - return password.length > 5; - } - - validPasswordConfirm() { - const { - role: { password, passwordConfirm }, - } = this.state; - return password === passwordConfirm; - } - - valid() { - return ( - this.validName() && this.validPassword() && this.validPasswordConfirm() - ); - } - - renderLocale(locale, { handleClick, modifiers }) { - const { locales } = this.props.metadata.app; - return ( - - ); - } - - renderPassword() { - const { intl, metadata } = this.props; - const { role } = this.state; - - if (!metadata.auth.password_login_uri) { - return null; - } - const passwordIntent = this.validPassword() ? undefined : Intent.DANGER; - const confirm = this.validPasswordConfirm(); - const confirmIntent = confirm ? undefined : Intent.DANGER; - const confirmHelper = confirm - ? undefined - : intl.formatMessage(messages.password_mismatch); - return ( - <> -

- -

- - - - - - - - - - - ); - } - - renderForm() { - const { intl, metadata } = this.props; - const { role } = this.state; - const nameIntent = this.validName() ? undefined : Intent.DANGER; - const locales = Object.keys(metadata.app.locales); - if (!role.id) { - return null; - } - return ( -
- - - - - -
- ); - } - - render() { - const { intl } = this.props; - return ( - - -
-
- {intl.formatMessage(messages.title)} -
-
- {this.renderForm()} - - -

- {chunks} }} - /> -

- -
-
-
-
- ); - } + }); + + return ( + + +
+
{title}
+
+ +
+ {(!currentRole.isPending || currentRole.id) && ( + <> + + + + + + + )} +
+
+
+ ); } - -const mapStateToProps = (state) => ({ - metadata: selectMetadata(state), - role: { - ...selectCurrentRole(state), - locale: selectLocale(state), - }, -}); - -SettingsScreen = withRouter(SettingsScreen); -SettingsScreen = connect(mapStateToProps, { updateRole, resetApiKey })( - SettingsScreen -); -SettingsScreen = injectIntl(SettingsScreen); -export default SettingsScreen; diff --git a/ui/src/screens/SettingsScreen/SettingsScreen.scss b/ui/src/screens/SettingsScreen/SettingsScreen.scss index d53f018378..3c141130e2 100644 --- a/ui/src/screens/SettingsScreen/SettingsScreen.scss +++ b/ui/src/screens/SettingsScreen/SettingsScreen.scss @@ -1,11 +1,10 @@ @import 'app/variables.scss'; -.SettingsScreen { - .settings-form { - max-width: 750px; +.SettingsScreen__cards { + max-width: 720px; + padding-bottom: 1px; +} - &__submit { - margin-top: $aleph-grid-size; - } - } +.SettingsScreen__cards > * + * { + margin-top: 2.5 * $aleph-grid-size; } From b13425cea1b8f30af444bb36fd4a16bb818ec7e7 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:40:33 +0200 Subject: [PATCH 05/29] Ensure that toasts are always visible, even when scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a hacky workaround, but a proper fix would require quite some refactoring. Considering that this hack is pretty isolated and not going to affect any other parts of the UI and that we will need to upgrade to Blueprint 5 at some point anyway, I’ve opted for the quick-and-dirty solution for now. --- ui/src/app/App.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/app/App.scss b/ui/src/app/App.scss index 064f045dfb..cb82bce4f1 100644 --- a/ui/src/app/App.scss +++ b/ui/src/app/App.scss @@ -141,8 +141,11 @@ a { ); } -/* This causes toasts to appear *below* the navbar instead of on top of it */ .aleph-toaster { + /* Hacky workaround to ensure toasts are visible even when scrolling */ + position: fixed !important; + + /* This causes toasts to appear *below* the navbar instead of on top of it */ margin-top: $aleph-grid-size * 5; } From 8065afa6053fb3cef152c0f399aa1446027e835a Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Tue, 16 Apr 2024 19:45:11 +0200 Subject: [PATCH 06/29] Do not display password setting when password auth is disabled --- ui/src/screens/SettingsScreen/SettingsScreen.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/src/screens/SettingsScreen/SettingsScreen.jsx b/ui/src/screens/SettingsScreen/SettingsScreen.jsx index d5f76fb1d7..66ad857c16 100644 --- a/ui/src/screens/SettingsScreen/SettingsScreen.jsx +++ b/ui/src/screens/SettingsScreen/SettingsScreen.jsx @@ -1,6 +1,6 @@ import { useIntl } from 'react-intl'; import { useSelector } from 'react-redux'; -import { selectCurrentRole } from 'selectors'; +import { selectCurrentRole, selectMetadata } from 'selectors'; import Screen from 'components/Screen/Screen'; import Dashboard from 'components/Dashboard/Dashboard'; import ProfileSettings from 'components/Settings/ProfileSettings'; @@ -13,6 +13,7 @@ import './SettingsScreen.scss'; export default function SettingsScreen() { const currentRole = useSelector(selectCurrentRole); + const metadata = useSelector(selectMetadata); const intl = useIntl(); const title = intl.formatMessage({ @@ -33,7 +34,7 @@ export default function SettingsScreen() { - + {metadata.auth.password_login_uri && } )} From 688bb96618fced055dd627b1d5f8d6287a83b8da Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:19:09 +0200 Subject: [PATCH 07/29] Use session tokens for authentication in API tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the future, roles won’t have an API key by default anymore. As an alternative, we generate session tokens explicitly. --- aleph/tests/util.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/aleph/tests/util.py b/aleph/tests/util.py index ed4bfced58..878fb77661 100644 --- a/aleph/tests/util.py +++ b/aleph/tests/util.py @@ -137,7 +137,11 @@ def login(self, foreign_id="tester", name=None, email=None, is_admin=False): role = self.create_user( foreign_id=foreign_id, name=name, email=email, is_admin=is_admin ) - headers = {"Authorization": role.api_key} + + authz = Authz.from_role(role) + token = authz.to_token() + + headers = {"Authorization": f"Token {token}"} return role, headers def create_collection(self, creator=None, **kwargs): From b3eac7f215f093ecd2b1fa668c36d47e53270bd4 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Thu, 2 May 2024 09:52:24 +0200 Subject: [PATCH 08/29] Do not generate API tokens for new roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most users do not need API access so there’s no reason to generate an API key for them by default. --- aleph/model/role.py | 3 - aleph/tests/test_role_model.py | 21 +++++++ aleph/tests/test_roles_api.py | 38 ++++++++++++- aleph/tests/test_sessions_api.py | 19 ++++++- aleph/tests/test_view_context.py | 98 ++++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 aleph/tests/test_view_context.py diff --git a/aleph/model/role.py b/aleph/model/role.py index 039513e2e4..c07a23317c 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -214,9 +214,6 @@ def load_or_create(cls, foreign_id, type_, name, email=None, is_admin=False): role.is_blocked = False role.notified_at = datetime.utcnow() - if role.api_key is None: - role.api_key = make_token() - if email is not None: role.email = email diff --git a/aleph/tests/test_role_model.py b/aleph/tests/test_role_model.py index 80e6aa8946..67aeb8a4be 100644 --- a/aleph/tests/test_role_model.py +++ b/aleph/tests/test_role_model.py @@ -77,3 +77,24 @@ def test_remove_role(self): # Remove the user from the group group_role.remove_role(user_role) assert user_role not in group_role.roles + + def test_role_by_api_key(self): + role_ = self.create_user() + role_.reset_api_key() + db.session.add(role_) + db.session.commit() + assert role_.api_key is not None + + role = Role.by_api_key(role_.api_key) + assert role is not None + assert role.id == role_.id + + def test_role_by_api_key_empty(self): + role_ = self.create_user() + assert role_.api_key is None + + role = Role.by_api_key(None) + assert role is None + + role = Role.by_api_key("") + assert role is None diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index bea4306896..0ec9cf4965 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -244,18 +244,50 @@ def test_reset_api_key_auth(self): def test_reset_api_key(self): role, headers = self.login() - old_key = role.api_key + assert role.api_key is None + # Generate initial API key for new user url = f"/api/2/roles/{role.id}/reset_api_key" res = self.client.post(url, headers=headers) new_key = res.json["api_key"] + assert res.status_code == 200 + assert new_key is not None - self.assertEqual(res.status_code, 200) - self.assertNotEqual(old_key, new_key) + # The new API key can be used for authentication + url = f"/api/2/roles/{role.id}" + res = self.client.get(url, headers={"Authorization": new_key}) + assert res.status_code == 200 + old_key = new_key + + # Regenerate API key + url = f"/api/2/roles/{role.id}/reset_api_key" + res = self.client.post(url, headers=headers) + new_key = res.json["api_key"] + + assert res.status_code == 200 + assert new_key is not None + assert new_key != old_key + + # Old key cannot be used for authentication anymore url = f"/api/2/roles/{role.id}" res = self.client.get(url, headers={"Authorization": old_key}) self.assertEqual(res.status_code, 403) + # New key can be used for authentication res = self.client.get(url, headers={"Authorization": new_key}) self.assertEqual(res.status_code, 200) + + def test_new_roles_no_api_key(self): + SETTINGS.PASSWORD_LOGIN = True + email = "john.doe@example.org" + data = { + "password": "12345678", + "code": Role.SIGNATURE.dumps(email), + } + res = self.client.post("/api/2/roles", data=data) + assert res.status_code == 201 + + role = Role.by_email(email) + assert role is not None + assert role.api_key is None diff --git a/aleph/tests/test_sessions_api.py b/aleph/tests/test_sessions_api.py index fcef587cb3..7a36321609 100644 --- a/aleph/tests/test_sessions_api.py +++ b/aleph/tests/test_sessions_api.py @@ -11,14 +11,13 @@ from aleph.logic.collections import update_collection from aleph.views.base_api import _metadata_locale from aleph.tests.util import TestCase -from aleph.tests.factories.models import RoleFactory from aleph.oauth import oauth class SessionsApiTestCase(TestCase): def setUp(self): super().setUp() - self.role = RoleFactory.create() + self.role = self.create_user() def test_admin_all_access(self): self.wl = Collection() @@ -122,6 +121,22 @@ def test_password_login_post_blocked_user(self): assert res.status_code == 403, res assert res.json["message"] == "Your account has been blocked." + def test_password_login_no_api_key(self): + SETTINGS.PASSWORD_LOGIN = True + secret = self.fake.password() + self.role.set_password(secret) + data = { + "email": self.role.email, + "password": secret, + } + assert self.role.api_key is None + + res = self.client.post("/api/2/sessions/login", data=data) + assert res.status_code == 200 + + db.session.refresh(self.role) + assert self.role.api_key is None + class SessionsApiOAuthTestCase(TestCase): def setUp(self): diff --git a/aleph/tests/test_view_context.py b/aleph/tests/test_view_context.py new file mode 100644 index 0000000000..11cf3f007d --- /dev/null +++ b/aleph/tests/test_view_context.py @@ -0,0 +1,98 @@ +from aleph.core import db +from aleph.tests.util import TestCase + + +class ViewContextTest(TestCase): + def setUp(self): + super().setUp() + self.role = self.create_user(email="john.doe@example.org") + self.role.set_password("12345678") + self.role.reset_api_key() + + self.other_role = self.create_user( + foreign_id="other", + email="jane.doe@example.org", + ) + assert self.other_role.api_key is None + + db.session.add(self.role) + db.session.add(self.other_role) + db.session.commit() + + def test_authz_header_session_token(self): + data = { + "email": "john.doe@example.org", + "password": "12345678", + } + res = self.client.post("/api/2/sessions/login", data=data) + assert res.status_code == 200 + token = res.json["token"] + + headers = {"Authorization": f"Token {token}"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + def test_authz_header_session_token_invalid(self): + headers = {"Authorization": "Token INVALID"} + res = self.client.get("/api/2/metadata", headers=headers) + assert res.status_code == 401 + + headers = {"Authorization": "Token INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 401 + + headers = {"Authorization": "Token "} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 401 + + def test_authz_header_api_key(self): + headers = {"Authorization": f"ApiKey {self.role.api_key}"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + headers = {"Authorization": self.role.api_key} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + def test_authz_header_api_key_invalid(self): + # The API behavior is a little weird in this case. When passing an invalid API key we do + # not immediately raise an auth error. Instead, we treat the request as an unauthenticated/ + # anonymous request. Only when trying to access a protected resource, we raise a 403 error. + # Keeping this behavior for backwards compatibility. + headers = {"Authorization": "ApiKey INVALID"} + res = self.client.get("/api/2/metadata", headers=headers) + assert res.status_code == 200 + + headers = {"Authorization": "ApiKey INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + headers = {"Authorization": "ApiKey "} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + headers = {"Authorization": ""} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + headers = {"Authorization": "INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + def test_authz_url_param_api_key(self): + query_string = {"api_key": self.role.api_key} + res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + def test_authz_url_params_api_key_invalid(self): + query_string = {"api_key": "INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string) + assert res.status_code == 403 + + query_string = {"api_key": ""} + res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string) + assert res.status_code == 403 From d7420bec283b98e5b1efdb596751d147ebc6b79a Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 1 May 2024 17:41:32 +0200 Subject: [PATCH 09/29] Handle users without an API key properly in the settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, an API was generate automatically for new users, i.e. every user had an API key. This has now changed, and the settings UI needs to properly handle situations where a user doesn’t yet have an API key. As this increases the complexity of the UI state, I’ve refactored the component to make use of a local reducer. --- aleph/model/role.py | 5 + aleph/tests/test_roles_api.py | 16 ++ aleph/views/serializers.py | 1 + ui/src/components/Settings/ApiKeySettings.jsx | 100 -------- ui/src/components/Settings/ApiKeySettings.tsx | 213 ++++++++++++++++++ 5 files changed, 235 insertions(+), 100 deletions(-) delete mode 100644 ui/src/components/Settings/ApiKeySettings.jsx create mode 100644 ui/src/components/Settings/ApiKeySettings.tsx diff --git a/aleph/model/role.py b/aleph/model/role.py index c07a23317c..d2f12319b9 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -68,6 +68,10 @@ class Role(db.Model, IdModel, SoftDeleteModel): def has_password(self): return self.password_digest is not None + @property + def has_api_key(self): + return self.api_key is not None + @property def is_public(self): return self.id in self.public_roles() @@ -168,6 +172,7 @@ def to_dict(self): "is_muted": self.is_muted, "is_tester": self.is_tester, "has_password": self.has_password, + "has_api_key": self.has_api_key, # 'notified_at': self.notified_at } ) diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 0ec9cf4965..4208a45fc9 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -106,6 +106,7 @@ def test_view(self): "is_muted", "is_tester", "has_password", + "has_api_key", "counts", "shallow", "writeable", @@ -121,6 +122,21 @@ def test_view(self): assert res.json["shallow"] is False assert res.json["writeable"] is True + def test_view_api_key(self): + role, headers = self.login(foreign_id="john", email="john.doe@example.org") + + res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["has_api_key"] is False + + role.reset_api_key() + db.session.add(role) + db.session.commit() + + res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["has_api_key"] is True + def test_update(self): res = self.client.post("/api/2/roles/%s" % self.rolex) assert res.status_code == 404, res diff --git a/aleph/views/serializers.py b/aleph/views/serializers.py index a2e75111f1..5685ef9a17 100644 --- a/aleph/views/serializers.py +++ b/aleph/views/serializers.py @@ -118,6 +118,7 @@ def _serialize(self, obj): obj["shallow"] = obj.get("shallow", True) if self.nested or not obj["writeable"]: obj.pop("has_password", None) + obj.pop("has_api_key", None) obj.pop("is_admin", None) obj.pop("is_muted", None) obj.pop("is_tester", None) diff --git a/ui/src/components/Settings/ApiKeySettings.jsx b/ui/src/components/Settings/ApiKeySettings.jsx deleted file mode 100644 index 6a4b32a832..0000000000 --- a/ui/src/components/Settings/ApiKeySettings.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useState } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { Button, Intent, Dialog, DialogBody, Card } from '@blueprintjs/core'; -import { ClipboardInput } from 'components/common'; -import { selectCurrentRole } from 'selectors'; -import { resetApiKey } from 'actions'; -import { FormattedMessage } from 'react-intl'; - -export default function ApiKeySettings() { - const dispatch = useDispatch(); - const role = useSelector(selectCurrentRole); - - const [isLoading, setIsLoading] = useState(false); - const [showConfirmation, setShowConfirmation] = useState(false); - const [apiKey, setApiKey] = useState(null); - - const onReset = async () => { - setIsLoading(true); - const { data } = await dispatch(resetApiKey(role)); - setApiKey(data.api_key); - setIsLoading(false); - }; - - const onClose = async () => { - setShowConfirmation(false); - setApiKey(null); - }; - - const resetMessage = ( - - ); - - return ( - <> - -

- -

- -

- -

- - -
- - - {apiKey === null ? ( - -

- -

- -
- ) : ( - -

- -

-

- -

-
- )} -
- - ); -} diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx new file mode 100644 index 0000000000..67197cadd3 --- /dev/null +++ b/ui/src/components/Settings/ApiKeySettings.tsx @@ -0,0 +1,213 @@ +import { useReducer } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Button, Intent, Dialog, DialogBody, Card } from '@blueprintjs/core'; +import { ClipboardInput } from 'components/common'; +import { selectCurrentRole } from 'selectors'; +import { resetApiKey } from 'actions'; +import { FormattedMessage } from 'react-intl'; + +const GENERATE_MESSAGE = ( + +); + +const REGENERATE_MESSAGE = ( + +); + +type State = { + state: + | 'START' + | 'REGENERATE_CONFIRMATION' + | 'REGENERATE_LOADING' + | 'REGENERATE_DONE' + | 'GENERATE_LOADING' + | 'GENERATE_DONE'; + apiKey?: string; +}; + +type RegenerateConfirmAction = { type: 'REGENERATE_CONFIRM' }; +type RegenerateStartAction = { type: 'REGENERATE_START' }; +type RegenerateEndAction = { type: 'REGENERATE_END'; apiKey: string }; +type GenerateStartAction = { type: 'GENERATE_START' }; +type GenerateEndAction = { type: 'GENERATE_END'; apiKey: string }; +type CloseAction = { type: 'CLOSE' }; + +type Action = + | RegenerateConfirmAction + | RegenerateStartAction + | RegenerateEndAction + | GenerateStartAction + | GenerateEndAction + | CloseAction; + +const STATE_TRANSITIONS: Record = { + REGENERATE_CONFIRM: 'REGENERATE_CONFIRMATION', + REGENERATE_START: 'REGENERATE_LOADING', + REGENERATE_END: 'REGENERATE_DONE', + GENERATE_START: 'GENERATE_LOADING', + GENERATE_END: 'GENERATE_DONE', + CLOSE: 'START', +}; + +function reducer(current: State, action: Action): State { + const next = { + state: STATE_TRANSITIONS[action.type], + apiKey: current.apiKey, + }; + + if (action.type === 'REGENERATE_END' || action.type === 'GENERATE_END') { + next.apiKey = action.apiKey; + } + + if (action.type === 'CLOSE') { + next.apiKey = undefined; + } + + return next; +} + +export default function ApiKeySettings() { + const reduxDispatch = useDispatch(); + + const role = useSelector(selectCurrentRole); + const [{ state, apiKey }, dispatch] = useReducer(reducer, { state: 'START' }); + + const generateApiKey = async () => { + const { data } = await reduxDispatch(resetApiKey(role)); + return data.api_key; + }; + + const onGenerate = async () => { + dispatch({ type: 'GENERATE_START' }); + const apiKey = await generateApiKey(); + dispatch({ type: 'GENERATE_END', apiKey }); + }; + + const onRegenerate = () => { + dispatch({ type: 'REGENERATE_CONFIRM' }); + }; + + const onConfirm = async () => { + dispatch({ type: 'REGENERATE_START' }); + const apiKey = await generateApiKey(); + dispatch({ type: 'REGENERATE_END', apiKey }); + }; + + const onClose = async () => { + dispatch({ type: 'CLOSE' }); + }; + + return ( + <> + +

+ +

+ +

+ {role.has_api_key ? ( + + ) : ( + + )} +

+ + +
+ + + + ); +} + +type ApiKeyDialogProps = { + state: State['state']; + apiKey?: string; + isOpen: boolean; + onConfirm: () => void; + onClose: () => void; +}; + +function ApiKeyDialog({ + state, + apiKey, + isOpen, + onConfirm, + onClose, +}: ApiKeyDialogProps) { + if (state === 'REGENERATE_CONFIRMATION' || state === 'REGENERATE_LOADING') { + return ( + + +

+ +

+ +
+
+ ); + } + + const title = + state === 'REGENERATE_DONE' ? REGENERATE_MESSAGE : GENERATE_MESSAGE; + + return ( + + +

+ +

+

+ +

+
+
+ ); +} From 0622c841c28311704f50f19eea473c106f54b721 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 1 May 2024 17:33:57 +0200 Subject: [PATCH 10/29] Update wording to clarify that API keys are secrets --- ui/src/components/Settings/ApiKeySettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx index 67197cadd3..40bbe2905a 100644 --- a/ui/src/components/Settings/ApiKeySettings.tsx +++ b/ui/src/components/Settings/ApiKeySettings.tsx @@ -198,7 +198,7 @@ function ApiKeyDialog({

From 6f03791577a726c6da67d42e3d1419efa283d18b Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 1 May 2024 17:53:18 +0200 Subject: [PATCH 11/29] Rename "reset_api_key" to "generate_api_key" This method is now also used to generate an initial key for users who do not yet have an API key. --- aleph/model/role.py | 2 +- aleph/tests/test_role_model.py | 2 +- aleph/tests/test_roles_api.py | 12 ++++++------ aleph/tests/test_view_context.py | 2 +- aleph/views/roles_api.py | 6 +++--- ui/src/actions/index.js | 2 +- ui/src/actions/roleActions.js | 6 +++--- ui/src/components/Settings/ApiKeySettings.tsx | 4 ++-- ui/src/reducers/roles.js | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/aleph/model/role.py b/aleph/model/role.py index d2f12319b9..0495498b5d 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -154,7 +154,7 @@ def check_password(self, secret): digest = self.password_digest or "" return check_password_hash(digest, secret) - def reset_api_key(self): + def generate_api_key(self): """Resets the API key""" self.api_key = make_token() diff --git a/aleph/tests/test_role_model.py b/aleph/tests/test_role_model.py index 67aeb8a4be..3c757044ea 100644 --- a/aleph/tests/test_role_model.py +++ b/aleph/tests/test_role_model.py @@ -80,7 +80,7 @@ def test_remove_role(self): def test_role_by_api_key(self): role_ = self.create_user() - role_.reset_api_key() + role_.generate_api_key() db.session.add(role_) db.session.commit() assert role_.api_key is not None diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 4208a45fc9..51c2ecf524 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -129,7 +129,7 @@ def test_view_api_key(self): assert res.status_code == 200 assert res.json["has_api_key"] is False - role.reset_api_key() + role.generate_api_key() db.session.add(role) db.session.commit() @@ -246,8 +246,8 @@ def test_create_on_existing_email(self): self.assertEqual(res.status_code, 409) - def test_reset_api_key_auth(self): - url = f"/api/2/roles/{self.rolex.id}/reset_api_key" + def test_generate_api_key_auth(self): + url = f"/api/2/roles/{self.rolex.id}/generate_api_key" # Anonymous request res = self.client.post(url) @@ -258,12 +258,12 @@ def test_reset_api_key_auth(self): res = self.client.post(url, headers=headers) self.assertEqual(res.status_code, 403) - def test_reset_api_key(self): + def test_generate_api_key(self): role, headers = self.login() assert role.api_key is None # Generate initial API key for new user - url = f"/api/2/roles/{role.id}/reset_api_key" + url = f"/api/2/roles/{role.id}/generate_api_key" res = self.client.post(url, headers=headers) new_key = res.json["api_key"] assert res.status_code == 200 @@ -277,7 +277,7 @@ def test_reset_api_key(self): old_key = new_key # Regenerate API key - url = f"/api/2/roles/{role.id}/reset_api_key" + url = f"/api/2/roles/{role.id}/generate_api_key" res = self.client.post(url, headers=headers) new_key = res.json["api_key"] diff --git a/aleph/tests/test_view_context.py b/aleph/tests/test_view_context.py index 11cf3f007d..2325649307 100644 --- a/aleph/tests/test_view_context.py +++ b/aleph/tests/test_view_context.py @@ -7,7 +7,7 @@ def setUp(self): super().setUp() self.role = self.create_user(email="john.doe@example.org") self.role.set_password("12345678") - self.role.reset_api_key() + self.role.generate_api_key() self.other_role = self.create_user( foreign_id="other", diff --git a/aleph/views/roles_api.py b/aleph/views/roles_api.py index 18d13c5415..a846905033 100644 --- a/aleph/views/roles_api.py +++ b/aleph/views/roles_api.py @@ -246,8 +246,8 @@ def update(id): return RoleSerializer.jsonify(role) -@blueprint.route("/api/2/roles//reset_api_key", methods=["POST"]) -def reset_api_key(id): +@blueprint.route("/api/2/roles//generate_api_key", methods=["POST"]) +def generate_api_key(id): """Reset the role’s API key. --- post: @@ -275,7 +275,7 @@ def reset_api_key(id): role = obj_or_404(Role.by_id(id)) require(request.authz.can_write_role(role.id)) - role.reset_api_key() + role.generate_api_key() db.session.add(role) db.session.commit() update_role(role) diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index 056846c1e4..3e8ed2f550 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -5,7 +5,7 @@ export { fetchRole, suggestRoles, updateRole, - resetApiKey, + generateApiKey, } from './roleActions'; export { createAlert, deleteAlert, queryAlerts } from './alertActions'; export { queryNotifications } from './notificationActions'; diff --git a/ui/src/actions/roleActions.js b/ui/src/actions/roleActions.js index d022eecf82..7ba2c30728 100644 --- a/ui/src/actions/roleActions.js +++ b/ui/src/actions/roleActions.js @@ -33,10 +33,10 @@ export const updateRole = asyncActionCreator( { name: 'UPDATE_ROLE' } ); -export const resetApiKey = asyncActionCreator( +export const generateApiKey = asyncActionCreator( (role) => async () => { - const response = await endpoint.post(`roles/${role.id}/reset_api_key`); + const response = await endpoint.post(`roles/${role.id}/generate_api_key`); return { id: role.id, data: response.data }; }, - { name: 'RESET_API_KEY' } + { name: 'GENERATE_API_KEY' } ); diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx index 40bbe2905a..c6fcc42fc5 100644 --- a/ui/src/components/Settings/ApiKeySettings.tsx +++ b/ui/src/components/Settings/ApiKeySettings.tsx @@ -3,7 +3,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { Button, Intent, Dialog, DialogBody, Card } from '@blueprintjs/core'; import { ClipboardInput } from 'components/common'; import { selectCurrentRole } from 'selectors'; -import { resetApiKey } from 'actions'; +import { generateApiKey as generateApiKeyAction } from 'actions'; import { FormattedMessage } from 'react-intl'; const GENERATE_MESSAGE = ( @@ -79,7 +79,7 @@ export default function ApiKeySettings() { const [{ state, apiKey }, dispatch] = useReducer(reducer, { state: 'START' }); const generateApiKey = async () => { - const { data } = await reduxDispatch(resetApiKey(role)); + const { data } = await reduxDispatch(generateApiKeyAction(role)); return data.api_key; }; diff --git a/ui/src/reducers/roles.js b/ui/src/reducers/roles.js index 6a37d8dccc..8df06965cd 100644 --- a/ui/src/reducers/roles.js +++ b/ui/src/reducers/roles.js @@ -1,6 +1,6 @@ import { createReducer } from 'redux-act'; -import { queryRoles, fetchRole, updateRole, resetApiKey } from 'actions'; +import { queryRoles, fetchRole, updateRole, generateApiKey } from 'actions'; import { resultObjects, objectLoadStart, @@ -20,7 +20,7 @@ export default createReducer( objectLoadComplete(state, id, data), [updateRole.COMPLETE]: (state, { id, data }) => objectLoadComplete(state, id, data), - [resetApiKey.COMPLETE]: (state, { id, data }) => + [generateApiKey.COMPLETE]: (state, { id, data }) => objectLoadComplete(state, id, { ...data, api_key: null }), }, initialState From be5f02960446b25710c7bfd8955964a9247a77da Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Thu, 2 May 2024 09:22:22 +0200 Subject: [PATCH 12/29] Send email notification when API key is (re-)generated --- aleph/templates/email/api_key_generated.html | 13 ++++++++++ aleph/templates/email/api_key_generated.txt | 13 ++++++++++ aleph/tests/test_roles_api.py | 26 ++++++++++++++++++++ aleph/views/roles_api.py | 10 +++++++- 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 aleph/templates/email/api_key_generated.html create mode 100644 aleph/templates/email/api_key_generated.txt diff --git a/aleph/templates/email/api_key_generated.html b/aleph/templates/email/api_key_generated.html new file mode 100644 index 0000000000..6a7f29b1df --- /dev/null +++ b/aleph/templates/email/api_key_generated.html @@ -0,0 +1,13 @@ +{% extends "email/layout.html" %} + +{% block content -%} +{% if event == "regenerated" -%} +{% trans -%} +Your API key has been regenerated. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{% else -%} +{% trans -%} +An API key has been generated for your account. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{%- endif %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_generated.txt b/aleph/templates/email/api_key_generated.txt new file mode 100644 index 0000000000..c41534c31d --- /dev/null +++ b/aleph/templates/email/api_key_generated.txt @@ -0,0 +1,13 @@ +{% extends "email/layout.txt" %} + +{% block content -%} +{% if event == "regenerated" -%} +{% trans -%} +Your API key has been regenerated. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{% else -%} +{% trans -%} +An API key has been generated for your account. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{%- endif %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 51c2ecf524..223bf928ab 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -294,6 +294,32 @@ def test_generate_api_key(self): res = self.client.get(url, headers={"Authorization": new_key}) self.assertEqual(res.status_code, 200) + def test_generate_api_key_notification(self): + role, headers = self.login(email="john.doe@example.org") + url = f"/api/2/roles/{role.id}/generate_api_key" + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + self.client.post(url, headers=headers) + assert len(outbox) == 1 + + msg = outbox[0] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] API key generated" + assert "An API key has been generated for your account" in msg.body + assert "An API key has been generated for your account" in msg.html + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + self.client.post(url, headers=headers) + assert len(outbox) == 1 + + msg = outbox[0] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] API key regenerated" + assert "Your API key has been regenerated" in msg.body + assert "Your API key has been regenerated" in msg.html + def test_new_roles_no_api_key(self): SETTINGS.PASSWORD_LOGIN = True email = "john.doe@example.org" diff --git a/aleph/views/roles_api.py b/aleph/views/roles_api.py index a846905033..95eca56f0a 100644 --- a/aleph/views/roles_api.py +++ b/aleph/views/roles_api.py @@ -1,7 +1,7 @@ import logging from banal import ensure_list from flask_babel import gettext -from flask import Blueprint, request +from flask import Blueprint, request, render_template from itsdangerous import BadSignature from werkzeug.exceptions import BadRequest from sqlalchemy import func @@ -11,6 +11,7 @@ from aleph.search import QueryParser, DatabaseQueryResult from aleph.model import Role from aleph.logic.roles import challenge_role, update_role, create_user, get_deep_role +from aleph.logic.mail import email_role from aleph.util import is_auto_admin from aleph.views.serializers import RoleSerializer from aleph.views.util import require, jsonify, parse_request, obj_or_404 @@ -275,6 +276,13 @@ def generate_api_key(id): role = obj_or_404(Role.by_id(id)) require(request.authz.can_write_role(role.id)) + event = "regenerated" if role.has_api_key else "generated" + params = {"role": role, "event": event} + plain = render_template("email/api_key_generated.txt", **params) + html = render_template("email/api_key_generated.html", **params) + subject = f"API key {event}" + email_role(role, subject, html=html, plain=plain) + role.generate_api_key() db.session.add(role) db.session.commit() From f30757f37a0369996c2a219a910df88918d1c780 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Thu, 2 May 2024 11:35:14 +0200 Subject: [PATCH 13/29] Extract logic to regenerate API keys into separate module While the logic initially was quite simply, there will be more business logic related to API keys, e.g. sending notifications ahead of and when an API key has expired. --- aleph/logic/api_keys.py | 21 +++++++++++++++++ aleph/tests/test_api_keys.py | 44 +++++++++++++++++++++++++++++++++++ aleph/tests/test_roles_api.py | 26 --------------------- aleph/views/roles_api.py | 20 ++++------------ 4 files changed, 69 insertions(+), 42 deletions(-) create mode 100644 aleph/logic/api_keys.py create mode 100644 aleph/tests/test_api_keys.py diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py new file mode 100644 index 0000000000..1f30285c74 --- /dev/null +++ b/aleph/logic/api_keys.py @@ -0,0 +1,21 @@ +from flask import render_template + +from aleph.core import db +from aleph.logic.mail import email_role +from aleph.logic.roles import update_role + + +def generate_user_api_key(role): + event = "regenerated" if role.has_api_key else "generated" + params = {"role": role, "event": event} + plain = render_template("email/api_key_generated.txt", **params) + html = render_template("email/api_key_generated.html", **params) + subject = f"API key {event}" + email_role(role, subject, html=html, plain=plain) + + role.generate_api_key() + db.session.add(role) + db.session.commit() + update_role(role) + + return role.api_key diff --git a/aleph/tests/test_api_keys.py b/aleph/tests/test_api_keys.py new file mode 100644 index 0000000000..2711c2e22f --- /dev/null +++ b/aleph/tests/test_api_keys.py @@ -0,0 +1,44 @@ +from aleph.core import db, mail +from aleph.logic.api_keys import generate_user_api_key +from aleph.tests.util import TestCase + + +class ApiKeysTestCase(TestCase): + def test_generate_user_api_key(self): + role = self.create_user() + assert role.api_key is None + + generate_user_api_key(role) + db.session.refresh(role) + assert role.api_key is not None + + old_key = role.api_key + generate_user_api_key(role) + db.session.refresh(role) + assert role.api_key != old_key + + def test_generate_user_api_key_notification(self): + role = self.create_user(email="john.doe@example.org") + assert role.api_key is None + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + + msg = outbox[0] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] API key generated" + assert "An API key has been generated for your account" in msg.body + assert "An API key has been generated for your account" in msg.html + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + + msg = outbox[0] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] API key regenerated" + assert "Your API key has been regenerated" in msg.body + assert "Your API key has been regenerated" in msg.html diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 223bf928ab..51c2ecf524 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -294,32 +294,6 @@ def test_generate_api_key(self): res = self.client.get(url, headers={"Authorization": new_key}) self.assertEqual(res.status_code, 200) - def test_generate_api_key_notification(self): - role, headers = self.login(email="john.doe@example.org") - url = f"/api/2/roles/{role.id}/generate_api_key" - - with mail.record_messages() as outbox: - assert len(outbox) == 0 - self.client.post(url, headers=headers) - assert len(outbox) == 1 - - msg = outbox[0] - assert msg.recipients == ["john.doe@example.org"] - assert msg.subject == "[Aleph] API key generated" - assert "An API key has been generated for your account" in msg.body - assert "An API key has been generated for your account" in msg.html - - with mail.record_messages() as outbox: - assert len(outbox) == 0 - self.client.post(url, headers=headers) - assert len(outbox) == 1 - - msg = outbox[0] - assert msg.recipients == ["john.doe@example.org"] - assert msg.subject == "[Aleph] API key regenerated" - assert "Your API key has been regenerated" in msg.body - assert "Your API key has been regenerated" in msg.html - def test_new_roles_no_api_key(self): SETTINGS.PASSWORD_LOGIN = True email = "john.doe@example.org" diff --git a/aleph/views/roles_api.py b/aleph/views/roles_api.py index 95eca56f0a..b15c662d24 100644 --- a/aleph/views/roles_api.py +++ b/aleph/views/roles_api.py @@ -1,7 +1,7 @@ import logging from banal import ensure_list from flask_babel import gettext -from flask import Blueprint, request, render_template +from flask import Blueprint, request from itsdangerous import BadSignature from werkzeug.exceptions import BadRequest from sqlalchemy import func @@ -11,7 +11,7 @@ from aleph.search import QueryParser, DatabaseQueryResult from aleph.model import Role from aleph.logic.roles import challenge_role, update_role, create_user, get_deep_role -from aleph.logic.mail import email_role +from aleph.logic.api_keys import generate_user_api_key from aleph.util import is_auto_admin from aleph.views.serializers import RoleSerializer from aleph.views.util import require, jsonify, parse_request, obj_or_404 @@ -275,22 +275,10 @@ def generate_api_key(id): """ role = obj_or_404(Role.by_id(id)) require(request.authz.can_write_role(role.id)) - - event = "regenerated" if role.has_api_key else "generated" - params = {"role": role, "event": event} - plain = render_template("email/api_key_generated.txt", **params) - html = render_template("email/api_key_generated.html", **params) - subject = f"API key {event}" - email_role(role, subject, html=html, plain=plain) - - role.generate_api_key() - db.session.add(role) - db.session.commit() - update_role(role) - + api_key = generate_user_api_key(role) data = RoleSerializer().serialize(role) # The API key usually isn’t included in API responses, but we return it # exactly once after it has been (re-)generated. - data["api_key"] = role.api_key + data["api_key"] = api_key return jsonify(data) From e1ed9b60cd31a895a4de0723d0d3515b3357dcd2 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Mon, 6 May 2024 10:31:28 +0200 Subject: [PATCH 14/29] Let API keys expire after 90 days --- .../d46fc882ec6b_api_key_expiration.py | 21 ++++++++++++ aleph/model/role.py | 13 +++++++- aleph/tests/test_api_keys.py | 21 ++++++++---- aleph/tests/test_role_model.py | 33 +++++++++++++++++++ 4 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py diff --git a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py new file mode 100644 index 0000000000..fc6bab62d1 --- /dev/null +++ b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py @@ -0,0 +1,21 @@ +"""API key expiration + +Revision ID: d46fc882ec6b +Revises: c52a1f469ac7 +Create Date: 2024-05-02 11:43:50.993948 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "d46fc882ec6b" +down_revision = "c52a1f469ac7" + + +def upgrade(): + op.add_column("role", sa.Column("api_key_expires_at", sa.DateTime())) + + +def downgrade(): + op.drop_column("role", "api_key_expires_at") diff --git a/aleph/model/role.py b/aleph/model/role.py index 0495498b5d..8f1409d475 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timedelta, timezone from normality import stringify from sqlalchemy import or_, not_, func from itsdangerous import URLSafeTimedSerializer @@ -11,6 +11,7 @@ from aleph.util import anonymize_email log = logging.getLogger(__name__) +API_KEY_EXPIRATION_DAYS = 90 membership = db.Table( @@ -52,6 +53,7 @@ class Role(db.Model, IdModel, SoftDeleteModel): email = db.Column(db.Unicode, nullable=True) type = db.Column(db.Enum(*TYPES, name="role_type"), nullable=False) api_key = db.Column(db.Unicode, nullable=True) + api_key_expires_at = db.Column(db.DateTime, nullable=True) is_admin = db.Column(db.Boolean, nullable=False, default=False) is_muted = db.Column(db.Boolean, nullable=False, default=False) is_tester = db.Column(db.Boolean, nullable=False, default=False) @@ -156,7 +158,10 @@ def check_password(self, secret): def generate_api_key(self): """Resets the API key""" + now = datetime.now(timezone.utc) + self.api_key = make_token() + self.api_key_expires_at = now + timedelta(days=API_KEY_EXPIRATION_DAYS) def to_dict(self): data = self.to_dict_dates() @@ -200,6 +205,12 @@ def by_api_key(cls, api_key): return None q = cls.all() q = q.filter_by(api_key=api_key) + q = q.filter( + or_( + cls.api_key_expires_at == None, # noqa: E711 + datetime.utcnow() < cls.api_key_expires_at, + ) + ) q = q.filter(cls.type == cls.USER) q = q.filter(cls.is_blocked == False) # noqa return q.first() diff --git a/aleph/tests/test_api_keys.py b/aleph/tests/test_api_keys.py index 2711c2e22f..c473fb6e04 100644 --- a/aleph/tests/test_api_keys.py +++ b/aleph/tests/test_api_keys.py @@ -1,3 +1,6 @@ +import datetime +import time_machine + from aleph.core import db, mail from aleph.logic.api_keys import generate_user_api_key from aleph.tests.util import TestCase @@ -7,15 +10,21 @@ class ApiKeysTestCase(TestCase): def test_generate_user_api_key(self): role = self.create_user() assert role.api_key is None + assert role.api_key_expires_at is None - generate_user_api_key(role) - db.session.refresh(role) - assert role.api_key is not None + with time_machine.travel("2024-01-01T00:00:00Z"): + generate_user_api_key(role) + db.session.refresh(role) + assert role.api_key is not None + assert role.api_key_expires_at.date() == datetime.date(2024, 3, 31) old_key = role.api_key - generate_user_api_key(role) - db.session.refresh(role) - assert role.api_key != old_key + + with time_machine.travel("2024-02-01T00:00:00Z"): + generate_user_api_key(role) + db.session.refresh(role) + assert role.api_key != old_key + assert role.api_key_expires_at.date() == datetime.date(2024, 5, 1) def test_generate_user_api_key_notification(self): role = self.create_user(email="john.doe@example.org") diff --git a/aleph/tests/test_role_model.py b/aleph/tests/test_role_model.py index 3c757044ea..21595ee1da 100644 --- a/aleph/tests/test_role_model.py +++ b/aleph/tests/test_role_model.py @@ -1,3 +1,5 @@ +import time_machine + from aleph.core import db from aleph.model import Role from aleph.tests.factories.models import RoleFactory @@ -98,3 +100,34 @@ def test_role_by_api_key_empty(self): role = Role.by_api_key("") assert role is None + + def test_role_by_api_key_expired(self): + role_ = self.create_user() + + with time_machine.travel("2024-01-01T00:00:00Z"): + role_.generate_api_key() + db.session.add(role_) + db.session.commit() + + with time_machine.travel("2024-03-30T23:59:59Z"): + print(role_.api_key_expires_at) + role = Role.by_api_key(role_.api_key) + assert role is not None + assert role.id == role_.id + + with time_machine.travel("2024-03-31T00:00:00Z"): + role = Role.by_api_key(role_.api_key) + assert role is None + + def test_role_by_api_key_legacy_without_expiration(self): + # Ensure that legacy API keys that were created without an expiration + # date continue to work. + role_ = self.create_user() + role_.generate_api_key() + role_.api_key_expires_at = None + db.session.add(role_) + db.session.commit() + + role = Role.by_api_key(role_.api_key) + assert role is not None + assert role.id == role_.id From a506ebecb95fc0a4b43d7f771e1ad32a61833aed Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Mon, 6 May 2024 10:31:46 +0200 Subject: [PATCH 15/29] Extract `generate_api_key` method from role model Initially, I added this to the role model as the model to be consistent with the model's `set_password` method. However, as the logic to generate an API token has become more complex, it is clear that it shouldn't live in the model. --- aleph/logic/api_keys.py | 10 +++++++++- aleph/model/role.py | 12 ++---------- aleph/tests/test_role_model.py | 19 +++++++++---------- aleph/tests/test_roles_api.py | 8 +++++--- aleph/tests/test_view_context.py | 2 +- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py index 1f30285c74..26c482d9b0 100644 --- a/aleph/logic/api_keys.py +++ b/aleph/logic/api_keys.py @@ -1,9 +1,14 @@ +import datetime from flask import render_template from aleph.core import db +from aleph.model.common import make_token from aleph.logic.mail import email_role from aleph.logic.roles import update_role +# Number of days after which API keys expire +API_KEY_EXPIRATION_DAYS = 90 + def generate_user_api_key(role): event = "regenerated" if role.has_api_key else "generated" @@ -13,7 +18,10 @@ def generate_user_api_key(role): subject = f"API key {event}" email_role(role, subject, html=html, plain=plain) - role.generate_api_key() + now = datetime.datetime.utcnow() + role.api_key = make_token() + role.api_key_expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) + db.session.add(role) db.session.commit() update_role(role) diff --git a/aleph/model/role.py b/aleph/model/role.py index 8f1409d475..043b72377e 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime from normality import stringify from sqlalchemy import or_, not_, func from itsdangerous import URLSafeTimedSerializer @@ -7,11 +7,10 @@ from aleph.core import db from aleph.settings import SETTINGS -from aleph.model.common import SoftDeleteModel, IdModel, make_token, query_like +from aleph.model.common import SoftDeleteModel, IdModel, query_like from aleph.util import anonymize_email log = logging.getLogger(__name__) -API_KEY_EXPIRATION_DAYS = 90 membership = db.Table( @@ -156,13 +155,6 @@ def check_password(self, secret): digest = self.password_digest or "" return check_password_hash(digest, secret) - def generate_api_key(self): - """Resets the API key""" - now = datetime.now(timezone.utc) - - self.api_key = make_token() - self.api_key_expires_at = now + timedelta(days=API_KEY_EXPIRATION_DAYS) - def to_dict(self): data = self.to_dict_dates() data.update( diff --git a/aleph/tests/test_role_model.py b/aleph/tests/test_role_model.py index 21595ee1da..5ad24cc1e9 100644 --- a/aleph/tests/test_role_model.py +++ b/aleph/tests/test_role_model.py @@ -1,3 +1,4 @@ +import datetime import time_machine from aleph.core import db @@ -82,12 +83,11 @@ def test_remove_role(self): def test_role_by_api_key(self): role_ = self.create_user() - role_.generate_api_key() + role_.api_key = "1234567890" db.session.add(role_) db.session.commit() - assert role_.api_key is not None - role = Role.by_api_key(role_.api_key) + role = Role.by_api_key("1234567890") assert role is not None assert role.id == role_.id @@ -103,11 +103,10 @@ def test_role_by_api_key_empty(self): def test_role_by_api_key_expired(self): role_ = self.create_user() - - with time_machine.travel("2024-01-01T00:00:00Z"): - role_.generate_api_key() - db.session.add(role_) - db.session.commit() + role_.api_key = "1234567890" + role_.api_key_expires_at = datetime.datetime(2024, 3, 31, 0, 0, 0) + db.session.add(role_) + db.session.commit() with time_machine.travel("2024-03-30T23:59:59Z"): print(role_.api_key_expires_at) @@ -123,11 +122,11 @@ def test_role_by_api_key_legacy_without_expiration(self): # Ensure that legacy API keys that were created without an expiration # date continue to work. role_ = self.create_user() - role_.generate_api_key() + role_.api_key = "1234567890" role_.api_key_expires_at = None db.session.add(role_) db.session.commit() - role = Role.by_api_key(role_.api_key) + role = Role.by_api_key("1234567890") assert role is not None assert role.id == role_.id diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 51c2ecf524..9bc893529b 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -129,9 +129,11 @@ def test_view_api_key(self): assert res.status_code == 200 assert res.json["has_api_key"] is False - role.generate_api_key() - db.session.add(role) - db.session.commit() + res = self.client.post( + f"/api/2/roles/{role.id}/generate_api_key", + headers=headers, + ) + assert res.status_code == 200 res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) assert res.status_code == 200 diff --git a/aleph/tests/test_view_context.py b/aleph/tests/test_view_context.py index 2325649307..f6aa460c7a 100644 --- a/aleph/tests/test_view_context.py +++ b/aleph/tests/test_view_context.py @@ -7,7 +7,7 @@ def setUp(self): super().setUp() self.role = self.create_user(email="john.doe@example.org") self.role.set_password("12345678") - self.role.generate_api_key() + self.role.api_key = "1234567890" self.other_role = self.create_user( foreign_id="other", From 6cc615b0cc51cbebcff11458a35381e33011e430 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Mon, 6 May 2024 10:31:57 +0200 Subject: [PATCH 16/29] Send notification when API keys are about to expire/have expired --- aleph/logic/api_keys.py | 62 ++++++++ .../d46fc882ec6b_api_key_expiration.py | 9 ++ aleph/model/role.py | 1 + aleph/templates/email/api_key_expired.html | 15 ++ aleph/templates/email/api_key_expired.txt | 11 ++ .../templates/email/api_key_expires_soon.html | 15 ++ .../templates/email/api_key_expires_soon.txt | 11 ++ aleph/tests/test_api_keys.py | 144 +++++++++++++++++- 8 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 aleph/templates/email/api_key_expired.html create mode 100644 aleph/templates/email/api_key_expired.txt create mode 100644 aleph/templates/email/api_key_expires_soon.html create mode 100644 aleph/templates/email/api_key_expires_soon.txt diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py index 26c482d9b0..3b3b8cd43f 100644 --- a/aleph/logic/api_keys.py +++ b/aleph/logic/api_keys.py @@ -1,14 +1,21 @@ import datetime + from flask import render_template +from sqlalchemy import and_, or_, func from aleph.core import db +from aleph.model import Role from aleph.model.common import make_token from aleph.logic.mail import email_role from aleph.logic.roles import update_role +from aleph.logic.util import ui_url # Number of days after which API keys expire API_KEY_EXPIRATION_DAYS = 90 +# Number of days before an API key expires +API_KEY_EXPIRES_SOON_DAYS = 7 + def generate_user_api_key(role): event = "regenerated" if role.has_api_key else "generated" @@ -21,9 +28,64 @@ def generate_user_api_key(role): now = datetime.datetime.utcnow() role.api_key = make_token() role.api_key_expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) + role.api_key_expiration_notification_sent = None db.session.add(role) db.session.commit() update_role(role) return role.api_key + + +def send_api_key_expiration_notifications(): + _send_api_key_expiration_notification( + days=7, + subject="Your API key will expire in 7 days", + plain_template="email/api_key_expires_soon.txt", + html_template="email/api_key_expires_soon.html", + ) + + _send_api_key_expiration_notification( + days=0, + subject="Your API key has expired", + plain_template="email/api_key_expired.txt", + html_template="email/api_key_expired.html", + ) + + +def _send_api_key_expiration_notification( + days, + subject, + plain_template, + html_template, +): + now = datetime.date.today() + threshold = now + datetime.timedelta(days=days) + + query = Role.all_users() + query = query.yield_per(1000) + query = query.where( + and_( + and_( + Role.api_key != None, # noqa: E711 + func.date(Role.api_key_expires_at) <= threshold, + ), + or_( + Role.api_key_expiration_notification_sent == None, # noqa: E711 + Role.api_key_expiration_notification_sent > days, + ), + ) + ) + + for role in query: + params = { + "role": role, + "expires_at": role.api_key_expires_at, + "settings_url": ui_url("settings"), + } + plain = render_template(plain_template, **params) + html = render_template(html_template, **params) + email_role(role, subject, html=html, plain=plain) + + query.update({Role.api_key_expiration_notification_sent: days}) + db.session.commit() diff --git a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py index fc6bab62d1..1d389fda20 100644 --- a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py +++ b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py @@ -15,7 +15,16 @@ def upgrade(): op.add_column("role", sa.Column("api_key_expires_at", sa.DateTime())) + op.add_column( + "role", sa.Column("api_key_expiration_notification_sent", sa.Integer()) + ) + op.create_index( + index_name="ix_role_api_key_expires_at", + table_name="role", + columns=["api_key_expires_at"], + ) def downgrade(): op.drop_column("role", "api_key_expires_at") + op.drop_column("role", "api_key_expiration_notification_sent") diff --git a/aleph/model/role.py b/aleph/model/role.py index 043b72377e..7078d2d817 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -53,6 +53,7 @@ class Role(db.Model, IdModel, SoftDeleteModel): type = db.Column(db.Enum(*TYPES, name="role_type"), nullable=False) api_key = db.Column(db.Unicode, nullable=True) api_key_expires_at = db.Column(db.DateTime, nullable=True) + api_key_expiration_notification_sent = db.Column(db.Integer, nullable=True) is_admin = db.Column(db.Boolean, nullable=False, default=False) is_muted = db.Column(db.Boolean, nullable=False, default=False) is_tester = db.Column(db.Boolean, nullable=False, default=False) diff --git a/aleph/templates/email/api_key_expired.html b/aleph/templates/email/api_key_expired.html new file mode 100644 index 0000000000..ee3c364f3f --- /dev/null +++ b/aleph/templates/email/api_key_expired.html @@ -0,0 +1,15 @@ +{% extends "email/layout.html" %} + +{% block content -%} +

+ {% trans expires_at=(expires_at | datetimeformat) -%} + Your API key has expired on {{expires_at}} UTC. + {%- endtrans %} +

+ +

+ {% trans settings_url=settings_url -%} + If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key and update any applications or scripts that use your API key. + {%- endtrans %} +

+{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expired.txt b/aleph/templates/email/api_key_expired.txt new file mode 100644 index 0000000000..bab3ea179a --- /dev/null +++ b/aleph/templates/email/api_key_expired.txt @@ -0,0 +1,11 @@ +{% extends "email/layout.txt" %} + +{% block content -%} +{% trans expires_at=(expires_at | datetimeformat) -%} +Your API key has expired on {{expires_at}} UTC. +{%- endtrans %} + +{% trans settings_url=settings_url -%} +If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key ({{settings_url}}) and update any applications or scripts that use your API key. +{%- endtrans %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expires_soon.html b/aleph/templates/email/api_key_expires_soon.html new file mode 100644 index 0000000000..9f655a3dcb --- /dev/null +++ b/aleph/templates/email/api_key_expires_soon.html @@ -0,0 +1,15 @@ +{% extends "email/layout.html" %} + +{% block content -%} +

+ {% trans expires_at=(expires_at | datetimeformat) -%} + Your API key will expire in 7 days, on {{expires_at}} UTC. + {%- endtrans %} +

+ +

+ {% trans settings_url=settings_url -%} + If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key and update any applications or scripts that use your API key. + {%- endtrans %} +

+{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expires_soon.txt b/aleph/templates/email/api_key_expires_soon.txt new file mode 100644 index 0000000000..c1fe124bc8 --- /dev/null +++ b/aleph/templates/email/api_key_expires_soon.txt @@ -0,0 +1,11 @@ +{% extends "email/layout.txt" %} + +{% block content -%} +{% trans expires_at=(expires_at | datetimeformat) -%} +Your API key will expire in 7 days, on {{expires_at}} UTC. +{%- endtrans %} + +{% trans -%} +If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key ({{settings_url}}) and update any applications or scripts that use your API key. +{%- endtrans %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/tests/test_api_keys.py b/aleph/tests/test_api_keys.py index c473fb6e04..e3f7ca0bb0 100644 --- a/aleph/tests/test_api_keys.py +++ b/aleph/tests/test_api_keys.py @@ -2,7 +2,10 @@ import time_machine from aleph.core import db, mail -from aleph.logic.api_keys import generate_user_api_key +from aleph.logic.api_keys import ( + generate_user_api_key, + send_api_key_expiration_notifications, +) from aleph.tests.util import TestCase @@ -51,3 +54,142 @@ def test_generate_user_api_key_notification(self): assert msg.subject == "[Aleph] API key regenerated" assert "Your API key has been regenerated" in msg.body assert "Your API key has been regenerated" in msg.html + + def test_send_api_key_expiration_notifications(self): + role = self.create_user(email="john.doe@example.org") + + with mail.record_messages() as outbox: + with time_machine.travel("2024-01-01T00:00:00Z"): + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + assert outbox[0].subject == "[Aleph] API key generated" + + assert role.api_key is not None + assert role.api_key_expires_at.date() == datetime.date(2024, 3, 31) + + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 1 + + # A notification is sent 7 days before the notification date + with time_machine.travel("2024-03-24T00:00:00Z"): + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 2 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key will expire in 7 days" + assert ( + "Your API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.body + ) + assert ( + "Your API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.html + ) + + # The notification is sent only once + with time_machine.travel("2024-03-25T00:00:00Z"): + assert len(outbox) == 2 + send_api_key_expiration_notifications() + assert len(outbox) == 2 + + # Another notification is sent when the key has expired + with time_machine.travel("2024-03-31T00:00:00Z"): + assert len(outbox) == 2 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key has expired" + assert ( + "Your API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.body + ) + assert ( + "Your API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.html + ) + + # The notification is sent only once + with time_machine.travel("2024-03-31T00:00:00Z"): + assert len(outbox) == 3 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + def test_send_api_key_expiration_notifications_no_key(self): + role = self.create_user(email="john.doe@example.org") + assert role.api_key is None + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + send_api_key_expiration_notifications() + assert len(outbox) == 0 + + def test_send_api_key_expiration_notifications_delay(self): + role = self.create_user(email="john.doe@example.org") + + with mail.record_messages() as outbox: + with time_machine.travel("2024-01-01T00:00:00Z"): + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + assert outbox[0].subject == "[Aleph] API key generated" + + # Notifications are sent even if the task that sends them is executed with a delay, + # for example 6 days before the key expires + with time_machine.travel("2024-03-26T00:00:00Z"): + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 2 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key will expire in 7 days" + + # 1 day after the key has expired + with time_machine.travel("2024-04-01T00:00:00Z"): + assert len(outbox) == 2 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key has expired" + + def test_send_api_key_expiration_notifications_regenerate(self): + role = self.create_user(email="john.doe@example.org") + + with mail.record_messages() as outbox: + with time_machine.travel("2024-01-01T00:00:00Z"): + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + assert outbox[0].subject == "[Aleph] API key generated" + + # 90 days after generating the initial API key + with time_machine.travel("2024-03-31T00:00:00Z"): + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + assert outbox[1].subject == "[Aleph] Your API key will expire in 7 days" + assert outbox[2].subject == "[Aleph] Your API key has expired" + + # Regenerate the key after it has expired + assert len(outbox) == 3 + generate_user_api_key(role) + assert len(outbox) == 4 + assert outbox[3].subject == "[Aleph] API key regenerated" + + # 90 days after regenerating the API key + with time_machine.travel("2024-06-29T00:00:00Z"): + assert len(outbox) == 4 + send_api_key_expiration_notifications() + assert len(outbox) == 6 + + assert outbox[4].subject == "[Aleph] Your API key will expire in 7 days" + assert outbox[5].subject == "[Aleph] Your API key has expired" From 6d2d89b732bcf6e2bee27065124bbd54213896a4 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Mon, 6 May 2024 10:32:07 +0200 Subject: [PATCH 17/29] Display API key expiration date in UI --- aleph/logic/api_keys.py | 2 +- aleph/model/role.py | 1 + aleph/tests/test_roles_api.py | 15 ++++++++----- aleph/views/serializers.py | 1 + ui/src/components/Settings/ApiKeySettings.tsx | 22 +++++++++++++++++-- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py index 3b3b8cd43f..9d365edaac 100644 --- a/aleph/logic/api_keys.py +++ b/aleph/logic/api_keys.py @@ -25,7 +25,7 @@ def generate_user_api_key(role): subject = f"API key {event}" email_role(role, subject, html=html, plain=plain) - now = datetime.datetime.utcnow() + now = datetime.datetime.utcnow().replace(microsecond=0) role.api_key = make_token() role.api_key_expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) role.api_key_expiration_notification_sent = None diff --git a/aleph/model/role.py b/aleph/model/role.py index 7078d2d817..ff5dbcbfae 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -171,6 +171,7 @@ def to_dict(self): "is_tester": self.is_tester, "has_password": self.has_password, "has_api_key": self.has_api_key, + "api_key_expires_at": self.api_key_expires_at, # 'notified_at': self.notified_at } ) diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 9bc893529b..8a24d21529 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -1,5 +1,7 @@ import json +import time_machine + from aleph.core import db, mail from aleph.settings import SETTINGS from aleph.model import Role @@ -128,16 +130,19 @@ def test_view_api_key(self): res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) assert res.status_code == 200 assert res.json["has_api_key"] is False + assert "api_key_expires_at" not in res.json - res = self.client.post( - f"/api/2/roles/{role.id}/generate_api_key", - headers=headers, - ) - assert res.status_code == 200 + with time_machine.travel("2024-01-01T00:00:00Z"): + res = self.client.post( + f"/api/2/roles/{role.id}/generate_api_key", + headers=headers, + ) + assert res.status_code == 200 res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) assert res.status_code == 200 assert res.json["has_api_key"] is True + assert res.json["api_key_expires_at"] == "2024-03-31T00:00:00" def test_update(self): res = self.client.post("/api/2/roles/%s" % self.rolex) diff --git a/aleph/views/serializers.py b/aleph/views/serializers.py index 5685ef9a17..d3a2d0878d 100644 --- a/aleph/views/serializers.py +++ b/aleph/views/serializers.py @@ -119,6 +119,7 @@ def _serialize(self, obj): if self.nested or not obj["writeable"]: obj.pop("has_password", None) obj.pop("has_api_key", None) + obj.pop("api_key_expires_at", None) obj.pop("is_admin", None) obj.pop("is_muted", None) obj.pop("is_tester", None) diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx index c6fcc42fc5..18d9fdb4ca 100644 --- a/ui/src/components/Settings/ApiKeySettings.tsx +++ b/ui/src/components/Settings/ApiKeySettings.tsx @@ -4,7 +4,8 @@ import { Button, Intent, Dialog, DialogBody, Card } from '@blueprintjs/core'; import { ClipboardInput } from 'components/common'; import { selectCurrentRole } from 'selectors'; import { generateApiKey as generateApiKeyAction } from 'actions'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, FormattedDate, FormattedTime } from 'react-intl'; +import convertUTCDateToLocalDate from 'util/convertUTCDateToLocalDate'; const GENERATE_MESSAGE = ( @@ -113,11 +118,24 @@ export default function ApiKeySettings() { /> +

+ {expiresAt && ( + , + time: , + }} + /> + )} +

+

{role.has_api_key ? ( ) : ( Date: Mon, 6 May 2024 10:32:18 +0200 Subject: [PATCH 18/29] Add CLI command to reset API key expiration of non-expiring API keys --- aleph/logic/api_keys.py | 17 +++++++++++++++++ aleph/manage.py | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py index 9d365edaac..6bbc470a24 100644 --- a/aleph/logic/api_keys.py +++ b/aleph/logic/api_keys.py @@ -89,3 +89,20 @@ def _send_api_key_expiration_notification( query.update({Role.api_key_expiration_notification_sent: days}) db.session.commit() + + +def reset_api_key_expiration(): + now = datetime.datetime.utcnow().replace(microsecond=0) + expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) + + query = Role.all_users() + query = query.yield_per(500) + query = query.where( + and_( + Role.api_key != None, # noqa: E711 + Role.api_key_expires_at == None, # noqa: E711 + ) + ) + + query.update({Role.api_key_expires_at: expires_at}) + db.session.commit() diff --git a/aleph/manage.py b/aleph/manage.py index d508e05a27..155c1d1e5d 100644 --- a/aleph/manage.py +++ b/aleph/manage.py @@ -20,6 +20,7 @@ from aleph.queues import get_status, cancel_queue from aleph.queues import get_active_dataset_status from aleph.index.admin import delete_index +from aleph.logic.api_keys import reset_api_key_expiration as _reset_api_key_expiration from aleph.index.entities import iter_proxies from aleph.index.util import AlephOperationalException from aleph.logic.collections import create_collection, update_collection @@ -566,3 +567,9 @@ def evilshit(): delete_index() destroy_db() upgrade() + + +@cli.command() +def reset_api_key_expiration(): + """Reset the expiration date of all legacy, non-expiring API keys.""" + _reset_api_key_expiration() From 5e52e2d78b4c7c235a17063ed4783fc42006580c Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 8 May 2024 15:39:12 +0200 Subject: [PATCH 19/29] Replace use of deprecated `utcnow` method https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow --- aleph/logic/api_keys.py | 4 ++-- aleph/model/role.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py index 6bbc470a24..ddb59b87c4 100644 --- a/aleph/logic/api_keys.py +++ b/aleph/logic/api_keys.py @@ -25,7 +25,7 @@ def generate_user_api_key(role): subject = f"API key {event}" email_role(role, subject, html=html, plain=plain) - now = datetime.datetime.utcnow().replace(microsecond=0) + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) role.api_key = make_token() role.api_key_expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) role.api_key_expiration_notification_sent = None @@ -92,7 +92,7 @@ def _send_api_key_expiration_notification( def reset_api_key_expiration(): - now = datetime.datetime.utcnow().replace(microsecond=0) + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) query = Role.all_users() diff --git a/aleph/model/role.py b/aleph/model/role.py index ff5dbcbfae..1bd6f50ed6 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timezone from normality import stringify from sqlalchemy import or_, not_, func from itsdangerous import URLSafeTimedSerializer @@ -199,10 +199,11 @@ def by_api_key(cls, api_key): return None q = cls.all() q = q.filter_by(api_key=api_key) + utcnow = datetime.now(timezone.utc) q = q.filter( or_( cls.api_key_expires_at == None, # noqa: E711 - datetime.utcnow() < cls.api_key_expires_at, + utcnow < cls.api_key_expires_at, ) ) q = q.filter(cls.type == cls.USER) From c0912233de6e05bc8a3964b13d45cb54bea4578b Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 8 May 2024 16:00:47 +0200 Subject: [PATCH 20/29] Remove unnecessary keys from API JSON response Aleph represents both users and groups using the role model. However, some API keys (such as `has_password` or `has_api_key` are not relevant for groups). --- aleph/tests/test_groups_api.py | 13 +++++++++++++ aleph/views/serializers.py | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/aleph/tests/test_groups_api.py b/aleph/tests/test_groups_api.py index 7287823aa0..a00e4880fc 100644 --- a/aleph/tests/test_groups_api.py +++ b/aleph/tests/test_groups_api.py @@ -15,11 +15,24 @@ def setUp(self): def test_index(self): res = self.client.get("/api/2/groups") assert res.status_code == 403, res + _, headers = self.login(foreign_id="user_1") res = self.client.get("/api/2/groups", headers=headers) assert res.status_code == 200, res assert res.json["total"] == 2, res.json validate(res.json["results"][0], "Role") + assert set(res.json["results"][0].keys()) == { + "id", + "created_at", + "updated_at", + "type", + "name", + "label", + "writeable", + "shallow", + "links", + } + _, headers = self.login(foreign_id="other") res = self.client.get("/api/2/groups", headers=headers) assert res.status_code == 200, res diff --git a/aleph/views/serializers.py b/aleph/views/serializers.py index d3a2d0878d..2b3e3f0aa7 100644 --- a/aleph/views/serializers.py +++ b/aleph/views/serializers.py @@ -129,6 +129,13 @@ def _serialize(self, obj): obj.pop("created_at", None) obj.pop("updated_at", None) if obj["type"] != Role.USER: + obj.pop("has_password", None) + obj.pop("has_api_key", None) + obj.pop("api_key_expires_at", None) + obj.pop("is_admin", None) + obj.pop("is_muted", None) + obj.pop("is_tester", None) + obj.pop("is_blocked", None) obj.pop("email", None) obj.pop("locale", None) obj.pop("api_key", None) From 9a42b22bf6b0c55a7a8879bada3e2781b9fe4e79 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 8 May 2024 16:11:00 +0200 Subject: [PATCH 21/29] Add note to remind us to remove/update logic handling legacy API keys --- aleph/model/role.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aleph/model/role.py b/aleph/model/role.py index 1bd6f50ed6..eb60eb0464 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -200,12 +200,16 @@ def by_api_key(cls, api_key): q = cls.all() q = q.filter_by(api_key=api_key) utcnow = datetime.now(timezone.utc) + + # TODO: Exclude API keys without expiration date after deadline + # See https://github.com/alephdata/aleph/issues/3729 q = q.filter( or_( cls.api_key_expires_at == None, # noqa: E711 utcnow < cls.api_key_expires_at, ) ) + q = q.filter(cls.type == cls.USER) q = q.filter(cls.is_blocked == False) # noqa return q.first() From b9ad42e8db7b83c36cac90be93acd987ac6ecb32 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 8 May 2024 16:26:38 +0200 Subject: [PATCH 22/29] Send API key expiration notifications on a daily basis --- aleph/logic/api_keys.py | 7 ++++++- aleph/worker.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py index ddb59b87c4..98680ffe2d 100644 --- a/aleph/logic/api_keys.py +++ b/aleph/logic/api_keys.py @@ -1,5 +1,6 @@ import datetime +import structlog from flask import render_template from sqlalchemy import and_, or_, func @@ -16,6 +17,8 @@ # Number of days before an API key expires API_KEY_EXPIRES_SOON_DAYS = 7 +log = structlog.get_logger(__name__) + def generate_user_api_key(role): event = "regenerated" if role.has_api_key else "generated" @@ -78,13 +81,15 @@ def _send_api_key_expiration_notification( ) for role in query: + expires_at = role.api_key_expires_at params = { "role": role, - "expires_at": role.api_key_expires_at, + "expires_at": expires_at, "settings_url": ui_url("settings"), } plain = render_template(plain_template, **params) html = render_template(html_template, **params) + log.info(f"Sending API key expiration notification: {role} at {expires_at}") email_role(role, subject, html=html, plain=plain) query.update({Role.api_key_expiration_notification_sent: days}) diff --git a/aleph/worker.py b/aleph/worker.py index 5818e5ad02..220ccaabe7 100644 --- a/aleph/worker.py +++ b/aleph/worker.py @@ -18,6 +18,7 @@ from aleph.model import Collection from aleph.queues import get_rate_limit from aleph.logic.alerts import check_alerts +from aleph.logic.api_keys import send_api_key_expiration_notifications from aleph.logic.collections import reingest_collection, reindex_collection from aleph.logic.collections import compute_collections, refresh_collection from aleph.logic.notifications import generate_digest, delete_old_notifications @@ -152,6 +153,7 @@ def periodic(self): update_roles() check_alerts() generate_digest() + send_api_key_expiration_notifications() delete_expired_exports() delete_old_notifications() From c0ad86d846b617f86fee45993a9eee1d650b0738 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:05:51 +0200 Subject: [PATCH 23/29] Fix Alembic migration order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We merged a different migration in the meantime (8adf50) and as a result Alembic wasn’t able to figure out how to upgrade the database unambiguously. --- aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py index 1d389fda20..4c5cfb13e5 100644 --- a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py +++ b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py @@ -1,7 +1,7 @@ """API key expiration Revision ID: d46fc882ec6b -Revises: c52a1f469ac7 +Revises: 8adf50aadcb0 Create Date: 2024-05-02 11:43:50.993948 """ @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. revision = "d46fc882ec6b" -down_revision = "c52a1f469ac7" +down_revision = "8adf50aadcb0" def upgrade(): From 1e74da4fa69a705b6a8d99fe7fdb2cb188e24d03 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 23 Oct 2024 20:02:56 +0200 Subject: [PATCH 24/29] Reauthenticate user in test to update session cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 452e46 changes the way we handle authentication in tests. Previously, we used API keys to authenticate users. Now, as we don’t generate API keys for users by default anymore, we use session tokens (the same mechanism the UI uses as well). In general, authentication using session tokens and API keys is quite similar: In both cases, the token is passed as a request header. However, there’s one significant difference between session tokens and API keys: When using API keys, data about the user (including group memberships) is loaded from the database on every request. When using session tokens, this data is cached in Redis for the lifetime of the session. For end users, this means they have to sign out and in again after their group memberships have been updated. When I originally implemented this change in our tests, it didn’t affect any of the tests as none of them did rely on updating group memberships. However, I’ve since implemented and merged additional tests in #3865, including one test that covers updating the user’s group memberships after the initial sign in. When I rebased this branch to include these new tests, this particular test failed. The solution for this is the same as for end users: In the test case, we need to reauthenticate the test user after updating the group memberships. --- aleph/tests/test_permissions_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/aleph/tests/test_permissions_api.py b/aleph/tests/test_permissions_api.py index 6b3229afc2..113d8936f1 100644 --- a/aleph/tests/test_permissions_api.py +++ b/aleph/tests/test_permissions_api.py @@ -94,6 +94,16 @@ def test_update_groups(self): assert res.json["results"][0]["role"]["id"] == str(self.role.id) self.role.add_role(group) + + # The user's group memberships are cached in Redis when the user signs in. + # As we've updated the group memberships, we need to reauthenticate the + # user to update the cache. + self.role, self.headers = self.login( + foreign_id="john", + name="John Doe", + email="john.doe@example.org", + ) + res = self.client.put(url, headers=self.headers, json=data) assert res.status_code == 200 assert len(res.json["results"]) == 2 From f95572a8d4ebe7d0382bde5c5d076ed72f680e1c Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 23 Oct 2024 20:16:02 +0200 Subject: [PATCH 25/29] Update wording of API key notification emails based on feedback --- aleph/templates/email/api_key_expired.html | 4 ++-- aleph/templates/email/api_key_expired.txt | 4 ++-- aleph/templates/email/api_key_expires_soon.html | 4 ++-- aleph/templates/email/api_key_expires_soon.txt | 4 ++-- aleph/templates/email/api_key_generated.html | 4 ++-- aleph/templates/email/api_key_generated.txt | 4 ++-- aleph/tests/test_api_keys.py | 16 ++++++++-------- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/aleph/templates/email/api_key_expired.html b/aleph/templates/email/api_key_expired.html index ee3c364f3f..43d49841ea 100644 --- a/aleph/templates/email/api_key_expired.html +++ b/aleph/templates/email/api_key_expired.html @@ -3,13 +3,13 @@ {% block content -%}

{% trans expires_at=(expires_at | datetimeformat) -%} - Your API key has expired on {{expires_at}} UTC. + Your Aleph API key has expired on {{expires_at}} UTC. {%- endtrans %}

{% trans settings_url=settings_url -%} - If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key and update any applications or scripts that use your API key. + If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key to maintain access. {%- endtrans %}

{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expired.txt b/aleph/templates/email/api_key_expired.txt index bab3ea179a..c2b96ab6d3 100644 --- a/aleph/templates/email/api_key_expired.txt +++ b/aleph/templates/email/api_key_expired.txt @@ -2,10 +2,10 @@ {% block content -%} {% trans expires_at=(expires_at | datetimeformat) -%} -Your API key has expired on {{expires_at}} UTC. +Your Aleph API key has expired on {{expires_at}} UTC. {%- endtrans %} {% trans settings_url=settings_url -%} -If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key ({{settings_url}}) and update any applications or scripts that use your API key. +If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key ({{settings_url}}) to maintain access. {%- endtrans %} {%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expires_soon.html b/aleph/templates/email/api_key_expires_soon.html index 9f655a3dcb..d6163e6fd5 100644 --- a/aleph/templates/email/api_key_expires_soon.html +++ b/aleph/templates/email/api_key_expires_soon.html @@ -3,13 +3,13 @@ {% block content -%}

{% trans expires_at=(expires_at | datetimeformat) -%} - Your API key will expire in 7 days, on {{expires_at}} UTC. + Your Aleph API key will expire in 7 days, on {{expires_at}} UTC. {%- endtrans %}

{% trans settings_url=settings_url -%} - If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key and update any applications or scripts that use your API key. + If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key to maintain access. {%- endtrans %}

{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expires_soon.txt b/aleph/templates/email/api_key_expires_soon.txt index c1fe124bc8..589fcf11f6 100644 --- a/aleph/templates/email/api_key_expires_soon.txt +++ b/aleph/templates/email/api_key_expires_soon.txt @@ -2,10 +2,10 @@ {% block content -%} {% trans expires_at=(expires_at | datetimeformat) -%} -Your API key will expire in 7 days, on {{expires_at}} UTC. +Your Aleph API key will expire in 7 days, on {{expires_at}} UTC. {%- endtrans %} {% trans -%} -If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key ({{settings_url}}) and update any applications or scripts that use your API key. +If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key ({{settings_url}}) to maintain access. {%- endtrans %} {%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_generated.html b/aleph/templates/email/api_key_generated.html index 6a7f29b1df..813e82bd5d 100644 --- a/aleph/templates/email/api_key_generated.html +++ b/aleph/templates/email/api_key_generated.html @@ -3,11 +3,11 @@ {% block content -%} {% if event == "regenerated" -%} {% trans -%} -Your API key has been regenerated. If that wasn’t you, please contact an administrator. +Your Aleph API key has been regenerated. If that wasn’t you, please contact an administrator. {%- endtrans %} {% else -%} {% trans -%} -An API key has been generated for your account. If that wasn’t you, please contact an administrator. +An Aleph API key has been generated for your account. If that wasn’t you, please contact an administrator. {%- endtrans %} {%- endif %} {%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_generated.txt b/aleph/templates/email/api_key_generated.txt index c41534c31d..861474ba98 100644 --- a/aleph/templates/email/api_key_generated.txt +++ b/aleph/templates/email/api_key_generated.txt @@ -3,11 +3,11 @@ {% block content -%} {% if event == "regenerated" -%} {% trans -%} -Your API key has been regenerated. If that wasn’t you, please contact an administrator. +Your Aleph API key has been regenerated. If that wasn’t you, please contact an administrator. {%- endtrans %} {% else -%} {% trans -%} -An API key has been generated for your account. If that wasn’t you, please contact an administrator. +An Aleph API key has been generated for your account. If that wasn’t you, please contact an administrator. {%- endtrans %} {%- endif %} {%- endblock %} \ No newline at end of file diff --git a/aleph/tests/test_api_keys.py b/aleph/tests/test_api_keys.py index e3f7ca0bb0..62c494a25f 100644 --- a/aleph/tests/test_api_keys.py +++ b/aleph/tests/test_api_keys.py @@ -41,8 +41,8 @@ def test_generate_user_api_key_notification(self): msg = outbox[0] assert msg.recipients == ["john.doe@example.org"] assert msg.subject == "[Aleph] API key generated" - assert "An API key has been generated for your account" in msg.body - assert "An API key has been generated for your account" in msg.html + assert "An Aleph API key has been generated for your account" in msg.body + assert "An Aleph API key has been generated for your account" in msg.html with mail.record_messages() as outbox: assert len(outbox) == 0 @@ -52,8 +52,8 @@ def test_generate_user_api_key_notification(self): msg = outbox[0] assert msg.recipients == ["john.doe@example.org"] assert msg.subject == "[Aleph] API key regenerated" - assert "Your API key has been regenerated" in msg.body - assert "Your API key has been regenerated" in msg.html + assert "Your Aleph API key has been regenerated" in msg.body + assert "Your Aleph API key has been regenerated" in msg.html def test_send_api_key_expiration_notifications(self): role = self.create_user(email="john.doe@example.org") @@ -82,11 +82,11 @@ def test_send_api_key_expiration_notifications(self): assert msg.recipients == ["john.doe@example.org"] assert msg.subject == "[Aleph] Your API key will expire in 7 days" assert ( - "Your API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." + "Your Aleph API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." in msg.body ) assert ( - "Your API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." + "Your Aleph API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." in msg.html ) @@ -106,11 +106,11 @@ def test_send_api_key_expiration_notifications(self): assert msg.recipients == ["john.doe@example.org"] assert msg.subject == "[Aleph] Your API key has expired" assert ( - "Your API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." + "Your Aleph API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." in msg.body ) assert ( - "Your API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." + "Your Aleph API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." in msg.html ) From de6eeff981d9e183e091cb3cbcf9743ae2f4b561 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 23 Oct 2024 20:22:34 +0200 Subject: [PATCH 26/29] Use strict equality check --- ui/src/components/Settings/PasswordSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/Settings/PasswordSettings.jsx b/ui/src/components/Settings/PasswordSettings.jsx index d3c631d9c1..4cfd5e1df3 100644 --- a/ui/src/components/Settings/PasswordSettings.jsx +++ b/ui/src/components/Settings/PasswordSettings.jsx @@ -39,7 +39,7 @@ export default function PasswordSettings() { const isValidCurrentPassword = currentPassword !== ''; const isValidNewPassword = newPassword && newPassword.length >= 6; - const isValidPasswordConfirmation = newPassword == passwordConfirmation; + const isValidPasswordConfirmation = newPassword === passwordConfirmation; const newPasswordIntent = newPassword && !isValidNewPassword ? Intent.DANGER : null; From 9b9efa93d4a37e71609fd3624e65c5c0d3f36a92 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Wed, 23 Oct 2024 20:31:06 +0200 Subject: [PATCH 27/29] Clarify that API keys expire when generating a new key --- ui/src/components/Settings/ApiKeySettings.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx index 18d9fdb4ca..b10ad54c21 100644 --- a/ui/src/components/Settings/ApiKeySettings.tsx +++ b/ui/src/components/Settings/ApiKeySettings.tsx @@ -216,7 +216,10 @@ function ApiKeyDialog({

From 50b689e04bba924bae8a2dad4bee72a2de51cc23 Mon Sep 17 00:00:00 2001 From: Till Prochaska <1512805+tillprochaska@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:47:26 +0100 Subject: [PATCH 28/29] Display different UI messages in case the API key has expired --- ui/src/components/Settings/ApiKeySettings.tsx | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx index b10ad54c21..0c07a61c12 100644 --- a/ui/src/components/Settings/ApiKeySettings.tsx +++ b/ui/src/components/Settings/ApiKeySettings.tsx @@ -107,6 +107,8 @@ export default function ApiKeySettings() { const expiresAt = role.api_key_expires_at && convertUTCDateToLocalDate(new Date(role.api_key_expires_at)); + const now = new Date(); + const hasExpired = expiresAt <= now; return ( <> @@ -118,25 +120,43 @@ export default function ApiKeySettings() { /> -

- {expiresAt && ( - , - time: , - }} - /> - )} -

+ {role.has_api_key && expiresAt && ( +

+ {hasExpired ? ( + , + time: , + }} + /> + ) : ( + , + time: , + }} + /> + )} +

+ )}

{role.has_api_key ? ( - + hasExpired ? ( + + ) : ( + + ) ) : ( Date: Mon, 4 Nov 2024 22:02:20 +0100 Subject: [PATCH 29/29] Fix Alembic migration order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We merged other migrations in the meantime and as a result Alembic wasn’t able to figure out how to upgrade the database unambiguously. --- ...674bde902_add_primary_key_constraint_to_role_membership.py | 4 ++-- aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py b/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py index f922d7b6e5..a94502aa37 100644 --- a/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py +++ b/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py @@ -1,14 +1,14 @@ """add primary key constraint to role_membership table Revision ID: 131674bde902 -Revises: c52a1f469ac7 +Revises: 8adf50aadcb0 Create Date: 2024-07-17 14:37:25.269913 """ # revision identifiers, used by Alembic. revision = "131674bde902" -down_revision = "c52a1f469ac7" +down_revision = "8adf50aadcb0" from alembic import op import sqlalchemy as sa diff --git a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py index 4c5cfb13e5..e2d6c777fc 100644 --- a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py +++ b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py @@ -1,7 +1,7 @@ """API key expiration Revision ID: d46fc882ec6b -Revises: 8adf50aadcb0 +Revises: 131674bde902 Create Date: 2024-05-02 11:43:50.993948 """ @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. revision = "d46fc882ec6b" -down_revision = "8adf50aadcb0" +down_revision = "131674bde902" def upgrade():