diff --git a/markdown/dev/reference/backend/api/apikey/en.md b/markdown/dev/reference/backend/api/apikey/en.md new file mode 100644 index 00000000000..d34d9f1325c --- /dev/null +++ b/markdown/dev/reference/backend/api/apikey/en.md @@ -0,0 +1,134 @@ +--- +title: API Keys +--- + +Documentation for the REST API endpoints to create, read, or remove API keys. + + +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). + + + + + + +## 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 + + +| 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. | + + +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 | + + + +### Example + + +```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}` + } + } +) +``` + + + +```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 + } +} +``` + + + +## 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 + + +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. + + + diff --git a/markdown/dev/reference/backend/api/en.md b/markdown/dev/reference/backend/api/en.md new file mode 100644 index 00000000000..0ac668ad7db --- /dev/null +++ b/markdown/dev/reference/backend/api/en.md @@ -0,0 +1,104 @@ +--- +title: REST API +--- + +This is the reference documentation for the FreeSewing backend REST API. + + +This documentation is under construction as we are re-working this API for v3. + + +## 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 `role`: + +| Level | Abilities | `user` | `bughunter` | `support` | `admin` | +| --: | -- | :--: | :--: | :--: | :--: | +| `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 + + diff --git a/markdown/dev/reference/backend/en.md b/markdown/dev/reference/backend/en.md new file mode 100644 index 00000000000..6052a34e0dd --- /dev/null +++ b/markdown/dev/reference/backend/en.md @@ -0,0 +1,5 @@ +--- +title: FreeSewing backend +--- + +FIXME: Explain what this is about diff --git a/sites/backend/src/controllers/apikey.mjs b/sites/backend/src/controllers/apikey.mjs index 56685baf6d6..7f593d86404 100644 --- a/sites/backend/src/controllers/apikey.mjs +++ b/sites/backend/src/controllers/apikey.mjs @@ -10,7 +10,7 @@ export function ApikeyController() {} * Create API key * * 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) => { const Apikey = new ApikeyModel(tools) @@ -22,8 +22,22 @@ ApikeyController.prototype.create = async (req, res, tools) => { /* * Read API key * - * This is the endpoint that handles reading of API keys/tokens - * See: https://freesewing.dev/reference/backend/api + * This is the endpoint that handles creation of API keys/tokens + * 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) => { const User = new UserModel(tools) diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs index 56ba475a545..2f4e6a51e9a 100644 --- a/sites/backend/src/models/apikey.mjs +++ b/sites/backend/src/models/apikey.mjs @@ -46,6 +46,28 @@ ApikeyModel.prototype.verify = async function (key, secret) { 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) { 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 (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel') 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) - return this.setResponse(400, 'expiresInHigherThanMaximum') + return this.setResponse(400, 'expiresIsHigherThanMaximum') // Load user making the call await this.User.loadAuthenticatedUser(user) diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 4fd6f9e71aa..a9791bab7f4 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -120,7 +120,7 @@ UserModel.prototype.create = async function ({ body }) { await this.mailer.send({ template: 'signup', language: this.language, - to: 'joost@decock.org', // this.email, + to: this.email, replacements: { actionUrl: i18nUrl(this.language, `/confirm/signup/${this.Confirmation.record.id}`), whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`), diff --git a/sites/backend/src/templates/email/blocks.mjs b/sites/backend/src/templates/email/blocks.mjs index e267efb94e9..54a49df4341 100644 --- a/sites/backend/src/templates/email/blocks.mjs +++ b/sites/backend/src/templates/email/blocks.mjs @@ -35,12 +35,12 @@ export const closingRow = { `, text: ` -{{ closing }} +{{{ closing }}} -{{ greeting }} +{{{ greeting }}} joost -PS: {{ text-ps }} : {{{ text-ps-link }}}`, +PS: {{{ text-ps }}} : {{{ supportUrl }}}`, } export const headingRow = { @@ -70,7 +70,7 @@ export const lead1Row = {

`, - text: `{{ textLead }} + text: `{{{ text-lead }}} {{{ actionUrl }}} `, } diff --git a/sites/backend/src/templates/email/signup.mjs b/sites/backend/src/templates/email/signup.mjs index c98baefd197..d6926680e35 100644 --- a/sites/backend/src/templates/email/signup.mjs +++ b/sites/backend/src/templates/email/signup.mjs @@ -21,10 +21,18 @@ export const signup = { ${closingRow.html} `), text: wrap.text(` - ${headingRow.text} - ${lead1Row.text} - ${buttonRow.text} - ${closingRow.text} +{{{ heading }}} + +{{{ textLead }}} + +{{{ 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', heading: 'Welcome to FreeSewing', lead: 'To activate your account, click the big black rectangle below:', + textLead: 'To activate your account, click the link below:', button: 'Activate account', closing: "That's all for now.", greeting: 'love', 'ps-pre-link': 'FreeSewing is free (duh), but please', 'ps-link': 'become a patron', '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, }, // FIXME: Translate German @@ -46,12 +56,14 @@ export const translations = { subject: '[FreeSewing] Confirm your E-mail address to activate your account', heading: 'Welcome to FreeSewing', lead: 'To activate your account, click the big black rectangle below:', + textLead: 'To activate your account, click the link below:', button: 'Activate account', closing: "That's all for now.", greeting: 'love', 'ps-pre-link': 'FreeSewing is free (duh), but please', '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, }, // FIXME: Translate Spanish @@ -59,12 +71,14 @@ export const translations = { subject: '[FreeSewing] Confirm your E-mail address to activate your account', heading: 'Welcome to FreeSewing', lead: 'To activate your account, click the big black rectangle below:', + textLead: 'To activate your account, click the link below:', button: 'Activate account', closing: "That's all for now.", greeting: 'love', 'ps-pre-link': 'FreeSewing is free (duh), but please', '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, }, // FIXME: Translate French @@ -72,24 +86,29 @@ export const translations = { subject: '[FreeSewing] Confirm your E-mail address to activate your account', heading: 'Welcome to FreeSewing', lead: 'To activate your account, click the big black rectangle below:', + textLead: 'To activate your account, click the link below:', button: 'Activate account', closing: "That's all for now.", greeting: 'love', 'ps-pre-link': 'FreeSewing is free (duh), but please', 'ps-link': 'become a patron', '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, }, nl: { subject: '[FreeSewing] Bevestig je E-mail adres om je account te activeren', heading: 'Welkom bij FreeSewing', 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', closing: 'Daarmee is dat ook weer geregeld.', greeting: 'liefs', 'ps-pre-link': 'FreeSewing is gratis (echt), maar gelieve', 'ps-link': 'ons werk te ondersteunen', '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, }, } diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs index 7283d28cd1a..d5aab549301 100644 --- a/sites/backend/tests/user.test.mjs +++ b/sites/backend/tests/user.test.mjs @@ -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 .request(config.api) .get(`/whoami/key`) @@ -315,4 +315,36 @@ describe(`${user} Signup flow and authentication`, () => { 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() + }) + }) }) diff --git a/sites/shared/components/mdx/index.js b/sites/shared/components/mdx/index.js index 49d37ea3089..9c4d3d97650 100644 --- a/sites/shared/components/mdx/index.js +++ b/sites/shared/components/mdx/index.js @@ -32,3 +32,5 @@ const mdxCustomComponents = (app = false) => ({ }) export default mdxCustomComponents + +//{children}