diff --git a/markdown/dev/reference/backend/api/apikey/en.md b/markdown/dev/reference/backend/api/apikey/en.md index d34d9f1325c..7d1774fad91 100644 --- a/markdown/dev/reference/backend/api/apikey/en.md +++ b/markdown/dev/reference/backend/api/apikey/en.md @@ -12,38 +12,39 @@ 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. + +The response to this API call is the only time the secret will be +revealed. + + ### 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 | +| Method | Path | Description | Auth | +| ------ | ---- | ----------- | ---- | +| | `/apikey/jwt` | Create a new API key | _jwt_ | +| | `/apikey/key` | Create a new API key | _key_ | ### 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. | +| Where | Variable | Type | Description | +| ----- | ------------- | -------- | ----------- | +| _body_ | `name` | `string` | Create a new API key. Endpoint for JWT authentication | +| _body_ | `level` | `number` | A privilege level from 0 to 8. | +| _body_ | `expiresIn` | `number` | body | 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. +Returns HTTP status code on success, if +the request is malformed, and on server error. | 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.secret` | `string` | The API secret | | `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 ```js -const token = axios.post( +const apiKey = axios.post( 'https://backend.freesewing.org/apikey/jwt', { name: 'My first API key', @@ -90,6 +91,185 @@ const token = axios.post( +## 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. + + +You need the `admin` role to read API keys of other users + +### Endpoints + +| Method | Path | Description | Auth | +| ------ | ---- | ----------- | ---- | +| | `/apikey/:id/jwt` | Reads an API key | _jwt_ | +| | `/apikey/:id/key` | Reads an API key | _key_ | + +### Parameters + + +| Where | Variable | Type | Description | +| ----- | ----------- | -------- | ----------- | +| _url_ | `:id` | `string` | The `key` field of the API key | + + +Returns HTTP status code on success, if +the request is malformed, if the key is not found, +and 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 | + + + +### Example + + +```js +const keyInfo = axios.get( + 'https://backend.freesewing.org/apikey/7ea12968-7758-40b6-8c73-75cc99be762b/jwt', + { + headers: { + Authorization: `Bearer ${token}` + } + } +) +``` + + + +```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 + } +} +``` + + + +## Read the current API key + +Reads the API key with which the current request was authenticated. + +### Endpoints + +| Method | Path | Description | Auth | +| ------ | ---- | ----------- | ---- | +| | `/whoami/key` | Reads the current API key | _key_ | + +### Parameters + + +This endpoint takes no parameters + + +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 | + + + +### Example + + +```js +const keyInfo = axios.get( + 'https://backend.freesewing.org/whoami/key', + { + auth: { + username: apikey.key, + password: apikey.secret, + } + } +) +``` + + + +```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 + } +} +``` + + + + +## Remove an API key + +Removes an existing API key. + + +You need the `admin` role to remove API keys of other users + + +### Endpoints + +| Method | Path | Description | Auth | +| ------ | ---- | ----------- | ---- | +| | `/apikey/:id/jwt` | Removes an API key | _jwt_ | +| | `/apikey/:id/key` | Removes an API key | _key_ | + +### Parameters + + +| Where | Variable | Type | Description | +| ----- | ----------- | -------- | ----------- | +| _url_ | `:id` | `string` | The `key` field of the API key | + + +Returns HTTP status code on success, if +the request is malformed, if the key is not found, +and on server error. + + +### Example + + +```js +const keyInfo = axios.get( + 'https://backend.freesewing.org/apikey/7ea12968-7758-40b6-8c73-75cc99be762b/jwt', + { + headers: { + Authorization: `Bearer ${token}` + } + } +) +``` + + +Status code (no content) does not come with a body + + + ## Notes The following is good to keep in mind when working with API keys: diff --git a/markdown/dev/reference/backend/api/en.md b/markdown/dev/reference/backend/api/en.md index 0ac668ad7db..a82a56d56aa 100644 --- a/markdown/dev/reference/backend/api/en.md +++ b/markdown/dev/reference/backend/api/en.md @@ -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. @@ -19,30 +20,35 @@ automation tasks such as creating issues on Github. ## 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. -- 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. +- **API Keys** (key): This is typically used to interact with the API in an + 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 -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: +For each endpoint, the API has a variant 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*. +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. +To get a token, you must first authenticate at the `/login` endpoint with +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 const account = await axios.get( @@ -57,11 +63,18 @@ const account = await axios.get( ### 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. +The combination of API key & secret serves as a username & password for [HTTP +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. -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://`). + + +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: @@ -96,8 +109,8 @@ The table below lists the priviledge of all levels as well as their correspondin | `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 | ❌ | ❌ | ❌ | ✅ | +| `7` | **write** account data of **other users** through **specific support methods** | ❌ | ❌ | ✅ | ✅ | +| `8` | impersonate other users, **full write access** | ❌ | ❌ | ❌ | ✅ | ## API Routes diff --git a/markdown/dev/reference/backend/en.md b/markdown/dev/reference/backend/en.md index 6052a34e0dd..c2972b0084e 100644 --- a/markdown/dev/reference/backend/en.md +++ b/markdown/dev/reference/backend/en.md @@ -2,4 +2,57 @@ 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. diff --git a/sites/backend/src/controllers/apikey.mjs b/sites/backend/src/controllers/apikey.mjs index 7f593d86404..9a1bf84522f 100644 --- a/sites/backend/src/controllers/apikey.mjs +++ b/sites/backend/src/controllers/apikey.mjs @@ -62,3 +62,16 @@ ApikeyController.prototype.whoami = async (req, res, tools) => { 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) +} diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs index 2f4e6a51e9a..c537be58e41 100644 --- a/sites/backend/src/models/apikey.mjs +++ b/sites/backend/src/models/apikey.mjs @@ -25,7 +25,8 @@ ApikeyModel.prototype.setResponse = function (status = 200, error = false, 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.result = 'error' 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) { this.record = await this.prisma.apikey.findUnique({ where }) 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 }) { if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') if (!body.name) return this.setResponse(400, 'nameMissing') @@ -101,7 +124,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) { name: body.name, level: body.level, secret: asJson(hashPassword(secret)), - userId: user._id, + userId: user._id || user.userId, }, }) } catch (err) { @@ -109,7 +132,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) { return this.setResponse(500, 'createApikeyFailed') } - return this.setResponse(200, 'success', { + return this.setResponse(201, 'created', { apikey: { key: this.record.id, secret, diff --git a/sites/backend/src/routes/apikey.mjs b/sites/backend/src/routes/apikey.mjs index 582169dfb06..b22eeb02643 100644 --- a/sites/backend/src/routes/apikey.mjs +++ b/sites/backend/src/routes/apikey.mjs @@ -27,4 +27,12 @@ export function apikeyRoutes(tools) { app.get('/whoami/key', passport.authenticate(...bsc), (req, res) => 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) + ) } diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs index d5aab549301..f476141bdaa 100644 --- a/sites/backend/tests/user.test.mjs +++ b/sites/backend/tests/user.test.mjs @@ -287,10 +287,10 @@ describe(`${user} Signup flow and authentication`, () => { expiresIn: 60, }) .end((err, res) => { - expect(res.status).to.equal(200) + 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(`success`) + 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') @@ -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) => { chai .request(config.api) @@ -320,7 +344,7 @@ describe(`${user} Signup flow and authentication`, () => { chai .request(config.api) .get(`/apikey/${store.apikey.key}/key`) - .auth(store.apikey.key, store.apikey.secret) + .auth(store.apikeykey.key, store.apikeykey.secret) .end((err, res) => { expect(res.status).to.equal(200) 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) => { chai .request(config.api) - .get(`/apikey/${store.apikey.key}/jwt`) + .get(`/apikey/${store.apikeykey.key}/jwt`) .set('Authorization', 'Bearer ' + store.token) .end((err, res) => { 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.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])) + 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() }) }) diff --git a/sites/shared/components/mdx/index.js b/sites/shared/components/mdx/index.js index 9c4d3d97650..7ad7b6fd10b 100644 --- a/sites/shared/components/mdx/index.js +++ b/sites/shared/components/mdx/index.js @@ -7,8 +7,52 @@ import { Tab, Tabs } from './tabs.js' import Example from './example.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 ( +
+ {method} +
+ ) +} + +const statusClasses = { + 2: 'bg-green-600 text-white', + 4: 'bg-orange-500 text-white', + 5: 'bg-red-600 text-white', +} + +const StatusCode = ({ status }) => { + return ( +
+ {status} +
+ ) +} + const mdxCustomComponents = (app = false) => ({ // Custom components + Method, + StatusCode, Comment: (props) => , Fixme: (props) => , Link: (props) => , diff --git a/sites/shared/config/tailwind-force.html b/sites/shared/config/tailwind-force.html index 173f12b0728..b8834598b90 100644 --- a/sites/shared/config/tailwind-force.html +++ b/sites/shared/config/tailwind-force.html @@ -18,3 +18,4 @@ +