1
0
Fork 0

wip(backend): Added MFA support

This commit is contained in:
Joost De Cock 2022-11-17 20:41:21 +01:00
parent 36726dbdc3
commit a5ee0a0854
13 changed files with 380 additions and 14 deletions

View file

@ -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) #
# #

View file

@ -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",

View file

@ -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)

View file

@ -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: {

View file

@ -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)
}

View file

@ -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,
}

View file

@ -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
*/

View file

@ -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

View file

@ -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),
},
})

View file

@ -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)

156
sites/backend/tests/mfa.mjs Normal file
View file

@ -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()
})
})
})
}

View file

@ -36,6 +36,7 @@ export const setup = async () => {
},
icons: {
user: '🧑 ',
mfa: '🔒 ',
jwt: '🎫 ',
key: '🎟️ ',
person: '🧕 ',

View file

@ -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==