-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add personal access tokens for API access (#271)
* Add API for creating and viewing personal access tokens * Redesign user settings page to match other settings pages * Fix linter errors * Fix warning with preferred name input * Fix issues with tokens API and model * Initial work on access token management * Add ability to copy new tokens to clipboard * Add ability to delete tokens * Add loading and empty states to token list * Work on authenticating with tokens * Tweak capitalization to match the rest of the site * Tweak UX of token creation * Track and display last-used time of tokens * Disable new token button if input is empty * Tweak color of token copy button * Add tests for token authn * Be explicit about length of access token hash * Fix issue in courses API test * Add changelog entry * Add documentation for tokens * Address feedback from code review * Add custom dark styling for alerts * Display token dates in a more human-readable way
- Loading branch information
1 parent
99b2690
commit 0a269f5
Showing
21 changed files
with
791 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
const router = require('express').Router({ | ||
mergeParams: true, | ||
}) | ||
const uuidv4 = require('uuid/v4') | ||
const crypto = require('crypto') | ||
|
||
const { check } = require('express-validator/check') | ||
const { matchedData } = require('express-validator/filter') | ||
|
||
const { ApiError, failIfErrors } = require('./util') | ||
|
||
const { AccessToken } = require('../models') | ||
const safeAsync = require('../middleware/safeAsync') | ||
|
||
// Get all tokens for authenticated user | ||
router.get( | ||
'/', | ||
safeAsync(async (req, res, _next) => { | ||
const { id } = res.locals.userAuthn | ||
const tokens = await AccessToken.findAll({ | ||
where: { | ||
userId: id, | ||
}, | ||
}) | ||
res.send(tokens) | ||
}) | ||
) | ||
|
||
// Get single token for authenticated user | ||
router.get( | ||
'/:tokenId', | ||
safeAsync(async (req, res, next) => { | ||
const { id } = res.locals.userAuthn | ||
const { tokenId } = req.params | ||
const token = await AccessToken.findOne({ | ||
where: { | ||
id: tokenId, | ||
userId: id, | ||
}, | ||
}) | ||
if (!token) { | ||
next(new ApiError(404, 'Token not found')) | ||
return | ||
} | ||
res.send(token) | ||
}) | ||
) | ||
|
||
// Creates a new token for the user | ||
router.post( | ||
'/', | ||
[check('name').isLength({ min: 1 }), failIfErrors], | ||
safeAsync(async (req, res, _next) => { | ||
const { id: userId } = res.locals.userAuthn | ||
const data = matchedData(req) | ||
const token = uuidv4() | ||
const tokenHash = crypto | ||
.createHash('sha256') | ||
.update(token, 'utf8') | ||
.digest('hex') | ||
const newToken = await AccessToken.create({ | ||
name: data.name, | ||
hash: tokenHash, | ||
userId, | ||
}) | ||
res.status(201).send({ ...newToken.toJSON(), token }) | ||
}) | ||
) | ||
|
||
router.delete( | ||
'/:tokenId', | ||
safeAsync(async (req, res, _next) => { | ||
const { id: userId } = res.locals.userAuthn | ||
const { tokenId: id } = req.params | ||
|
||
const deleteCount = await AccessToken.destroy({ | ||
where: { | ||
id, | ||
userId, | ||
}, | ||
}) | ||
|
||
if (deleteCount === 0) { | ||
res.status(404).send() | ||
} else { | ||
res.status(204).send() | ||
} | ||
}) | ||
) | ||
|
||
module.exports = router |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
/* eslint-env jest */ | ||
const app = require('../app') | ||
const testutil = require('../../test/util') | ||
const { requestAsUser } = require('../../test/util') | ||
|
||
beforeEach(async () => { | ||
await testutil.setupTestDb() | ||
await testutil.populateTestDb() | ||
}) | ||
|
||
afterEach(() => testutil.destroyTestDb()) | ||
|
||
describe('Tokens API', () => { | ||
describe('GET /tokens', () => { | ||
it("returns user's own tokens", async () => { | ||
const request = await requestAsUser(app, 'admin') | ||
const res = await request.get(`/api/tokens`) | ||
expect(res.statusCode).toEqual(200) | ||
expect(res.body).toHaveLength(1) | ||
expect(res.body[0]).toMatchObject({ | ||
name: 'Admin test token', | ||
hash: | ||
'8b66be9b382176ea802a06d1be2a5e66d53fadf279a5fc40e17c6862c75d4e0f', | ||
}) | ||
// Make sure token is never stored or returned | ||
expect(res.body[0].token).toBeUndefined() | ||
}) | ||
|
||
it('excludes tokens of other users', async () => { | ||
const request = await requestAsUser(app, 'student') | ||
const res = await request.get(`/api/tokens`) | ||
expect(res.statusCode).toBe(200) | ||
expect(res.body).toEqual([]) | ||
}) | ||
}) | ||
describe('GET /tokens/:tokenId', () => { | ||
it("fails for token that doesn't exist", async () => { | ||
const request = await requestAsUser(app, 'admin') | ||
const res = await request.get(`/api/tokens/1234`) | ||
expect(res.statusCode).toEqual(404) | ||
}) | ||
}) | ||
|
||
describe('POST /tokens', () => { | ||
it('creates a new token', async () => { | ||
const token = { name: 'my new token' } | ||
const request = await requestAsUser(app, 'admin') | ||
const res = await request.post(`/api/tokens`).send(token) | ||
expect(res.statusCode).toBe(201) | ||
expect(res.body).toMatchObject({ | ||
id: 2, | ||
name: 'my new token', | ||
token: expect.any(String), | ||
hash: expect.any(String), | ||
lastUsedAt: null, | ||
}) | ||
|
||
// Check that we can now fetch the new token | ||
const res2 = await request.get('/api/tokens') | ||
expect(res2.statusCode).toBe(200) | ||
expect(res2.body).toHaveLength(2) | ||
expect(res2.body[1].name).toBe('my new token') | ||
}) | ||
}) | ||
|
||
describe('DELETE /tokens', () => { | ||
it('removes a token for a user', async () => { | ||
const request = await requestAsUser(app, 'admin') | ||
const res = await request.delete('/api/tokens/1') | ||
expect(res.statusCode).toBe(204) | ||
|
||
const res2 = await request.get('/api/tokens/1') | ||
expect(res2.statusCode).toBe(404) | ||
}) | ||
|
||
it('fails for user who does not own the token', async () => { | ||
const request = await requestAsUser(app, 'student') | ||
const res = await request.delete('/api/tokens/1') | ||
expect(res.statusCode).toBe(404) | ||
|
||
const adminRequest = await requestAsUser(app, 'admin') | ||
const res2 = await adminRequest.get('/api/tokens/1') | ||
expect(res2.statusCode).toBe(200) | ||
expect(res2.body.name).toBe('Admin test token') | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
/* eslint-env browser */ | ||
import React, { useRef } from 'react' | ||
import PropTypes from 'prop-types' | ||
import { | ||
ListGroupItem, | ||
Alert, | ||
InputGroup, | ||
Input, | ||
InputGroupAddon, | ||
Button, | ||
} from 'reactstrap' | ||
import moment from 'moment' | ||
|
||
const AccessTokenListGroupItem = props => { | ||
const inputRef = useRef() | ||
|
||
const copyValue = e => { | ||
inputRef.current.select() | ||
document.execCommand('copy') | ||
e.target.focus() | ||
} | ||
|
||
const createdAt = moment(props.createdAt) | ||
const createdAtHumanReadable = createdAt.fromNow() | ||
const createdAtCalendar = createdAt.calendar() | ||
const lastUsedAt = moment(props.lastUsedAt) | ||
const lastUsedAtHumanReadable = lastUsedAt.fromNow() | ||
const lastUsedAtCalendar = lastUsedAt.calendar() | ||
|
||
return ( | ||
<ListGroupItem> | ||
<div className="d-flex flex-row align-items-center"> | ||
<div className="d-flex flex-column"> | ||
<strong>{props.name}</strong> | ||
<span className="text-muted" title={createdAtCalendar}> | ||
Created {createdAtHumanReadable} | ||
</span> | ||
<span className="text-muted" title={lastUsedAtCalendar}> | ||
{props.lastUsedAt | ||
? `Last used ${lastUsedAtHumanReadable}` | ||
: 'Never used'} | ||
</span> | ||
</div> | ||
<Button | ||
color="danger" | ||
outline | ||
className="ml-auto" | ||
onClick={props.onDeleteToken} | ||
> | ||
Delete | ||
</Button> | ||
</div> | ||
{props.token && ( | ||
<> | ||
<Alert fade={false} color="success" className="mt-3"> | ||
<strong className="alert-heading">Token created!</strong> | ||
<br /> | ||
Be sure to take note of it now, as you won't be able to see it | ||
later. | ||
<InputGroup className="mt-2"> | ||
<Input | ||
className="bg-light" | ||
readOnly | ||
value={props.token} | ||
onFocus={e => e.target.select()} | ||
innerRef={inputRef} | ||
/> | ||
<InputGroupAddon addonType="append"> | ||
<Button color="secondary" onClick={copyValue}> | ||
Copy | ||
</Button> | ||
</InputGroupAddon> | ||
</InputGroup> | ||
</Alert> | ||
</> | ||
)} | ||
</ListGroupItem> | ||
) | ||
} | ||
|
||
AccessTokenListGroupItem.propTypes = { | ||
name: PropTypes.string.isRequired, | ||
createdAt: PropTypes.string.isRequired, | ||
lastUsedAt: PropTypes.string, | ||
token: PropTypes.string, | ||
onDeleteToken: PropTypes.func.isRequired, | ||
} | ||
|
||
AccessTokenListGroupItem.defaultProps = { | ||
lastUsedAt: null, | ||
token: null, | ||
} | ||
|
||
export default AccessTokenListGroupItem |
Oops, something went wrong.