1
0
Fork 0

wip(backend): more work on routes and docs

This commit is contained in:
joostdecock 2022-11-06 17:39:49 +01:00
parent 3fc08d8bdb
commit d563bb2d17
10 changed files with 349 additions and 17 deletions

View file

@ -0,0 +1,134 @@
---
title: API Keys
---
Documentation for the REST API endpoints to create, read, or remove API keys.
<Tip>
The FreeSewing backend REST API supports authentication both with JSON Web
Tokens (JWT) as with API keys (KEY). This describes the endpoints that deal
with creating, reading, and removing API keys. For authentication details,
refer to [the section on authenticating to the
API](/reference/backend/api#authentication).
</Tip>
## Create a new API key
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.
### Endpoints
| Method | Path | Description |
| ------ | ---- | ----------- |
| `POST` | `/apikey/jwt` | Create a new API key. Endpoint for JWT authentication |
| `POST` | `/apikey/key` | Create a new API key. Endpoint for API key authentication |
### Parameters
<Tabs tabs="Request, Response">
<Tab>
| Variable | Type | Description |
| -------- | -------- | ----------- |
| `name` | `string` | Create a new API key. Endpoint for JWT authentication |
| `level` | `number` | A privilege level from 0 to 8. |
| `expiresIn` | `number` | The number of seconds until this key expires. |
</Tab>
<Tab>
Returns status code `200` on success, `400` on if the request is malformed, and
`500` on server error.
| Value | Type | Description |
| ------------------- | -------- | ----------- |
| `result` | `string` | `success` on success, and `error` on error |
| `apikey.key` | `string` | The API key |
| `apikey.secret` | `string` | The API secret |
| `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 token = axios.post(
'https://backend.freesewing.org/apikey/jwt',
{
name: 'My first API key',
level: 2, // Read only
expiresIn: 3600, // One hour
},
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
```
</Tab>
<Tab>
```json
{
result: 'success',
apikey: {
key: '7ea12968-7758-40b6-8c73-75cc99be762b',
secret: '503d7adbdb3ec18ab27adfcd895d8b47a8d6bc8307d548500fbf9c05a5a8820e',
level: 3,
expiresAt: '2022-11-06T15:57:30.190Z',
name: 'My first API key',
userId: 61
}
}
```
</Tab>
</Tabs>
## Notes
The following is good to keep in mind when working with API keys:
### This is not the authentication documentation
The FreeSewing backend REST API supports authentication both with JSON Web
Tokens (JWT) as with API keys (KEY).
This describes the endpoints that deal with creating, reading, and removing API
keys. For authentication details, refer to [the section on authenticating to
the API](/reference/backend/api#authentication).
### API keys are immutable
Once created, API keys cannot be updated.
You should remove them and re-create a new one if you want to make change.
### API keys have an expiry
API keys have an expiry date. The maximum validity for an API key is 1 year.
### API keys have a permission level
API keys have a permission level. You can never create an API key with a higher
permission level than your own permission level.
### Circumstances that will trigger your API keys to be revoked
As a precaution, all your API keys will be revoked when:
- Your role is downgraded to a role with fewer privileges
- Your account is (b)locked
- You revoke your consent for FreeSewing to process your data
<Note>
This is not an exhaustive list. For example, if we find your use of our API to
be excessive, we might also revoke your API keys to shield us from the
financial impact of your use of our API.
</Note>

View file

@ -0,0 +1,104 @@
---
title: REST API
---
This is the reference documentation for the FreeSewing backend REST API.
<Fixme>
This documentation is under construction as we are re-working this API for v3.
</Fixme>
## Purpose of this API
This API is how one can interact with the FreeSewing backend data.
That means the data for our users, including all people they have added to
their profile, as well as all of the patterns they have saved to our profile.
This API also manages subscriptions to our newsletter, and a number of other
automation tasks such as creating issues on Github.
## Authentication
This API is not accessible without authentication.
The FreeSewing backend API allows two types of authentication:
- JSON Web Tokens (jwt): This is typically used to authenticate humans in a
browser session.
- 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.
Apart from the handlful of API endpoints that are accessible without
authentication (typically the ones dealing with the signup flow), this API has
a variant for each route depending on what authentication you want to use:
- `/some/route/jwt` : Authenticate with JWT
- `/some/route/key` : Authenticate with an API key and secret
### 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*.
To get a token, you must first authenticate at the `/login` endpoint with username and password.
You will receive a token in the response.
In subsequent API calls, you must then include this token in the `Authorization` header prefixed by `Bearer`. Liek his:
```js
const account = await axios.get(
`https://backend.freesewing.org/account/jwt`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
```
### API key authentication
The combination API key & secret serves as a username & password for HTTP basic authentication.
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.
On the plus side, sending a username and password with a request 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:
```sh
curl -u api-key-here:api-secret-here \
https://backend.freesewing.org/account/key
```
## Privilege levels for user roles and API keys
The privilege level is an intiger from `0` to `8`. The higher the number, the higher the priviledge.
User accounts have a `role` that determines their privilege level.
The available roles (with their privilege levels) are:
- **user**: `4`
- **bughunter**: `5`
- **support**: `6`
- **admin**: `8`
These roles are used when using JWT authentication, as that's typically used by humans.
When using API keys, the privilege level can be set on the API key itself in a more granular way.
The table below lists the priviledge of all levels as well as their corresponding <small><small><b>`role`</b></small></small>:
| Level | Abilities | <small><small>`user`</small></small> | <small><small>`bughunter`</small></small> | <small><small>`support`</small></small> | <small><small>`admin`</small></small> |
| --: | -- | :--: | :--: | :--: | :--: |
| `0` | authenticate | ✅ | ✅ | ✅ | ✅ |
| `1` | **read** measurements and patterns | ✅ | ✅ | ✅ | ✅ |
| `2` | **read all** account data | ✅ | ✅ | ✅ | ✅ |
| `3` | **write** measurements or patterns | ✅ | ✅ | ✅ | ✅ |
| `4` | **write all** account data | ✅ | ✅ | ✅ | ✅ |
| `5` | **read** measurements or patterns of **other users** | ❌ | ✅ | ✅ | ✅ |
| `6` | **read all** account data of **other users** | ❌ | ❌ | ✅ | ✅ |
| `7` | **write** access through **specific support methods** | ❌ | ❌ | ✅ | ✅ |
| `8` | impersonate other user, full write access | ❌ | ❌ | ❌ | ✅ |
## API Routes
<ReadMore />

View file

@ -0,0 +1,5 @@
---
title: FreeSewing backend
---
FIXME: Explain what this is about

View file

@ -10,7 +10,7 @@ export function ApikeyController() {}
* Create API key * Create API key
* *
* This is the endpoint that handles creation of API keys/tokens * This is the endpoint that handles creation of API keys/tokens
* See: https://freesewing.dev/reference/backend/api * See: https://freesewing.dev/reference/backend/api/apikey
*/ */
ApikeyController.prototype.create = async (req, res, tools) => { ApikeyController.prototype.create = async (req, res, tools) => {
const Apikey = new ApikeyModel(tools) const Apikey = new ApikeyModel(tools)
@ -22,8 +22,22 @@ ApikeyController.prototype.create = async (req, res, tools) => {
/* /*
* Read API key * Read API key
* *
* This is the endpoint that handles reading of API keys/tokens * This is the endpoint that handles creation of API keys/tokens
* See: https://freesewing.dev/reference/backend/api * See: https://freesewing.dev/reference/backend/api/apikey
*/
ApikeyController.prototype.read = async (req, res, tools) => {
const Apikey = new ApikeyModel(tools)
await Apikey.readIfAllowed({ id: req.params.id }, req.user)
return Apikey.sendResponse(res)
}
/*
* Read current API key (whoami)
*
* This is the endpoint that handles reading of the API keys/token used in this
* request
* See: https://freesewing.dev/reference/backend/api/apikey
*/ */
ApikeyController.prototype.whoami = async (req, res, tools) => { ApikeyController.prototype.whoami = async (req, res, tools) => {
const User = new UserModel(tools) const User = new UserModel(tools)

View file

@ -46,6 +46,28 @@ ApikeyModel.prototype.verify = async function (key, secret) {
return this return this
} }
ApikeyModel.prototype.readIfAllowed = 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, 'permissionLackingToLoadOtherApiKey')
}
}
return this.setResponse(200, 'success', {
apikey: {
key: this.record.id,
level: this.record.level,
expiresAt: this.record.expiresAt,
name: this.record.name,
userId: this.record.userId,
},
})
}
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 })
@ -59,9 +81,9 @@ ApikeyModel.prototype.create = async function ({ body, user }) {
if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric') if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric')
if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel') if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel')
if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing') if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing')
if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresInNotNumeric') if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresIsNotNumeric')
if (body.expiresIn > this.config.apikeys.maxExpirySeconds) if (body.expiresIn > this.config.apikeys.maxExpirySeconds)
return this.setResponse(400, 'expiresInHigherThanMaximum') return this.setResponse(400, 'expiresIsHigherThanMaximum')
// Load user making the call // Load user making the call
await this.User.loadAuthenticatedUser(user) await this.User.loadAuthenticatedUser(user)

View file

@ -120,7 +120,7 @@ UserModel.prototype.create = async function ({ body }) {
await this.mailer.send({ await this.mailer.send({
template: 'signup', template: 'signup',
language: this.language, language: this.language,
to: 'joost@decock.org', // this.email, to: this.email,
replacements: { replacements: {
actionUrl: i18nUrl(this.language, `/confirm/signup/${this.Confirmation.record.id}`), actionUrl: i18nUrl(this.language, `/confirm/signup/${this.Confirmation.record.id}`),
whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`), whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`),

View file

@ -35,12 +35,12 @@ export const closingRow = {
</td> </td>
</tr>`, </tr>`,
text: ` text: `
{{ closing }} {{{ closing }}}
{{ greeting }} {{{ greeting }}}
joost joost
PS: {{ text-ps }} : {{{ text-ps-link }}}`, PS: {{{ text-ps }}} : {{{ supportUrl }}}`,
} }
export const headingRow = { export const headingRow = {
@ -70,7 +70,7 @@ export const lead1Row = {
</p> </p>
</td> </td>
</tr>`, </tr>`,
text: `{{ textLead }} text: `{{{ text-lead }}}
{{{ actionUrl }}} {{{ actionUrl }}}
`, `,
} }

View file

@ -21,10 +21,18 @@ export const signup = {
${closingRow.html} ${closingRow.html}
`), `),
text: wrap.text(` text: wrap.text(`
${headingRow.text} {{{ heading }}}
${lead1Row.text}
${buttonRow.text} {{{ textLead }}}
${closingRow.text}
{{{ actionUrl }}}
{{{ closing }}}
{{{ greeting }}},
joost
PS: {{{ text-ps }}} : {{{ supportUrl }}}
`), `),
} }
@ -33,12 +41,14 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account', subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing', heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:', lead: 'To activate your account, click the big black rectangle below:',
textLead: 'To activate your account, click the link below:',
button: 'Activate account', button: 'Activate account',
closing: "That's all for now.", closing: "That's all for now.",
greeting: 'love', greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please', 'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron', 'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.', 'ps-post-link': 'if you cxan afford it.',
'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.en, ...sharedTranslations.en,
}, },
// FIXME: Translate German // FIXME: Translate German
@ -46,12 +56,14 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account', subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing', heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:', lead: 'To activate your account, click the big black rectangle below:',
textLead: 'To activate your account, click the link below:',
button: 'Activate account', button: 'Activate account',
closing: "That's all for now.", closing: "That's all for now.",
greeting: 'love', greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please', 'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron', 'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.', 'ps-post-link': 'if you can afford it.',
'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.de, ...sharedTranslations.de,
}, },
// FIXME: Translate Spanish // FIXME: Translate Spanish
@ -59,12 +71,14 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account', subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing', heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:', lead: 'To activate your account, click the big black rectangle below:',
textLead: 'To activate your account, click the link below:',
button: 'Activate account', button: 'Activate account',
closing: "That's all for now.", closing: "That's all for now.",
greeting: 'love', greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please', 'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron', 'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.', 'ps-post-link': 'if you can afford it.',
'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.es, ...sharedTranslations.es,
}, },
// FIXME: Translate French // FIXME: Translate French
@ -72,24 +86,29 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account', subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing', heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:', lead: 'To activate your account, click the big black rectangle below:',
textLead: 'To activate your account, click the link below:',
button: 'Activate account', button: 'Activate account',
closing: "That's all for now.", closing: "That's all for now.",
greeting: 'love', greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please', 'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron', 'ps-link': 'become a patron',
'ps-post-link': 'if you can afford it.', 'ps-post-link': 'if you can afford it.',
'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.fr, ...sharedTranslations.fr,
}, },
nl: { nl: {
subject: '[FreeSewing] Bevestig je E-mail adres om je account te activeren', subject: '[FreeSewing] Bevestig je E-mail adres om je account te activeren',
heading: 'Welkom bij FreeSewing', heading: 'Welkom bij FreeSewing',
lead: 'Om je account te activeren moet je op de grote zwarte rechthoek hieronder te klikken:', lead: 'Om je account te activeren moet je op de grote zwarte rechthoek hieronder te klikken:',
textLead: 'Om je account te activeren moet je op de link hieronder te klikken:',
button: 'Account activeren', button: 'Account activeren',
closing: 'Daarmee is dat ook weer geregeld.', closing: 'Daarmee is dat ook weer geregeld.',
greeting: 'liefs', greeting: 'liefs',
'ps-pre-link': 'FreeSewing is gratis (echt), maar gelieve', 'ps-pre-link': 'FreeSewing is gratis (echt), maar gelieve',
'ps-link': 'ons werk te ondersteunen', 'ps-link': 'ons werk te ondersteunen',
'ps-post-link': 'als het even kan.', 'ps-post-link': 'als het even kan.',
'text-ps':
'FreeSewing is gratis (echt), maar gelieve ons werk te ondersteunen als het even kan',
...sharedTranslations.nl, ...sharedTranslations.nl,
}, },
} }

View file

@ -300,7 +300,7 @@ describe(`${user} Signup flow and authentication`, () => {
}) })
}) })
step(`${user} Read API Key with api key (whoami)`, (done) => { step(`${user} Read API Key with KEY (whoami)`, (done) => {
chai chai
.request(config.api) .request(config.api)
.get(`/whoami/key`) .get(`/whoami/key`)
@ -315,4 +315,36 @@ describe(`${user} Signup flow and authentication`, () => {
done() done()
}) })
}) })
step(`${user} Read API Key with KEY`, (done) => {
chai
.request(config.api)
.get(`/apikey/${store.apikey.key}/key`)
.auth(store.apikey.key, store.apikey.secret)
.end((err, res) => {
expect(res.status).to.equal(200)
expect(res.type).to.equal('application/json')
expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`success`)
const checks = ['key', 'level', 'expiresAt', 'name', 'userId']
checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i]))
done()
})
})
step(`${user} Read API Key with JWT`, (done) => {
chai
.request(config.api)
.get(`/apikey/${store.apikey.key}/jwt`)
.set('Authorization', 'Bearer ' + store.token)
.end((err, res) => {
expect(res.status).to.equal(200)
expect(res.type).to.equal('application/json')
expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`success`)
const checks = ['key', 'level', 'expiresAt', 'name', 'userId']
checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i]))
done()
})
})
}) })

View file

@ -32,3 +32,5 @@ const mdxCustomComponents = (app = false) => ({
}) })
export default mdxCustomComponents export default mdxCustomComponents
//<span className="bg-secondary px-2 mx-1 rounded text-primary-content bg-opacity-80">{children}</span>