diff --git a/sites/backend/example.env b/sites/backend/example.env index 5e9255f5a69..598fd0648eb 100644 --- a/sites/backend/example.env +++ b/sites/backend/example.env @@ -67,6 +67,14 @@ BACKEND_JWT_ISSUER=freesewing.org BACKEND_JWT_EXPIRY=7d +##################################################################### +# Multi-Factor Authentication (MFA) # +##################################################################### + +# The service for the token generator (think Google Authenticator) +#BACKEND_MFA_SERVICE=FreeSewing + + ##################################################################### # Email (via AWS SES) # # # diff --git a/sites/backend/package.json b/sites/backend/package.json index 10b7d8a2fc9..7750f1aaa38 100644 --- a/sites/backend/package.json +++ b/sites/backend/package.json @@ -29,10 +29,12 @@ "crypto": "^1.0.1", "express": "4.18.2", "mustache": "^4.2.0", + "otplib": "^12.0.1", "passport": "^0.6.0", "passport-http": "^0.3.0", "passport-jwt": "^4.0.0", - "pino": "^8.7.0" + "pino": "^8.7.0", + "qrcode": "^1.5.1" }, "devDependencies": { "chai-http": "^4.3.0", diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 0f579ad0120..2cdd9cd4b34 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -55,6 +55,8 @@ model User { language String @default("en") lastLogin DateTime? lusername String @unique + mfaSecret String @default("") + mfaEnabled Boolean @default(false) newsletter Boolean @default(false) password String patron Int @default(0) diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 27f27dd8fdc..5c862591821 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -58,6 +58,9 @@ const config = { }, languages: ['en', 'de', 'es', 'fr', 'nl'], measies: measurements, + mfa: { + service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing', + }, port, roles: { levels: { diff --git a/sites/backend/src/controllers/users.mjs b/sites/backend/src/controllers/users.mjs index f649619bd5d..b1880f0ef44 100644 --- a/sites/backend/src/controllers/users.mjs +++ b/sites/backend/src/controllers/users.mjs @@ -65,3 +65,16 @@ UsersController.prototype.update = async (req, res, tools) => { return User.sendResponse(res) } + +/* + * Updates the MFA setting of the authenticated user + * + * See: https://freesewing.dev/reference/backend/api + */ +UsersController.prototype.updateMfa = async (req, res, tools) => { + const User = new UserModel(tools) + await User.guardedRead({ id: req.user.uid }, req) + await User.guardedMfaUpdate(req) + + return User.sendResponse(res) +} diff --git a/sites/backend/src/index.mjs b/sites/backend/src/index.mjs index 4c600542937..ceb4a0f0af9 100644 --- a/sites/backend/src/index.mjs +++ b/sites/backend/src/index.mjs @@ -13,6 +13,8 @@ import { verifyConfig } from './config.mjs' import { loadExpressMiddleware, loadPassportMiddleware } from './middleware.mjs' // Encryption import { encryption } from './utils/crypto.mjs' +// Multi-Factor Authentication (MFA) +import { mfa } from './utils/mfa.mjs' // Email import { mailer } from './utils/email.mjs' @@ -28,6 +30,7 @@ const tools = { passport, prisma, ...encryption(config.encryption.key), + ...mfa(config.mfa), ...mailer(config), config, } diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index da3ab4e2aba..988b0894bdd 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -10,9 +10,10 @@ export function UserModel(tools) { this.prisma = tools.prisma this.decrypt = tools.decrypt this.encrypt = tools.encrypt + this.mfa = tools.mfa this.mailer = tools.email this.Confirmation = new ConfirmationModel(tools) - this.encryptedFields = ['bio', 'github', 'email', 'initial', 'img'] + this.encryptedFields = ['bio', 'github', 'email', 'initial', 'img', 'mfaSecret'] this.clear = {} // For holding decrypted data return this @@ -163,6 +164,8 @@ UserModel.prototype.guardedCreate = async function ({ body }) { username, lusername: username, language: body.language, + mfaEnabled: false, + mfaSecret: this.encrypt(''), password: asJson(hashPassword(randomString())), // We'll change this later github: this.encrypt(''), bio: this.encrypt(''), @@ -240,9 +243,16 @@ UserModel.prototype.passwordLogin = async function (req) { return this.setResponse(401, 'loginFailed') } - log.info(`Login by user ${this.record.id} (${this.record.username})`) + // Check for MFA + if (this.record.mfaEnabled) { + if (!req.body.token) return this.setResponse(200, false, { note: 'mfaTokenRequired' }) + else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) { + return this.setResponse(401, 'loginFailed') + } + } // Login success + log.info(`Login by user ${this.record.id} (${this.record.username})`) if (updatedPasswordField) { // Update the password field with a v3 hash await this.unguardedUpdate({ password: updatedPasswordField }) @@ -420,6 +430,59 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { return this.setResponse(200, false, returnData) } +/* + * Enables/Disables MFA on the account - Used when we pass through + * user-provided data so we can't be certain it's safe + */ +UserModel.prototype.guardedMfaUpdate = async function ({ body, user }) { + if (user.level < 4) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + if (body.mfa === true && this.record.mfaEnabled === true) + return this.setResponse(400, 'mfaActive') + + // Disable + if (body.mfa === false) { + } + // Confirm + else if (body.mfa === true && body.token && body.secret) { + if (body.secret === this.clear.mfaSecret && this.mfa.verify(body.token, this.clear.mfaSecret)) { + // Looks good. Enable MFA + try { + await this.unguardedUpdate({ + mfaEnabled: true, + }) + } catch (err) { + log.warn(err, 'Could not enable MFA after token check') + return this.setResponse(500, 'mfaActivationFailed') + } + return this.setResponse(200, false, {}) + } else return this.setResponse(403, 'mfaTokenInvalid') + } + // Enroll + else if (body.mfa === true && this.record.mfaEnabled === false) { + let mfa + try { + mfa = await this.mfa.enroll(this.record.username) + } catch (err) { + log.warn(err, 'Failed to enroll MFA') + } + + // Update mfaSecret + try { + await this.unguardedUpdate({ + mfaSecret: this.encrypt(mfa.secret), + }) + } catch (err) { + log.warn(err, 'Could not update username after user creation') + return this.setResponse(500, 'usernameUpdateAfterUserCreationFailed') + } + + return this.setResponse(200, false, { mfa }) + } + + return this.setResponse(400, 'invalidMfaSetting') +} + /* * Returns account data */ diff --git a/sites/backend/src/routes/users.mjs b/sites/backend/src/routes/users.mjs index 2e2998bdc28..6b962f6f2b9 100644 --- a/sites/backend/src/routes/users.mjs +++ b/sites/backend/src/routes/users.mjs @@ -17,7 +17,6 @@ export function usersRoutes(tools) { app.post('/login', (req, res) => Users.login(req, res, tools)) // Read current jwt - app.get('/whoami/jwt', passport.authenticate(...jwt), (req, res) => Users.whoami(req, res, tools)) app.get('/account/jwt', passport.authenticate(...jwt), (req, res) => Users.whoami(req, res, tools) @@ -34,6 +33,13 @@ export function usersRoutes(tools) { Users.update(req, res, tools) ) + // Enable MFA (totp) + app.post('/account/mfa/jwt', passport.authenticate(...jwt), (req, res) => + Users.updateMfa(req, res, tools) + ) + app.post('/account/mfa/key', passport.authenticate(...bsc), (req, res) => + Users.updateMfa(req, res, tools) + ) /* // Remove account diff --git a/sites/backend/src/utils/mfa.mjs b/sites/backend/src/utils/mfa.mjs new file mode 100644 index 00000000000..f14c7074eae --- /dev/null +++ b/sites/backend/src/utils/mfa.mjs @@ -0,0 +1,30 @@ +import qrcode from 'qrcode' +import { authenticator } from '@otplib/preset-default' + +const dark = '#AAAAAA' +const light = '#EEEEEE' +/* + * Exporting this closure that makes sure we have access to the + * instantiated config + */ +export const mfa = ({ service }) => ({ + mfa: { + enroll: async (user) => { + const secret = authenticator.generateSecret() + const otpauth = authenticator.keyuri(user, service, secret) + let svg + try { + svg = await qrcode.toString(otpauth, { + type: 'svg', + color: { dark, light }, + }) + } catch (err) { + console.log(err) + } + svg = svg.replace(dark, 'currentColor').replace(light, 'none') + + return { secret, otpauth, qrcode: svg } + }, + verify: (token, secret) => authenticator.check(token, secret), + }, +}) diff --git a/sites/backend/tests/index.mjs b/sites/backend/tests/index.mjs index c57833285e8..4fcdefb4cb2 100644 --- a/sites/backend/tests/index.mjs +++ b/sites/backend/tests/index.mjs @@ -1,4 +1,5 @@ import { userTests } from './user.mjs' +import { mfaTests } from './mfa.mjs' import { accountTests } from './account.mjs' import { apikeyTests } from './apikey.mjs' import { personTests } from './person.mjs' @@ -7,6 +8,7 @@ import { setup } from './shared.mjs' const runTests = async (...params) => { await userTests(...params) + await mfaTests(...params) await apikeyTests(...params) await accountTests(...params) await personTests(...params) diff --git a/sites/backend/tests/mfa.mjs b/sites/backend/tests/mfa.mjs new file mode 100644 index 00000000000..390dbe57742 --- /dev/null +++ b/sites/backend/tests/mfa.mjs @@ -0,0 +1,156 @@ +import { authenticator } from '@otplib/preset-default' + +export const mfaTests = async (chai, config, expect, store) => { + const secret = { + jwt: store.account, + key: store.altaccount, + } + + for (const auth in secret) { + describe(`${store.icon('mfa', auth)} Setup Multi-Factor Authentication (MFA) (${auth})`, () => { + it(`${store.icon('mfa')} Should return 400 on MFA enable without proper value`, (done) => { + chai + .request(config.api) + .post(`/account/mfa/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + secret[auth].token + : 'Basic ' + + new Buffer(`${secret[auth].apikey.key}:${secret[auth].apikey.secret}`).toString( + 'base64' + ) + ) + .send({ mfa: 'yes' }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(400) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`invalidMfaSetting`) + done() + }) + }) + + it(`${store.icon('mfa', auth)} Should return MFA secret and QR code`, (done) => { + chai + .request(config.api) + .post(`/account/mfa/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + secret[auth].token + : 'Basic ' + + new Buffer(`${secret[auth].apikey.key}:${secret[auth].apikey.secret}`).toString( + 'base64' + ) + ) + .send({ mfa: true }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.mfa.secret).to.equal(`string`) + expect(typeof res.body.mfa.otpauth).to.equal(`string`) + expect(typeof res.body.mfa.qrcode).to.equal(`string`) + secret[auth].mfaSecret = res.body.mfa.secret + done() + }) + }) + + it(`${store.icon('mfa', auth)} Should enable MFA after validating the token`, (done) => { + chai + .request(config.api) + .post(`/account/mfa/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + secret[auth].token + : 'Basic ' + + new Buffer(`${secret[auth].apikey.key}:${secret[auth].apikey.secret}`).toString( + 'base64' + ) + ) + .send({ + mfa: true, + secret: secret[auth].mfaSecret, + token: authenticator.generate(secret[auth].mfaSecret), + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + done() + }) + }) + + it(`${store.icon('mfa', auth)} Should not request MFA when it's already active`, (done) => { + chai + .request(config.api) + .post(`/account/mfa/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + secret[auth].token + : 'Basic ' + + new Buffer(`${secret[auth].apikey.key}:${secret[auth].apikey.secret}`).toString( + 'base64' + ) + ) + .send({ mfa: true }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(400) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`mfaActive`) + done() + }) + }) + + it(`${store.icon('mfa', auth)} Should not enable MFA when it's already active`, (done) => { + chai + .request(config.api) + .post(`/account/mfa/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + secret[auth].token + : 'Basic ' + + new Buffer(`${secret[auth].apikey.key}:${secret[auth].apikey.secret}`).toString( + 'base64' + ) + ) + .send({ + mfa: true, + secret: secret[auth].mfaSecret, + token: authenticator.generate(secret[auth].mfaSecret), + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(400) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`mfaActive`) + done() + }) + }) + }) + } + + describe(`${store.icon('mfa')} Multi-Factor Authentication (MFA) login flow`, () => { + it(`${store.icon('mfa')} Should not login with username/password only`, (done) => { + chai + .request(config.api) + .post('/login') + .send({ + username: store.account.username, + password: store.account.password, + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal('success') + expect(res.body.note).to.equal('mfaTokenRequired') + done() + }) + }) + }) +} diff --git a/sites/backend/tests/shared.mjs b/sites/backend/tests/shared.mjs index ae9cd0f093c..9a6b1652a75 100644 --- a/sites/backend/tests/shared.mjs +++ b/sites/backend/tests/shared.mjs @@ -36,6 +36,7 @@ export const setup = async () => { }, icons: { user: '🧑 ', + mfa: '🔒 ', jwt: '🎫 ', key: '🎟️ ', person: '🧕 ', diff --git a/yarn.lock b/yarn.lock index 78663794e0e..8b6e609a928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4066,6 +4066,44 @@ dependencies: "@octokit/openapi-types" "^13.11.0" +"@otplib/core@^12.0.1": + version "12.0.1" + resolved "https://registry.yarnpkg.com/@otplib/core/-/core-12.0.1.tgz#73720a8cedce211fe5b3f683cd5a9c098eaf0f8d" + integrity sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA== + +"@otplib/plugin-crypto@^12.0.1": + version "12.0.1" + resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz#2b42c624227f4f9303c1c041fca399eddcbae25e" + integrity sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g== + dependencies: + "@otplib/core" "^12.0.1" + +"@otplib/plugin-thirty-two@^12.0.1": + version "12.0.1" + resolved "https://registry.yarnpkg.com/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz#5cc9b56e6e89f2a1fe4a2b38900ca4e11c87aa9e" + integrity sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA== + dependencies: + "@otplib/core" "^12.0.1" + thirty-two "^1.0.2" + +"@otplib/preset-default@^12.0.1": + version "12.0.1" + resolved "https://registry.yarnpkg.com/@otplib/preset-default/-/preset-default-12.0.1.tgz#cb596553c08251e71b187ada4a2246ad2a3165ba" + integrity sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ== + dependencies: + "@otplib/core" "^12.0.1" + "@otplib/plugin-crypto" "^12.0.1" + "@otplib/plugin-thirty-two" "^12.0.1" + +"@otplib/preset-v11@^12.0.1": + version "12.0.1" + resolved "https://registry.yarnpkg.com/@otplib/preset-v11/-/preset-v11-12.0.1.tgz#4c7266712e7230500b421ba89252963c838fc96d" + integrity sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg== + dependencies: + "@otplib/core" "^12.0.1" + "@otplib/plugin-crypto" "^12.0.1" + "@otplib/plugin-thirty-two" "^12.0.1" + "@parcel/watcher@2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b" @@ -4138,10 +4176,10 @@ resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.5.0-43.0362da9eebca54d94c8ef5edd3b2e90af99ba452.tgz#5b7fae294ee9bd9790d0e7b7a0b0912e4222ac08" integrity sha512-o7LyVx8PPJBLrEzLl6lpxxk2D5VnlM4Fwmrbq0NoT6pr5aa1OuHD9ZG+WJY6TlR/iD9bhmo2LNcxddCMr5Rv2A== -"@prisma/engines@4.6.1": - version "4.6.1" - resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.6.1.tgz#ae31309cc0f600f2da22708697b3be4eb1e46f9e" - integrity sha512-3u2/XxvxB+Q7cMXHnKU0CpBiUK1QWqpgiBv28YDo1zOIJE3FCF8DI2vrp6vuwjGt5h0JGXDSvmSf4D4maVjJdw== +"@prisma/engines@4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.5.0.tgz#82df347a893a5ae2a67707d44772ba181f4b9328" + integrity sha512-4t9ir2SbQQr/wMCNU4YpHWp5hU14J2m3wHUZnGJPpmBF8YtkisxyVyQsKd1e6FyLTaGq8LOLhm6VLYHKqKNm+g== "@reach/auto-id@^0.13.2": version "0.13.2" @@ -9838,6 +9876,11 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" +dijkstrajs@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" + integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg== + dir-glob@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4" @@ -10221,6 +10264,11 @@ enabled@2.0.x: resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -18846,6 +18894,15 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== +otplib@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/otplib/-/otplib-12.0.1.tgz#c1d3060ab7aadf041ed2960302f27095777d1f73" + integrity sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg== + dependencies: + "@otplib/core" "^12.0.1" + "@otplib/preset-default" "^12.0.1" + "@otplib/preset-v11" "^12.0.1" + ow@^0.21.0: version "0.21.0" resolved "https://registry.yarnpkg.com/ow/-/ow-0.21.0.tgz#c2df2ad78d1bfc2ea9cdca311b7a6275258df621" @@ -19775,6 +19832,11 @@ png-js@^1.0.0: resolved "https://registry.yarnpkg.com/png-js/-/png-js-1.0.0.tgz#e5484f1e8156996e383aceebb3789fd75df1874d" integrity sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g== +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + polished@^4.0.5: version "4.2.2" resolved "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz#2529bb7c3198945373c52e34618c8fe7b1aa84d1" @@ -20858,12 +20920,12 @@ prettyjson@^1.2.1: colors "1.4.0" minimist "^1.2.0" -prisma@4.6.1: - version "4.6.1" - resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.6.1.tgz#6c85fb667abed006a6b849c9c1ddd81d3f071b87" - integrity sha512-BR4itMCuzrDV4tn3e2TF+nh1zIX/RVU0isKtKoN28ADeoJ9nYaMhiuRRkFd2TZN8+l/XfYzoRKyHzUFXLQhmBQ== +prisma@4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.5.0.tgz#361ae3f4476d0821b97645e5da42975a7c2943bb" + integrity sha512-9Aeg4qiKlv9Wsjz4NO8k2CzRzlvS3A4FYVJ5+28sBBZ0eEwbiVOE/Jj7v6rZC1tFW2s4GSICQOAyuOjc6WsNew== dependencies: - "@prisma/engines" "4.6.1" + "@prisma/engines" "4.5.0" prismjs@~1.27.0: version "1.27.0" @@ -21105,6 +21167,16 @@ q@^1.1.2, q@^1.5.1: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw== +qrcode@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.1.tgz#0103f97317409f7bc91772ef30793a54cd59f0cb" + integrity sha512-nS8NJ1Z3md8uTjKtP+SGGhfqmTCs5flU/xR623oI0JX+Wepz9R8UrRVCTBTJm3qGw3rH6jJ6MUHjkDx15cxSSg== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@6.11.0, qs@^6.5.1, qs@^6.9.6: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" @@ -24720,6 +24792,11 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thirty-two@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a" + integrity sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA== + thread-stream@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.2.0.tgz#310c03a253f729094ce5d4638ef5186dfa80a9e8" @@ -26677,7 +26754,7 @@ yargs@17.1.1: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^15.0.1, yargs@^15.0.2: +yargs@^15.0.1, yargs@^15.0.2, yargs@^15.3.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==