Skip to content

Commit

Permalink
Allow users to reset their API key
Browse files Browse the repository at this point in the history
Fix #1027
  • Loading branch information
tillprochaska committed May 25, 2023
1 parent eb4af77 commit 71e7678
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 16 deletions.
4 changes: 4 additions & 0 deletions aleph/model/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,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(
Expand Down
30 changes: 30 additions & 0 deletions aleph/tests/test_roles_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,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)
36 changes: 36 additions & 0 deletions aleph/views/roles_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,39 @@ def update(id):
db.session.commit()
update_role(role)
return RoleSerializer.jsonify(role)


@blueprint.route("/api/2/roles/<int:id>/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)
8 changes: 7 additions & 1 deletion ui/src/actions/index.js
Original file line number Diff line number Diff line change
@@ -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 } from './configActions';
Expand Down
8 changes: 8 additions & 0 deletions ui/src/actions/roleActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
);
25 changes: 14 additions & 11 deletions ui/src/components/common/ClipboardInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,20 @@ export default function ClipboardInput(props) {
readOnly
value={props.value}
rightElement={
<Tooltip content={tooltip}>
<Button
onClick={() => {
inputRef.current.select();
document.execCommand('copy');
showSuccessToast(intl.formatMessage(messages.success));
}}
icon="clipboard"
minimal
/>
</Tooltip>
<>
<Tooltip content={tooltip}>
<Button
onClick={() => {
inputRef.current.select();
document.execCommand('copy');
showSuccessToast(intl.formatMessage(messages.success));
}}
icon="clipboard"
minimal
/>
</Tooltip>
{props.rightElement}
</>
}
/>
);
Expand Down
4 changes: 3 additions & 1 deletion ui/src/reducers/roles.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createReducer } from 'redux-act';

import { queryRoles, fetchRole, updateRole } from 'actions';
import { queryRoles, fetchRole, updateRole, resetApiKey } from 'actions';
import {
resultObjects,
objectLoadStart,
Expand All @@ -20,6 +20,8 @@ export default createReducer(
objectLoadComplete(state, id, data),
[updateRole.COMPLETE]: (state, { id, data }) =>
objectLoadComplete(state, id, data),
[resetApiKey.COMPLETE]: (state, { id, data }) =>
objectLoadComplete(state, id, data),
},
initialState
);
67 changes: 64 additions & 3 deletions ui/src/screens/SettingsScreen/SettingsScreen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ import {
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 Screen from 'components/Screen/Screen';
import Dashboard from 'components/Dashboard/Dashboard';
import ClipboardInput from 'components/common/ClipboardInput';
import { updateRole } from 'actions';
import { updateRole, resetApiKey } from 'actions';
import { selectMetadata, selectLocale, selectCurrentRole } from 'selectors';
import SelectWrapper from 'components/common/SelectWrapper';

Expand All @@ -44,6 +47,14 @@ const messages = defineMessages({
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:
Expand Down Expand Up @@ -100,12 +111,15 @@ export class SettingsScreen extends React.Component {
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);
}

Expand All @@ -125,6 +139,18 @@ export class SettingsScreen extends React.Component {
}
}

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;
Expand Down Expand Up @@ -314,7 +340,22 @@ export class SettingsScreen extends React.Component {
labelFor="api_key"
helperText={intl.formatMessage(messages.api_key_help)}
>
<ClipboardInput id="api_key" icon="key" value={role.api_key} />
<ClipboardInput
id="api_key"
icon="key"
value={role.api_key}
rightElement={
<Tooltip content={intl.formatMessage(messages.api_key_reset)}>
<Button
minimal
small
icon="reset"
onClick={this.toggleApiKeyResetDialog}
aria-label={intl.formatMessage(messages.api_key_reset)}
/>
</Tooltip>
}
/>
</FormGroup>
<FormGroup
label={intl.formatMessage(messages.email)}
Expand Down Expand Up @@ -363,6 +404,24 @@ export class SettingsScreen extends React.Component {
</h5>
</div>
{this.renderForm()}
<Dialog
title={intl.formatMessage(messages.api_key_reset)}
isOpen={this.state.showApiKeyResetDialog}
onClose={this.toggleApiKeyResetDialog}
>
<DialogBody>
<p>
<FormattedMessage
id="settings.api_key.confirmation"
defaultMessage="When you reset your API key, <strong>your current key will stop working</strong> and a new key is generated. You will need to update your applications to use the new key."
values={{ strong: (chunks) => <strong>{chunks}</strong> }}
/>
</p>
<Button intent={Intent.DANGER} fill onClick={this.onResetApiKey}>
{intl.formatMessage(messages.api_key_reset)}
</Button>
</DialogBody>
</Dialog>
</Dashboard>
</Screen>
);
Expand All @@ -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;

0 comments on commit 71e7678

Please sign in to comment.