Skip to content

Commit

Permalink
Add personal access tokens for API access (#271)
Browse files Browse the repository at this point in the history
* 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
nwalters512 authored and james9909 committed Apr 17, 2019
1 parent 99b2690 commit 0a269f5
Show file tree
Hide file tree
Showing 21 changed files with 791 additions and 44 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ with the current semantic version and the next changes should go under a **[Next
* Hides the create queue button when creating a queue and the create course button when creating a course. ([@jackieo5023](https://github.com/jackieo5023) in [#274](https://github.com/illinois/queue/pull/274))
* Fix double rendering of the course page. ([@james9909](https://github.com/james9909) in [#275](https://github.com/illinois/queue/pull/275))
* Add improved styles, docs, and server-side rendering for dark mode. ([@nwalters512](https://github.com/nwalters512) in [#276](https://github.com/illinois/queue/pull/276))
* Add personal access tokens for API access. ([@nwalters512](https://github.com/nwalters512) in [#271](https://github.com/illinois/queue/pull/271))

## v1.1.0

Expand Down
11 changes: 11 additions & 0 deletions doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,14 @@ described here are located under `/api/`.
| `/questions/:questionId/answering` | `POST` | Mark a question as being answered |
| `/questions/:questionId/answering` | `DELETE` | Mark a question as no longer being answered |
| `/questions/:questionId/answered` | `POST` | Mark the question as answered |

## API Access Tokens

When logged into [queue.illinois.edu/q](https://queue.illinois.edu/q), you're automatically authenticated with the API. However, if you want to access the API from outside the queue to programmatically access or manipulate it, you can create personal access tokens that don't rely on Shibboleth for authentication.

To create a token, visit https://queue.illinois.edu/q/settings. After copying down your token, you can pass the token either with the `private_token` query parameter or the `Private-Token` header:

```
curl -H "Private-Token: TOKEN" https://queue.illinois.edu/q/api/<REST_OF_PATH>
curl https://queue.illinois.edu/q/api/<REST_OF_PATH>?private_token=TOKEN
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"styled-jsx": "^3.2.1",
"umzug": "^2.2.0",
"use-debounce": "^1.1.2",
"uuid": "^3.3.2",
"validator": "^10.11.0",
"winston": "^3.2.1"
},
Expand Down
2 changes: 1 addition & 1 deletion src/api/courses.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe('Courses API', () => {
})
})

describe('POST /api/course/:courseId/staff', async () => {
describe('POST /api/course/:courseId/staff', () => {
test('succeeds for admin', async () => {
const newUser = { netid: 'newnetid' }
const request = await requestAsUser(app, 'admin')
Expand Down
1 change: 0 additions & 1 deletion src/api/queues.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/* eslint-env jest */
// const request = require('supertest')
const app = require('../app')
const testutil = require('../../test/util')
const { requestAsUser } = require('../../test/util')
Expand Down
91 changes: 91 additions & 0 deletions src/api/tokens.js
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
87 changes: 87 additions & 0 deletions src/api/tokens.test.js
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')
})
})
})
7 changes: 6 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ if (isDev || isNow) {
app.use(`${baseUrl}/login/shib`, require('./auth/shibboleth'))
app.use(`${baseUrl}/logout`, require('./auth/logout'))

app.use(`${baseUrl}/api`, require('./middleware/authnToken'))
app.use(`${baseUrl}/api`, require('./middleware/authnJwt'))
app.use(`${baseUrl}/api`, require('./middleware/checkAuthn'))
app.use(`${baseUrl}/api`, require('./middleware/authz'))

// This will selectively send redirects if the user needs to (re)authenticate
Expand All @@ -41,6 +43,7 @@ app.use(`${baseUrl}/`, require('./middleware/redirectIfNeedsAuthn'))

// API routes
app.use(`${baseUrl}/api/users`, require('./api/users'))
app.use(`${baseUrl}/api/tokens`, require('./api/tokens'))
app.use(`${baseUrl}/api/courses`, require('./api/courses'))
app.use(`${baseUrl}/api/queues`, require('./api/queues'))
app.use(`${baseUrl}/api/questions`, require('./api/questions'))
Expand All @@ -57,7 +60,9 @@ app.use(`${baseUrl}/api`, (err, _req, res, _next) => {
const statusCode = err.httpStatusCode || 500
const message = err.message || 'Something went wrong'
res.status(statusCode).json({ message })
logger.error(err)
if (process.env.NODE_ENV !== 'test') {
logger.error(err)
}
})
app.use(`${baseUrl}/api`, (_req, res, _next) => {
res.status(404).json({
Expand Down
18 changes: 18 additions & 0 deletions src/components/darkmode.scss
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,22 @@ body.darkmode {
color: #adb5bd !important;
border-color: transparent !important;
}

.alert-success {
color: $page-background !important;
background-color: #00d97e !important;
border-color: #00d97e !important;
}

.alert-warning {
color: $page-background;
background-color: #f6c343;
border-color: #f6c343;
}

.alert-danger {
color: $text-color;
background-color: #e63757;
border-color: #e63757;
}
}
94 changes: 94 additions & 0 deletions src/components/userSettings/AccessTokenListGroupItem.jsx
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&apos;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
Loading

0 comments on commit 0a269f5

Please sign in to comment.