1
0
Fork 0

wip(backend): concluded apikeys routes and docs

This commit is contained in:
joostdecock 2022-11-06 20:16:01 +01:00
parent d563bb2d17
commit bd7c3e8b6e
9 changed files with 415 additions and 45 deletions

View file

@ -12,38 +12,39 @@ refer to [the section on authenticating to the
API](/reference/backend/api#authentication). API](/reference/backend/api#authentication).
</Tip> </Tip>
## Create a new API key ## Create a new API key
Create a new API key. The API key will belong to the user who is authenticated Create a new API key. The API key will belong to the user who is authenticated
when making the call. Supported for both JWT and KEY authentication. when making the call. Supported for both JWT and KEY authentication.
<Note compact>
The response to this API call is the only time the secret will be
revealed.
</Note>
### Endpoints ### Endpoints
| Method | Path | Description | | Method | Path | Description | Auth |
| ------ | ---- | ----------- | | ------ | ---- | ----------- | ---- |
| `POST` | `/apikey/jwt` | Create a new API key. Endpoint for JWT authentication | | <Method post /> | `/apikey/jwt` | Create a new API key | _jwt_ |
| `POST` | `/apikey/key` | Create a new API key. Endpoint for API key authentication | | <Method post /> | `/apikey/key` | Create a new API key | _key_ |
### Parameters ### Parameters
<Tabs tabs="Request, Response"> <Tabs tabs="Request, Response">
<Tab> <Tab>
| Variable | Type | Description | | Where | Variable | Type | Description |
| -------- | -------- | ----------- | | ----- | ------------- | -------- | ----------- |
| `name` | `string` | Create a new API key. Endpoint for JWT authentication | | _body_ | `name` | `string` | Create a new API key. Endpoint for JWT authentication |
| `level` | `number` | A privilege level from 0 to 8. | | _body_ | `level` | `number` | A privilege level from 0 to 8. |
| `expiresIn` | `number` | The number of seconds until this key expires. | | _body_ | `expiresIn` | `number` | body | The number of seconds until this key expires. |
</Tab> </Tab>
<Tab> <Tab>
Returns status code `200` on success, `400` on if the request is malformed, and Returns HTTP status code <StatusCode status="201"/> on success, <StatusCode status="400"/> if
`500` on server error. the request is malformed, and <StatusCode status="500"/> on server error.
| Value | Type | Description | | Value | Type | Description |
| ------------------- | -------- | ----------- | | ------------------- | -------- | ----------- |
| `result` | `string` | `success` on success, and `error` on error | | `result` | `string` | `created` on success, and `error` on error |
| `apikey.key` | `string` | The API key | | `apikey.key` | `string` | The API key |
| `apikey.secret` | `string` | The API secret | | `apikey.secret` | `string` | The API secret |
| `apikey.level` | `number` | The privilege level of the API key | | `apikey.level` | `number` | The privilege level of the API key |
@ -57,7 +58,7 @@ Returns status code `200` on success, `400` on if the request is malformed, and
<Tabs tabs="Request, Response"> <Tabs tabs="Request, Response">
<Tab> <Tab>
```js ```js
const token = axios.post( const apiKey = axios.post(
'https://backend.freesewing.org/apikey/jwt', 'https://backend.freesewing.org/apikey/jwt',
{ {
name: 'My first API key', name: 'My first API key',
@ -90,6 +91,185 @@ const token = axios.post(
</Tab> </Tab>
</Tabs> </Tabs>
## Read an API key
Reads an existing API key. Note that the API secret can only be retrieved at
the moment the API key is created.
<Note compact>
You need the `admin` role to read API keys of other users
</Note>
### Endpoints
| Method | Path | Description | Auth |
| ------ | ---- | ----------- | ---- |
| <Method get /> | `/apikey/:id/jwt` | Reads an API key | _jwt_ |
| <Method get /> | `/apikey/:id/key` | Reads an API key | _key_ |
### Parameters
<Tabs tabs="Request, Response">
<Tab>
| Where | Variable | Type | Description |
| ----- | ----------- | -------- | ----------- |
| _url_ | `:id` | `string` | The `key` field of the API key |
</Tab>
<Tab>
Returns HTTP status code <StatusCode status="200"/> on success, <StatusCode status="400"/> if
the request is malformed, <StatusCode status="404"/> if the key is not found,
and <StatusCode status="500"/> on server error.
| Value | Type | Description |
| ------------------- | -------- | ----------- |
| `result` | `string` | `success` on success, and `error` on error |
| `apikey.key` | `string` | The API key |
| `apikey.level` | `number` | The privilege level of the API key |
| `apikey.expiresAt` | `string` | A string representation of the moment the API key expires |
| `apikey.name` | `string` | The name of the API key |
| `apikey.userId` | `number` | The ID of the user who created the API key |
</Tab>
</Tabs>
### Example
<Tabs tabs="Request, Response">
<Tab>
```js
const keyInfo = axios.get(
'https://backend.freesewing.org/apikey/7ea12968-7758-40b6-8c73-75cc99be762b/jwt',
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
```
</Tab>
<Tab>
```json
{
result: 'success',
apikey: {
key: '7ea12968-7758-40b6-8c73-75cc99be762b',
level: 3,
expiresAt: '2022-11-06T15:57:30.190Z',
name: 'My first API key',
userId: 61
}
}
```
</Tab>
</Tabs>
## Read the current API key
Reads the API key with which the current request was authenticated.
### Endpoints
| Method | Path | Description | Auth |
| ------ | ---- | ----------- | ---- |
| <Method get /> | `/whoami/key` | Reads the current API key | _key_ |
### Parameters
<Tabs tabs="Request, Response">
<Tab>
<Note compact>This endpoint takes no parameters</Note>
</Tab>
<Tab>
Returns status code `200` on success, `400` on if the request is malformed,
`404` if the key is not found, and `500` on server error.
| Value | Type | Description |
| ------------------- | -------- | ----------- |
| `result` | `string` | `success` on success, and `error` on error |
| `apikey.key` | `string` | The API key |
| `apikey.level` | `number` | The privilege level of the API key |
| `apikey.expiresAt` | `string` | A string representation of the moment the API key expires |
| `apikey.name` | `string` | The name of the API key |
| `apikey.userId` | `number` | The ID of the user who created the API key |
</Tab>
</Tabs>
### Example
<Tabs tabs="Request, Response">
<Tab>
```js
const keyInfo = axios.get(
'https://backend.freesewing.org/whoami/key',
{
auth: {
username: apikey.key,
password: apikey.secret,
}
}
)
```
</Tab>
<Tab>
```json
{
result: 'success',
apikey: {
key: '7ea12968-7758-40b6-8c73-75cc99be762b',
level: 3,
expiresAt: '2022-11-06T15:57:30.190Z',
name: 'My first API key',
userId: 61
}
}
```
</Tab>
</Tabs>
## Remove an API key
Removes an existing API key.
<Note compact>
You need the `admin` role to remove API keys of other users
</Note>
### Endpoints
| Method | Path | Description | Auth |
| ------ | ---- | ----------- | ---- |
| <Method delete /> | `/apikey/:id/jwt` | Removes an API key | _jwt_ |
| <Method delete /> | `/apikey/:id/key` | Removes an API key | _key_ |
### Parameters
<Tabs tabs="Request, Response">
<Tab>
| Where | Variable | Type | Description |
| ----- | ----------- | -------- | ----------- |
| _url_ | `:id` | `string` | The `key` field of the API key |
</Tab>
<Tab>
Returns HTTP status code <StatusCode status="204"/> on success, <StatusCode status="400"/> if
the request is malformed, <StatusCode status="404"/> if the key is not found,
and <StatusCode status="500"/> on server error.
</Tab>
</Tabs>
### Example
<Tabs tabs="Request, Response">
<Tab>
```js
const keyInfo = axios.get(
'https://backend.freesewing.org/apikey/7ea12968-7758-40b6-8c73-75cc99be762b/jwt',
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
```
</Tab>
<Tab>
<Note compact>Status code <StatusCode status="204"/> (no content) does not come with a body</Note>
</Tab>
</Tabs>
## Notes ## Notes
The following is good to keep in mind when working with API keys: The following is good to keep in mind when working with API keys:

View file

@ -1,5 +1,6 @@
--- ---
title: REST API title: Backend REST API
linktitle: REST API
--- ---
This is the reference documentation for the FreeSewing backend REST API. This is the reference documentation for the FreeSewing backend REST API.
@ -19,30 +20,35 @@ automation tasks such as creating issues on Github.
## Authentication ## Authentication
This API is not accessible without authentication. Apart from a handlful of API endpoints that are accessible without
authentication (typically the ones dealing with the signup flow or password
recovery), this API requires authentication.
The FreeSewing backend API allows two types of authentication: Two different types of authentication are supports:
- JSON Web Tokens (jwt): This is typically used to authenticate humans in a - **JSON Web Tokens** (jwt): This is typically used to authenticate humans in a
browser session. browser session.
- API Keys (key): This is typically used to interact with the API in an - **API Keys** (key): This is typically used to interact with the API in an
automated way. Like in a script, or a CI/CD context. automated way. Like in a script, a CI/CD context, a serverless runner, and so
on.
Apart from the handlful of API endpoints that are accessible without For each endpoint, the API has a variant depending on what authentication you
authentication (typically the ones dealing with the signup flow), this API has want to use:
a variant for each route depending on what authentication you want to use:
- `/some/route/jwt` : Authenticate with JWT - `/some/route/jwt` : Authenticate with JWT
- `/some/route/key` : Authenticate with an API key and secret - `/some/route/key` : Authenticate with an API key and secret
### JWT authentication ### JWT authentication
The use of JSON Web Tokens ([jwt](https://jwt.io)) is typically used in a browser context where we want to establish a *session*. The use of JSON Web Tokens ([jwt](https://jwt.io)) is typically used in a
browser context where we want to establish a *session*.
To get a token, you must first authenticate at the `/login` endpoint with username and password. To get a token, you must first authenticate at the `/login` endpoint with
You will receive a token in the response. username and password. You will receive a JSON Web Token (jwt) as part of the
response.
In subsequent API calls, you must then include this token in the `Authorization` header prefixed by `Bearer`. Liek his: In subsequent API calls, you must then include this token in the
`Authorization` header prefixed by `Bearer`. Like his:
```js ```js
const account = await axios.get( const account = await axios.get(
@ -57,11 +63,18 @@ const account = await axios.get(
### API key authentication ### API key authentication
The combination API key & secret serves as a username & password for HTTP basic authentication. The combination of API key & secret serves as a username & password for [HTTP
In basic authentication, the password is sent unencrypted, but since the FreeSewing backend is only reachable over a TLS encrypted connection, this is not a problem. basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication).
On the plus side, sending a username and password with a request is supported pretty much everywhere. <Note>
In addition, there is no need to establish a session first, so this make the entire transation stateless. In basic authentication, the password is sent
unencrypted. To guard against this, this API should only be served over a
connectin encrypted with TLS. (a url starting with `https://`).
</Note>
Sending a username and password with a request like this is supported
pretty much everywhere. In addition, there is no need to establish a session
first, so this make the entire transation stateless.
Below is an example using curl: Below is an example using curl:
@ -96,8 +109,8 @@ The table below lists the priviledge of all levels as well as their correspondin
| `4` | **write all** account data | ✅ | ✅ | ✅ | ✅ | | `4` | **write all** account data | ✅ | ✅ | ✅ | ✅ |
| `5` | **read** measurements or patterns of **other users** | ❌ | ✅ | ✅ | ✅ | | `5` | **read** measurements or patterns of **other users** | ❌ | ✅ | ✅ | ✅ |
| `6` | **read all** account data of **other users** | ❌ | ❌ | ✅ | ✅ | | `6` | **read all** account data of **other users** | ❌ | ❌ | ✅ | ✅ |
| `7` | **write** access through **specific support methods** | ❌ | ❌ | ✅ | ✅ | | `7` | **write** account data of **other users** through **specific support methods** | ❌ | ❌ | ✅ | ✅ |
| `8` | impersonate other user, full write access | ❌ | ❌ | ❌ | ✅ | | `8` | impersonate other users, **full write access** | ❌ | ❌ | ❌ | ✅ |
## API Routes ## API Routes

View file

@ -2,4 +2,57 @@
title: FreeSewing backend title: FreeSewing backend
--- ---
FIXME: Explain what this is about The FreeSewing backend handles all user data. Prior to version 3 of FreeSewing,
the backend was only used internally as the data store for our frontend, the
FreeSewing.org website.
In version 3, we have rewritten the backend with the explicit goal to offer it
as a service to users and developers. This allows integration with other tools
such as hosted instances of our lab, CLI tools, serverless runners, CI/CD
environments and so on.
In other words, we no longer merely provide our own frontend, you can now also
use our backend as a service to build your own projects.
## Changes for developers
### Authentication with JWT and API keys
Before version 3, the backend only supported authentication via JSON Web
Tokens. That's fine for a browser session, but not very handy if you want to
talk to the API directly.
Since version 3, we support authentication with API keys. Furthermore, we
allow any FreeSewing user to generate their own API keys.
In other words, if you want to connect to our backend API, you don't need to
ask us. You can generate your own API key and start right away.
We've made a number of changes to make it easier for external developers and
contributors to work with our backend.
### Sqlite instead of MongoDB
Our backend used to use MongoDB for storage. Since version 3, we've moved to
Sqlite which is a file-based database making local development a breeze since
you don't need to run a local database server.
### Sanity instead of local storage
We now use Sanity and the Sanity API to stored images for users (avatars for
user accounts and people). Furthermore, we also generate PDFs in the browser
now so we also don't need storage for that.
As a result, our backend does not need any storage, only access to the Sqlite
file. This also makes it easier to work with the backend as a developer.
## Use, don't abuse
Our backend API runs in a cloud environment and while we do not charge for
access to the API, we do need to pay the bills of said cloud provider.
As such, please be mindful of the amount of requests you generate. And if you
have big plans, please reach out to us to discuss them first.
We will monitor the use of our backend API and we may at any moment decide to
revoke API keys if we feel the use is beyond what we can or want to support.

View file

@ -62,3 +62,16 @@ ApikeyController.prototype.whoami = async (req, res, tools) => {
return Apikey.sendResponse(res) return Apikey.sendResponse(res)
} }
/*
* Remove API key
*
* This is the endpoint that handles removal of API keys/tokens
* See: https://freesewing.dev/reference/backend/api/apikey
*/
ApikeyController.prototype.delete = async (req, res, tools) => {
const Apikey = new ApikeyModel(tools)
await Apikey.removeIfAllowed({ id: req.params.id }, req.user)
return Apikey.sendResponse(res)
}

View file

@ -25,7 +25,8 @@ ApikeyModel.prototype.setResponse = function (status = 200, error = false, data
...data, ...data,
}, },
} }
if (status > 201) { if (status === 201) this.response.body.result = 'created'
else if (status > 201) {
this.response.body.error = error this.response.body.error = error
this.response.body.result = 'error' this.response.body.result = 'error'
this.error = true this.error = true
@ -68,12 +69,34 @@ ApikeyModel.prototype.readIfAllowed = async function (where, user) {
}) })
} }
ApikeyModel.prototype.removeIfAllowed = async function (where, user) {
if (!this.User.authenticatedUser) await this.User.loadAuthenticatedUser(user)
await this.read(where)
if (!this.record) return this.setResponse(404, 'apikeyNotFound')
if (this.record.userId !== this.User.authenticatedUser.id) {
// Not own key - only admin can do that
if (this.User.authenticatedUser.role !== 'admin') {
return this.setResponse(400, 'permissionLackingToRemoveOtherApiKey')
}
}
await this.remove(where)
return this.setResponse(204)
}
ApikeyModel.prototype.read = async function (where) { ApikeyModel.prototype.read = async function (where) {
this.record = await this.prisma.apikey.findUnique({ where }) this.record = await this.prisma.apikey.findUnique({ where })
return this return this
} }
ApikeyModel.prototype.remove = async function (where) {
await this.prisma.apikey.delete({ where })
this.record = false
return this
}
ApikeyModel.prototype.create = async function ({ body, user }) { ApikeyModel.prototype.create = async function ({ body, user }) {
if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
if (!body.name) return this.setResponse(400, 'nameMissing') if (!body.name) return this.setResponse(400, 'nameMissing')
@ -101,7 +124,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) {
name: body.name, name: body.name,
level: body.level, level: body.level,
secret: asJson(hashPassword(secret)), secret: asJson(hashPassword(secret)),
userId: user._id, userId: user._id || user.userId,
}, },
}) })
} catch (err) { } catch (err) {
@ -109,7 +132,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) {
return this.setResponse(500, 'createApikeyFailed') return this.setResponse(500, 'createApikeyFailed')
} }
return this.setResponse(200, 'success', { return this.setResponse(201, 'created', {
apikey: { apikey: {
key: this.record.id, key: this.record.id,
secret, secret,

View file

@ -27,4 +27,12 @@ export function apikeyRoutes(tools) {
app.get('/whoami/key', passport.authenticate(...bsc), (req, res) => app.get('/whoami/key', passport.authenticate(...bsc), (req, res) =>
Apikey.whoami(req, res, tools) Apikey.whoami(req, res, tools)
) )
// Remove Apikey
app.delete('/apikey/:id/jwt', passport.authenticate(...jwt), (req, res) =>
Apikey.delete(req, res, tools)
)
app.delete('/apikey/:id/key', passport.authenticate(...bsc), (req, res) =>
Apikey.delete(req, res, tools)
)
} }

View file

@ -287,10 +287,10 @@ describe(`${user} Signup flow and authentication`, () => {
expiresIn: 60, expiresIn: 60,
}) })
.end((err, res) => { .end((err, res) => {
expect(res.status).to.equal(200) expect(res.status).to.equal(201)
expect(res.type).to.equal('application/json') expect(res.type).to.equal('application/json')
expect(res.charset).to.equal('utf-8') expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
expect(typeof res.body.apikey.key).to.equal('string') expect(typeof res.body.apikey.key).to.equal('string')
expect(typeof res.body.apikey.secret).to.equal('string') expect(typeof res.body.apikey.secret).to.equal('string')
expect(typeof res.body.apikey.expiresAt).to.equal('string') expect(typeof res.body.apikey.expiresAt).to.equal('string')
@ -300,6 +300,30 @@ describe(`${user} Signup flow and authentication`, () => {
}) })
}) })
step(`${user} Create API Key with KEY`, (done) => {
chai
.request(config.api)
.post('/apikey/key')
.auth(store.apikey.key, store.apikey.secret)
.send({
name: 'Test API key with key',
level: 4,
expiresIn: 60,
})
.end((err, res) => {
expect(res.status).to.equal(201)
expect(res.type).to.equal('application/json')
expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`created`)
expect(typeof res.body.apikey.key).to.equal('string')
expect(typeof res.body.apikey.secret).to.equal('string')
expect(typeof res.body.apikey.expiresAt).to.equal('string')
expect(res.body.apikey.level).to.equal(4)
store.apikeykey = res.body.apikey
done()
})
})
step(`${user} Read API Key with KEY (whoami)`, (done) => { step(`${user} Read API Key with KEY (whoami)`, (done) => {
chai chai
.request(config.api) .request(config.api)
@ -320,7 +344,7 @@ describe(`${user} Signup flow and authentication`, () => {
chai chai
.request(config.api) .request(config.api)
.get(`/apikey/${store.apikey.key}/key`) .get(`/apikey/${store.apikey.key}/key`)
.auth(store.apikey.key, store.apikey.secret) .auth(store.apikeykey.key, store.apikeykey.secret)
.end((err, res) => { .end((err, res) => {
expect(res.status).to.equal(200) expect(res.status).to.equal(200)
expect(res.type).to.equal('application/json') expect(res.type).to.equal('application/json')
@ -335,7 +359,7 @@ describe(`${user} Signup flow and authentication`, () => {
step(`${user} Read API Key with JWT`, (done) => { step(`${user} Read API Key with JWT`, (done) => {
chai chai
.request(config.api) .request(config.api)
.get(`/apikey/${store.apikey.key}/jwt`) .get(`/apikey/${store.apikeykey.key}/jwt`)
.set('Authorization', 'Bearer ' + store.token) .set('Authorization', 'Bearer ' + store.token)
.end((err, res) => { .end((err, res) => {
expect(res.status).to.equal(200) expect(res.status).to.equal(200)
@ -343,7 +367,18 @@ describe(`${user} Signup flow and authentication`, () => {
expect(res.charset).to.equal('utf-8') expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`success`)
const checks = ['key', 'level', 'expiresAt', 'name', 'userId'] const checks = ['key', 'level', 'expiresAt', 'name', 'userId']
checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i])) checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikeykey[i]))
done()
})
})
step(`${user} Remove API Key with KEY`, (done) => {
chai
.request(config.api)
.delete(`/apikey/${store.apikeykey.key}/key`)
.auth(store.apikeykey.key, store.apikeykey.secret)
.end((err, res) => {
expect(res.status).to.equal(204)
done() done()
}) })
}) })

View file

@ -7,8 +7,52 @@ import { Tab, Tabs } from './tabs.js'
import Example from './example.js' import Example from './example.js'
import Examples from './examples.js' import Examples from './examples.js'
const methodClasses = {
get: 'bg-green-600 text-white',
post: 'bg-sky-600 text-white',
put: 'bg-orange-500 text-white',
delete: 'bg-red-600 text-white',
}
const Method = (props) => {
let method = false
for (const m in methodClasses) {
if (!method && props[m]) method = m.toUpperCase()
}
return (
<div
className={`my-1 text-xs inline-flex items-center font-bold leading-sm uppercase px-3 py-1 rounded-full ${
methodClasses[method.toLowerCase()]
}`}
>
{method}
</div>
)
}
const statusClasses = {
2: 'bg-green-600 text-white',
4: 'bg-orange-500 text-white',
5: 'bg-red-600 text-white',
}
const StatusCode = ({ status }) => {
return (
<div
className={`my-1 text-xs inline-flex items-center font-bold leading-sm uppercase px-3 py-1 rounded-full ${
statusClasses['' + status.slice(0, 1)]
}`}
>
{status}
</div>
)
}
const mdxCustomComponents = (app = false) => ({ const mdxCustomComponents = (app = false) => ({
// Custom components // Custom components
Method,
StatusCode,
Comment: (props) => <Popout {...props} comment />, Comment: (props) => <Popout {...props} comment />,
Fixme: (props) => <Popout {...props} fixme />, Fixme: (props) => <Popout {...props} fixme />,
Link: (props) => <Popout {...props} link />, Link: (props) => <Popout {...props} link />,

View file

@ -18,3 +18,4 @@
<!-- Background opacity for highlighted lines in code --> <!-- Background opacity for highlighted lines in code -->
<code class="bg-yellow-300 bg-opacity-5" /> <code class="bg-yellow-300 bg-opacity-5" />
<code class="bg-orange-300 bg-opacity-5 opacity-80 line-through decoration-orange-500" /> <code class="bg-orange-300 bg-opacity-5 opacity-80 line-through decoration-orange-500" />