From 56f47830758ff8e92abcfcc5f35d474ab12fe215 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 5 Oct 2022 15:37:15 +0200 Subject: [PATCH] Support two factor authentication in backend --- package.json | 1 + server/controllers/api/users/index.ts | 2 + server/controllers/api/users/token.ts | 7 +- server/controllers/api/users/two-factor.ts | 91 ++++++ server/helpers/otp.ts | 54 ++++ server/initializers/constants.ts | 10 +- .../initializers/migrations/0745-user-otp.ts | 29 ++ server/lib/auth/oauth.ts | 27 +- server/lib/redis.ts | 25 +- server/middlewares/validators/shared/index.ts | 1 + server/middlewares/validators/shared/users.ts | 62 ++++ server/middlewares/validators/two-factor.ts | 81 ++++++ server/middlewares/validators/users.ts | 87 ++---- server/models/user/user.ts | 9 +- server/tests/api/check-params/index.ts | 5 +- server/tests/api/check-params/two-factor.ts | 275 ++++++++++++++++++ server/tests/api/users/index.ts | 1 + server/tests/api/users/two-factor.ts | 153 ++++++++++ shared/models/users/index.ts | 1 + .../users/two-factor-enable-result.model.ts | 7 + shared/models/users/user.model.ts | 2 + shared/server-commands/server/server.ts | 12 +- shared/server-commands/users/index.ts | 1 + shared/server-commands/users/login-command.ts | 73 +++-- .../users/two-factor-command.ts | 75 +++++ shared/server-commands/users/users-command.ts | 3 +- yarn.lock | 12 + 27 files changed, 1015 insertions(+), 91 deletions(-) create mode 100644 server/controllers/api/users/two-factor.ts create mode 100644 server/helpers/otp.ts create mode 100644 server/initializers/migrations/0745-user-otp.ts create mode 100644 server/middlewares/validators/shared/users.ts create mode 100644 server/middlewares/validators/two-factor.ts create mode 100644 server/tests/api/check-params/two-factor.ts create mode 100644 server/tests/api/users/two-factor.ts create mode 100644 shared/models/users/two-factor-enable-result.model.ts create mode 100644 shared/server-commands/users/two-factor-command.ts diff --git a/package.json b/package.json index dd913896d..6dcf26253 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "node-media-server": "^2.1.4", "nodemailer": "^6.0.0", "opentelemetry-instrumentation-sequelize": "^0.29.0", + "otpauth": "^8.0.3", "p-queue": "^6", "parse-torrent": "^9.1.0", "password-generator": "^2.0.2", diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 07b9ae395..a8677a1d3 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -51,6 +51,7 @@ import { myVideosHistoryRouter } from './my-history' import { myNotificationsRouter } from './my-notifications' import { mySubscriptionsRouter } from './my-subscriptions' import { myVideoPlaylistsRouter } from './my-video-playlists' +import { twoFactorRouter } from './two-factor' const auditLogger = auditLoggerFactory('users') @@ -66,6 +67,7 @@ const askSendEmailLimiter = buildRateLimiter({ }) const usersRouter = express.Router() +usersRouter.use('/', twoFactorRouter) usersRouter.use('/', tokensRouter) usersRouter.use('/', myNotificationsRouter) usersRouter.use('/', mySubscriptionsRouter) diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts index 012a49791..c6afea67c 100644 --- a/server/controllers/api/users/token.ts +++ b/server/controllers/api/users/token.ts @@ -1,8 +1,9 @@ import express from 'express' import { logger } from '@server/helpers/logger' import { CONFIG } from '@server/initializers/config' +import { OTP } from '@server/initializers/constants' import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' -import { handleOAuthToken } from '@server/lib/auth/oauth' +import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth' import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' import { Hooks } from '@server/lib/plugins/hooks' import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares' @@ -79,6 +80,10 @@ async function handleToken (req: express.Request, res: express.Response, next: e } catch (err) { logger.warn('Login error', { err }) + if (err instanceof MissingTwoFactorError) { + res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE) + } + return res.fail({ status: err.code, message: err.message, diff --git a/server/controllers/api/users/two-factor.ts b/server/controllers/api/users/two-factor.ts new file mode 100644 index 000000000..1725294e7 --- /dev/null +++ b/server/controllers/api/users/two-factor.ts @@ -0,0 +1,91 @@ +import express from 'express' +import { generateOTPSecret, isOTPValid } from '@server/helpers/otp' +import { Redis } from '@server/lib/redis' +import { asyncMiddleware, authenticate, usersCheckCurrentPassword } from '@server/middlewares' +import { + confirmTwoFactorValidator, + disableTwoFactorValidator, + requestOrConfirmTwoFactorValidator +} from '@server/middlewares/validators/two-factor' +import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models' + +const twoFactorRouter = express.Router() + +twoFactorRouter.post('/:id/two-factor/request', + authenticate, + asyncMiddleware(usersCheckCurrentPassword), + asyncMiddleware(requestOrConfirmTwoFactorValidator), + asyncMiddleware(requestTwoFactor) +) + +twoFactorRouter.post('/:id/two-factor/confirm-request', + authenticate, + asyncMiddleware(requestOrConfirmTwoFactorValidator), + confirmTwoFactorValidator, + asyncMiddleware(confirmRequestTwoFactor) +) + +twoFactorRouter.post('/:id/two-factor/disable', + authenticate, + asyncMiddleware(usersCheckCurrentPassword), + asyncMiddleware(disableTwoFactorValidator), + asyncMiddleware(disableTwoFactor) +) + +// --------------------------------------------------------------------------- + +export { + twoFactorRouter +} + +// --------------------------------------------------------------------------- + +async function requestTwoFactor (req: express.Request, res: express.Response) { + const user = res.locals.user + + const { secret, uri } = generateOTPSecret(user.email) + const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, secret) + + return res.json({ + otpRequest: { + requestToken, + secret, + uri + } + } as TwoFactorEnableResult) +} + +async function confirmRequestTwoFactor (req: express.Request, res: express.Response) { + const requestToken = req.body.requestToken + const otpToken = req.body.otpToken + const user = res.locals.user + + const secret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken) + if (!secret) { + return res.fail({ + message: 'Invalid request token', + status: HttpStatusCode.FORBIDDEN_403 + }) + } + + if (isOTPValid({ secret, token: otpToken }) !== true) { + return res.fail({ + message: 'Invalid OTP token', + status: HttpStatusCode.FORBIDDEN_403 + }) + } + + user.otpSecret = secret + await user.save() + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function disableTwoFactor (req: express.Request, res: express.Response) { + const user = res.locals.user + + user.otpSecret = null + await user.save() + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/helpers/otp.ts b/server/helpers/otp.ts new file mode 100644 index 000000000..a13edc5e2 --- /dev/null +++ b/server/helpers/otp.ts @@ -0,0 +1,54 @@ +import { Secret, TOTP } from 'otpauth' +import { WEBSERVER } from '@server/initializers/constants' + +function isOTPValid (options: { + secret: string + token: string +}) { + const { token, secret } = options + + const totp = new TOTP({ + ...baseOTPOptions(), + + secret + }) + + const delta = totp.validate({ + token, + window: 1 + }) + + if (delta === null) return false + + return true +} + +function generateOTPSecret (email: string) { + const totp = new TOTP({ + ...baseOTPOptions(), + + label: email, + secret: new Secret() + }) + + return { + secret: totp.secret.base32, + uri: totp.toString() + } +} + +export { + isOTPValid, + generateOTPSecret +} + +// --------------------------------------------------------------------------- + +function baseOTPOptions () { + return { + issuer: WEBSERVER.HOST, + algorithm: 'SHA1', + digits: 6, + period: 30 + } +} diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 9257ebf93..9d6087867 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -25,7 +25,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 740 +const LAST_MIGRATION_VERSION = 745 // --------------------------------------------------------------------------- @@ -640,6 +640,8 @@ const BCRYPT_SALT_SIZE = 10 const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days +const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes + const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { @@ -805,6 +807,10 @@ const REDUNDANCY = { } const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) +const OTP = { + HEADER_NAME: 'x-peertube-otp', + HEADER_REQUIRED_VALUE: 'required; app' +} const ASSETS_PATH = { DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'), @@ -986,6 +992,7 @@ export { FOLLOW_STATES, DEFAULT_USER_THEME_NAME, SERVER_ACTOR_NAME, + TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, PLUGIN_GLOBAL_CSS_FILE_NAME, PLUGIN_GLOBAL_CSS_PATH, PRIVATE_RSA_KEY_SIZE, @@ -1041,6 +1048,7 @@ export { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME, ASSETS_PATH, FILES_CONTENT_HASH, + OTP, loadLanguages, buildLanguages, generateContentHash diff --git a/server/initializers/migrations/0745-user-otp.ts b/server/initializers/migrations/0745-user-otp.ts new file mode 100644 index 000000000..157308ea1 --- /dev/null +++ b/server/initializers/migrations/0745-user-otp.ts @@ -0,0 +1,29 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + const { transaction } = utils + + const data = { + type: Sequelize.STRING, + defaultValue: null, + allowNull: true + } + await utils.queryInterface.addColumn('user', 'otpSecret', data, { transaction }) + +} + +async function down (utils: { + queryInterface: Sequelize.QueryInterface + transaction: Sequelize.Transaction +}) { +} + +export { + up, + down +} diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts index fa1887315..b541142a5 100644 --- a/server/lib/auth/oauth.ts +++ b/server/lib/auth/oauth.ts @@ -11,8 +11,20 @@ import OAuth2Server, { import { randomBytesPromise } from '@server/helpers/core-utils' import { MOAuthClient } from '@server/types/models' import { sha1 } from '@shared/extra-utils' -import { OAUTH_LIFETIME } from '../../initializers/constants' +import { HttpStatusCode } from '@shared/models' +import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' +import { isOTPValid } from '@server/helpers/otp' + +class MissingTwoFactorError extends Error { + code = HttpStatusCode.UNAUTHORIZED_401 + name = 'missing_two_factor' +} + +class InvalidTwoFactorError extends Error { + code = HttpStatusCode.BAD_REQUEST_400 + name = 'invalid_two_factor' +} /** * @@ -94,6 +106,9 @@ function handleOAuthAuthenticate ( } export { + MissingTwoFactorError, + InvalidTwoFactorError, + handleOAuthToken, handleOAuthAuthenticate } @@ -118,6 +133,16 @@ async function handlePasswordGrant (options: { const user = await getUser(request.body.username, request.body.password, bypassLogin) if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid') + if (user.otpSecret) { + if (!request.headers[OTP.HEADER_NAME]) { + throw new MissingTwoFactorError('Missing two factor header') + } + + if (isOTPValid({ secret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) { + throw new InvalidTwoFactorError('Invalid two factor header') + } + } + const token = await buildToken() return saveToken(token, client, user, { bypassLogin }) diff --git a/server/lib/redis.ts b/server/lib/redis.ts index 9b3c72300..b7523492a 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts @@ -9,6 +9,7 @@ import { CONTACT_FORM_LIFETIME, RESUMABLE_UPLOAD_SESSION_LIFETIME, TRACKER_RATE_LIMITS, + TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, USER_PASSWORD_CREATE_LIFETIME, USER_PASSWORD_RESET_LIFETIME, @@ -108,10 +109,24 @@ class Redis { return this.removeValue(this.generateResetPasswordKey(userId)) } - async getResetPasswordLink (userId: number) { + async getResetPasswordVerificationString (userId: number) { return this.getValue(this.generateResetPasswordKey(userId)) } + /* ************ Two factor auth request ************ */ + + async setTwoFactorRequest (userId: number, otpSecret: string) { + const requestToken = await generateRandomString(32) + + await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME) + + return requestToken + } + + async getTwoFactorRequestToken (userId: number, requestToken: string) { + return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken)) + } + /* ************ Email verification ************ */ async setVerifyEmailVerificationString (userId: number) { @@ -342,6 +357,10 @@ class Redis { return 'reset-password-' + userId } + private generateTwoFactorRequestKey (userId: number, token: string) { + return 'two-factor-request-' + userId + '-' + token + } + private generateVerifyEmailKey (userId: number) { return 'verify-email-' + userId } @@ -391,8 +410,8 @@ class Redis { return JSON.parse(value) } - private setObject (key: string, value: { [ id: string ]: number | string }) { - return this.setValue(key, JSON.stringify(value)) + private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) { + return this.setValue(key, JSON.stringify(value), expirationMilliseconds) } private async setValue (key: string, value: string, expirationMilliseconds?: number) { diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts index bbd03b248..de98cd442 100644 --- a/server/middlewares/validators/shared/index.ts +++ b/server/middlewares/validators/shared/index.ts @@ -1,5 +1,6 @@ export * from './abuses' export * from './accounts' +export * from './users' export * from './utils' export * from './video-blacklists' export * from './video-captions' diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts new file mode 100644 index 000000000..fbaa7db0e --- /dev/null +++ b/server/middlewares/validators/shared/users.ts @@ -0,0 +1,62 @@ +import express from 'express' +import { ActorModel } from '@server/models/actor/actor' +import { UserModel } from '@server/models/user/user' +import { MUserDefault } from '@server/types/models' +import { HttpStatusCode } from '@shared/models' + +function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) { + const id = parseInt(idArg + '', 10) + return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res) +} + +function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { + return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) +} + +async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { + const user = await UserModel.loadByUsernameOrEmail(username, email) + + if (user) { + res.fail({ + status: HttpStatusCode.CONFLICT_409, + message: 'User with this username or email already exists.' + }) + return false + } + + const actor = await ActorModel.loadLocalByName(username) + if (actor) { + res.fail({ + status: HttpStatusCode.CONFLICT_409, + message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.' + }) + return false + } + + return true +} + +async function checkUserExist (finder: () => Promise, res: express.Response, abortResponse = true) { + const user = await finder() + + if (!user) { + if (abortResponse === true) { + res.fail({ + status: HttpStatusCode.NOT_FOUND_404, + message: 'User not found' + }) + } + + return false + } + + res.locals.user = user + return true +} + +export { + checkUserIdExist, + checkUserEmailExist, + checkUserNameOrEmailDoesNotAlreadyExist, + checkUserExist +} diff --git a/server/middlewares/validators/two-factor.ts b/server/middlewares/validators/two-factor.ts new file mode 100644 index 000000000..106b579b5 --- /dev/null +++ b/server/middlewares/validators/two-factor.ts @@ -0,0 +1,81 @@ +import express from 'express' +import { body, param } from 'express-validator' +import { HttpStatusCode, UserRight } from '@shared/models' +import { exists, isIdValid } from '../../helpers/custom-validators/misc' +import { areValidationErrors, checkUserIdExist } from './shared' + +const requestOrConfirmTwoFactorValidator = [ + param('id').custom(isIdValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return + + if (res.locals.user.otpSecret) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: `Two factor is already enabled.` + }) + } + + return next() + } +] + +const confirmTwoFactorValidator = [ + body('requestToken').custom(exists), + body('otpToken').custom(exists), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + return next() + } +] + +const disableTwoFactorValidator = [ + param('id').custom(isIdValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return + + if (!res.locals.user.otpSecret) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: `Two factor is already disabled.` + }) + } + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + requestOrConfirmTwoFactorValidator, + confirmTwoFactorValidator, + disableTwoFactorValidator +} + +// --------------------------------------------------------------------------- + +async function checkCanEnableOrDisableTwoFactor (userId: number | string, res: express.Response) { + const authUser = res.locals.oauth.token.user + + if (!await checkUserIdExist(userId, res)) return + + if (res.locals.user.id !== authUser.id && authUser.hasRight(UserRight.MANAGE_USERS) !== true) { + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: `User ${authUser.username} does not have right to change two factor setting of this user.` + }) + + return false + } + + return true +} diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index eb693318f..046029547 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -1,9 +1,8 @@ import express from 'express' import { body, param, query } from 'express-validator' import { Hooks } from '@server/lib/plugins/hooks' -import { MUserDefault } from '@server/types/models' import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' -import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' +import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' import { isThemeNameValid } from '../../helpers/custom-validators/plugins' import { isUserAdminFlagsValid, @@ -30,8 +29,15 @@ import { isThemeRegistered } from '../../lib/plugins/theme-utils' import { Redis } from '../../lib/redis' import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' import { ActorModel } from '../../models/actor/actor' -import { UserModel } from '../../models/user/user' -import { areValidationErrors, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam } from './shared' +import { + areValidationErrors, + checkUserEmailExist, + checkUserIdExist, + checkUserNameOrEmailDoesNotAlreadyExist, + doesVideoChannelIdExist, + doesVideoExist, + isValidVideoIdParam +} from './shared' const usersListValidator = [ query('blocked') @@ -435,7 +441,7 @@ const usersResetPasswordValidator = [ if (!await checkUserIdExist(req.params.id, res)) return const user = res.locals.user - const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) + const redisVerificationString = await Redis.Instance.getResetPasswordVerificationString(user.id) if (redisVerificationString !== req.body.verificationString) { return res.fail({ @@ -500,6 +506,24 @@ const usersVerifyEmailValidator = [ } ] +const usersCheckCurrentPassword = [ + body('currentPassword').custom(exists), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const user = res.locals.oauth.token.User + if (await user.isPasswordMatch(req.body.currentPassword) !== true) { + return res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: 'currentPassword is invalid.' + }) + } + + return next() + } +] + const userAutocompleteValidator = [ param('search') .isString() @@ -567,6 +591,7 @@ export { usersUpdateValidator, usersUpdateMeValidator, usersVideoRatingValidator, + usersCheckCurrentPassword, ensureUserRegistrationAllowed, ensureUserRegistrationAllowedForIP, usersGetValidator, @@ -580,55 +605,3 @@ export { ensureCanModerateUser, ensureCanManageChannelOrAccount } - -// --------------------------------------------------------------------------- - -function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) { - const id = parseInt(idArg + '', 10) - return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res) -} - -function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { - return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) -} - -async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { - const user = await UserModel.loadByUsernameOrEmail(username, email) - - if (user) { - res.fail({ - status: HttpStatusCode.CONFLICT_409, - message: 'User with this username or email already exists.' - }) - return false - } - - const actor = await ActorModel.loadLocalByName(username) - if (actor) { - res.fail({ - status: HttpStatusCode.CONFLICT_409, - message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.' - }) - return false - } - - return true -} - -async function checkUserExist (finder: () => Promise, res: express.Response, abortResponse = true) { - const user = await finder() - - if (!user) { - if (abortResponse === true) { - res.fail({ - status: HttpStatusCode.NOT_FOUND_404, - message: 'User not found' - }) - } - - return false - } - - res.locals.user = user - return true -} diff --git a/server/models/user/user.ts b/server/models/user/user.ts index 1a7c84390..34329580b 100644 --- a/server/models/user/user.ts +++ b/server/models/user/user.ts @@ -403,6 +403,11 @@ export class UserModel extends Model>> { @Column lastLoginDate: Date + @AllowNull(true) + @Default(null) + @Column + otpSecret: string + @CreatedAt createdAt: Date @@ -935,7 +940,9 @@ export class UserModel extends Model>> { pluginAuth: this.pluginAuth, - lastLoginDate: this.lastLoginDate + lastLoginDate: this.lastLoginDate, + + twoFactorEnabled: !!this.otpSecret } if (parameters.withAdminFlags) { diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index cd7a38459..33dc8fb76 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -2,6 +2,7 @@ import './abuses' import './accounts' import './blocklist' import './bulk' +import './channel-import-videos' import './config' import './contact-form' import './custom-pages' @@ -17,6 +18,7 @@ import './redundancy' import './search' import './services' import './transcoding' +import './two-factor' import './upload-quota' import './user-notifications' import './user-subscriptions' @@ -24,12 +26,11 @@ import './users-admin' import './users' import './video-blacklist' import './video-captions' +import './video-channel-syncs' import './video-channels' import './video-comments' import './video-files' import './video-imports' -import './video-channel-syncs' -import './channel-import-videos' import './video-playlists' import './video-source' import './video-studio' diff --git a/server/tests/api/check-params/two-factor.ts b/server/tests/api/check-params/two-factor.ts new file mode 100644 index 000000000..e7ca5490c --- /dev/null +++ b/server/tests/api/check-params/two-factor.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@shared/models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands' + +describe('Test two factor API validators', function () { + let server: PeerTubeServer + + let rootId: number + let rootPassword: string + let rootRequestToken: string + let rootOTPToken: string + + let userId: number + let userToken = '' + let userPassword: string + let userRequestToken: string + let userOTPToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + { + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + } + + { + const result = await server.users.generate('user1') + userToken = result.token + userId = result.userId + userPassword = result.password + } + + { + const { id } = await server.users.getMyInfo() + rootId = id + rootPassword = server.store.user.password + } + }) + + describe('When requesting two factor', function () { + + it('Should fail with an unknown user id', async function () { + await server.twoFactor.request({ userId: 42, currentPassword: rootPassword, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an invalid user id', async function () { + await server.twoFactor.request({ + userId: 'invalid' as any, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to request another user two factor without the appropriate rights', async function () { + await server.twoFactor.request({ + userId: rootId, + token: userToken, + currentPassword: userPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to request another user two factor with the appropriate rights', async function () { + await server.twoFactor.request({ userId, currentPassword: rootPassword }) + }) + + it('Should fail to request two factor without a password', async function () { + await server.twoFactor.request({ + userId, + token: userToken, + currentPassword: undefined, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to request two factor with an incorrect password', async function () { + await server.twoFactor.request({ + userId, + token: userToken, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to request my two factor auth', async function () { + { + const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword }) + userRequestToken = otpRequest.requestToken + userOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + } + + { + const { otpRequest } = await server.twoFactor.request({ userId: rootId, currentPassword: rootPassword }) + rootRequestToken = otpRequest.requestToken + rootOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + } + }) + }) + + describe('When confirming two factor request', function () { + + it('Should fail with an unknown user id', async function () { + await server.twoFactor.confirmRequest({ + userId: 42, + requestToken: rootRequestToken, + otpToken: rootOTPToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an invalid user id', async function () { + await server.twoFactor.confirmRequest({ + userId: 'invalid' as any, + requestToken: rootRequestToken, + otpToken: rootOTPToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to confirm another user two factor request without the appropriate rights', async function () { + await server.twoFactor.confirmRequest({ + userId: rootId, + token: userToken, + requestToken: rootRequestToken, + otpToken: rootOTPToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail without request token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: undefined, + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an invalid request token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: 'toto', + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with request token of another user', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: rootRequestToken, + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail without an otp token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: userRequestToken, + otpToken: undefined, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad otp token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: userRequestToken, + otpToken: '123456', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to confirm another user two factor request with the appropriate rights', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: userRequestToken, + otpToken: userOTPToken + }) + + // Reinit + await server.twoFactor.disable({ userId, currentPassword: rootPassword }) + }) + + it('Should succeed to confirm my two factor request', async function () { + await server.twoFactor.confirmRequest({ + userId, + token: userToken, + requestToken: userRequestToken, + otpToken: userOTPToken + }) + }) + + it('Should fail to confirm again two factor request', async function () { + await server.twoFactor.confirmRequest({ + userId, + token: userToken, + requestToken: userRequestToken, + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + describe('When disabling two factor', function () { + + it('Should fail with an unknown user id', async function () { + await server.twoFactor.disable({ + userId: 42, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an invalid user id', async function () { + await server.twoFactor.disable({ + userId: 'invalid' as any, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to disable another user two factor without the appropriate rights', async function () { + await server.twoFactor.disable({ + userId: rootId, + token: userToken, + currentPassword: userPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail to disabled two factor with an incorrect password', async function () { + await server.twoFactor.disable({ + userId, + token: userToken, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to disable another user two factor with the appropriate rights', async function () { + await server.twoFactor.disable({ userId, currentPassword: rootPassword }) + + // Reinit + const { otpRequest } = await server.twoFactor.request({ userId, currentPassword: rootPassword }) + await server.twoFactor.confirmRequest({ + userId, + requestToken: otpRequest.requestToken, + otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + }) + }) + + it('Should succeed to update my two factor auth', async function () { + await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword }) + }) + + it('Should fail to disable again two factor', async function () { + await server.twoFactor.disable({ + userId, + token: userToken, + currentPassword: userPassword, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts index c65152c6f..643f1a531 100644 --- a/server/tests/api/users/index.ts +++ b/server/tests/api/users/index.ts @@ -1,3 +1,4 @@ +import './two-factor' import './user-subscriptions' import './user-videos' import './users' diff --git a/server/tests/api/users/two-factor.ts b/server/tests/api/users/two-factor.ts new file mode 100644 index 000000000..450aac4dc --- /dev/null +++ b/server/tests/api/users/two-factor.ts @@ -0,0 +1,153 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { expectStartWith } from '@server/tests/shared' +import { HttpStatusCode } from '@shared/models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands' + +async function login (options: { + server: PeerTubeServer + password?: string + otpToken?: string + expectedStatus?: HttpStatusCode +}) { + const { server, password = server.store.user.password, otpToken, expectedStatus } = options + + const user = { username: server.store.user.username, password } + const { res, body: { access_token: token } } = await server.login.loginAndGetResponse({ user, otpToken, expectedStatus }) + + return { res, token } +} + +describe('Test users', function () { + let server: PeerTubeServer + let rootId: number + let otpSecret: string + let requestToken: string + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const { id } = await server.users.getMyInfo() + rootId = id + }) + + it('Should not add the header on login if two factor is not enabled', async function () { + const { res, token } = await login({ server }) + + expect(res.header['x-peertube-otp']).to.not.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should request two factor and get the secret and uri', async function () { + const { otpRequest } = await server.twoFactor.request({ + userId: rootId, + currentPassword: server.store.user.password + }) + + expect(otpRequest.requestToken).to.exist + + expect(otpRequest.secret).to.exist + expect(otpRequest.secret).to.have.lengthOf(32) + + expect(otpRequest.uri).to.exist + expectStartWith(otpRequest.uri, 'otpauth://') + expect(otpRequest.uri).to.include(otpRequest.secret) + + requestToken = otpRequest.requestToken + otpSecret = otpRequest.secret + }) + + it('Should not have two factor confirmed yet', async function () { + const { twoFactorEnabled } = await server.users.getMyInfo() + expect(twoFactorEnabled).to.be.false + }) + + it('Should confirm two factor', async function () { + await server.twoFactor.confirmRequest({ + userId: rootId, + otpToken: TwoFactorCommand.buildOTP({ secret: otpSecret }).generate(), + requestToken + }) + }) + + it('Should not add the header on login if two factor is enabled and password is incorrect', async function () { + const { res, token } = await login({ server, password: 'fake', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should add the header on login if two factor is enabled and password is correct', async function () { + const { res, token } = await login({ server, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + + expect(res.header['x-peertube-otp']).to.exist + expect(token).to.not.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should not login with correct password and incorrect otp secret', async function () { + const otp = TwoFactorCommand.buildOTP({ secret: 'a'.repeat(32) }) + + const { res, token } = await login({ server, otpToken: otp.generate(), expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should not login with correct password and incorrect otp code', async function () { + const { res, token } = await login({ server, otpToken: '123456', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should not login with incorrect password and correct otp code', async function () { + const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate() + + const { res, token } = await login({ server, password: 'fake', otpToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should correctly login with correct password and otp code', async function () { + const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate() + + const { res, token } = await login({ server, otpToken }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should have two factor enabled when getting my info', async function () { + const { twoFactorEnabled } = await server.users.getMyInfo() + expect(twoFactorEnabled).to.be.true + }) + + it('Should disable two factor and be able to login without otp token', async function () { + await server.twoFactor.disable({ userId: rootId, currentPassword: server.store.user.password }) + + const { res, token } = await login({ server }) + expect(res.header['x-peertube-otp']).to.not.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should have two factor disabled when getting my info', async function () { + const { twoFactorEnabled } = await server.users.getMyInfo() + expect(twoFactorEnabled).to.be.false + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts index b25978587..32f7a441c 100644 --- a/shared/models/users/index.ts +++ b/shared/models/users/index.ts @@ -1,3 +1,4 @@ +export * from './two-factor-enable-result.model' export * from './user-create-result.model' export * from './user-create.model' export * from './user-flag.model' diff --git a/shared/models/users/two-factor-enable-result.model.ts b/shared/models/users/two-factor-enable-result.model.ts new file mode 100644 index 000000000..1fc801f0a --- /dev/null +++ b/shared/models/users/two-factor-enable-result.model.ts @@ -0,0 +1,7 @@ +export interface TwoFactorEnableResult { + otpRequest: { + requestToken: string + secret: string + uri: string + } +} diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts index 63c5c8a92..7b6494ff8 100644 --- a/shared/models/users/user.model.ts +++ b/shared/models/users/user.model.ts @@ -62,6 +62,8 @@ export interface User { pluginAuth: string | null lastLoginDate: Date | null + + twoFactorEnabled: boolean } export interface MyUserSpecialPlaylist { diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index a8f8c1d84..7096faf21 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts @@ -13,7 +13,15 @@ import { AbusesCommand } from '../moderation' import { OverviewsCommand } from '../overviews' import { SearchCommand } from '../search' import { SocketIOCommand } from '../socket' -import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users' +import { + AccountsCommand, + BlocklistCommand, + LoginCommand, + NotificationsCommand, + SubscriptionsCommand, + TwoFactorCommand, + UsersCommand +} from '../users' import { BlacklistCommand, CaptionsCommand, @@ -136,6 +144,7 @@ export class PeerTubeServer { videos?: VideosCommand videoStats?: VideoStatsCommand views?: ViewsCommand + twoFactor?: TwoFactorCommand constructor (options: { serverNumber: number } | { url: string }) { if ((options as any).url) { @@ -417,5 +426,6 @@ export class PeerTubeServer { this.videoStudio = new VideoStudioCommand(this) this.videoStats = new VideoStatsCommand(this) this.views = new ViewsCommand(this) + this.twoFactor = new TwoFactorCommand(this) } } diff --git a/shared/server-commands/users/index.ts b/shared/server-commands/users/index.ts index f6f93b4d2..1afc02dc1 100644 --- a/shared/server-commands/users/index.ts +++ b/shared/server-commands/users/index.ts @@ -5,4 +5,5 @@ export * from './login' export * from './login-command' export * from './notifications-command' export * from './subscriptions-command' +export * from './two-factor-command' export * from './users-command' diff --git a/shared/server-commands/users/login-command.ts b/shared/server-commands/users/login-command.ts index 54070e426..f2fc6d1c5 100644 --- a/shared/server-commands/users/login-command.ts +++ b/shared/server-commands/users/login-command.ts @@ -2,34 +2,27 @@ import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models' import { unwrapBody } from '../requests' import { AbstractCommand, OverrideCommandOptions } from '../shared' +type LoginOptions = OverrideCommandOptions & { + client?: { id?: string, secret?: string } + user?: { username: string, password?: string } + otpToken?: string +} + export class LoginCommand extends AbstractCommand { - login (options: OverrideCommandOptions & { - client?: { id?: string, secret?: string } - user?: { username: string, password?: string } - } = {}) { - const { client = this.server.store.client, user = this.server.store.user } = options - const path = '/api/v1/users/token' + async login (options: LoginOptions = {}) { + const res = await this._login(options) - const body = { - client_id: client.id, - client_secret: client.secret, - username: user.username, - password: user.password ?? 'password', - response_type: 'code', - grant_type: 'password', - scope: 'upload' - } + return this.unwrapLoginBody(res.body) + } - return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({ - ...options, + async loginAndGetResponse (options: LoginOptions = {}) { + const res = await this._login(options) - path, - requestType: 'form', - fields: body, - implicitToken: false, - defaultExpectedStatus: HttpStatusCode.OK_200 - })) + return { + res, + body: this.unwrapLoginBody(res.body) + } } getAccessToken (arg1?: { username: string, password?: string }): Promise @@ -129,4 +122,38 @@ export class LoginCommand extends AbstractCommand { defaultExpectedStatus: HttpStatusCode.OK_200 }) } + + private _login (options: LoginOptions) { + const { client = this.server.store.client, user = this.server.store.user, otpToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: client.id, + client_secret: client.secret, + username: user.username, + password: user.password ?? 'password', + response_type: 'code', + grant_type: 'password', + scope: 'upload' + } + + const headers = otpToken + ? { 'x-peertube-otp': otpToken } + : {} + + return this.postBodyRequest({ + ...options, + + path, + headers, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private unwrapLoginBody (body: any) { + return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument + } } diff --git a/shared/server-commands/users/two-factor-command.ts b/shared/server-commands/users/two-factor-command.ts new file mode 100644 index 000000000..6c9d270ae --- /dev/null +++ b/shared/server-commands/users/two-factor-command.ts @@ -0,0 +1,75 @@ +import { TOTP } from 'otpauth' +import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class TwoFactorCommand extends AbstractCommand { + + static buildOTP (options: { + secret: string + }) { + const { secret } = options + + return new TOTP({ + issuer: 'PeerTube', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret + }) + } + + request (options: OverrideCommandOptions & { + userId: number + currentPassword: string + }) { + const { currentPassword, userId } = options + + const path = '/api/v1/users/' + userId + '/two-factor/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + confirmRequest (options: OverrideCommandOptions & { + userId: number + requestToken: string + otpToken: string + }) { + const { userId, requestToken, otpToken } = options + + const path = '/api/v1/users/' + userId + '/two-factor/confirm-request' + + return this.postBodyRequest({ + ...options, + + path, + fields: { requestToken, otpToken }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + disable (options: OverrideCommandOptions & { + userId: number + currentPassword: string + }) { + const { userId, currentPassword } = options + const path = '/api/v1/users/' + userId + '/two-factor/disable' + + return this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/users/users-command.ts b/shared/server-commands/users/users-command.ts index e7d021059..811b9685b 100644 --- a/shared/server-commands/users/users-command.ts +++ b/shared/server-commands/users/users-command.ts @@ -202,7 +202,8 @@ export class UsersCommand extends AbstractCommand { token, userId: user.id, userChannelId: me.videoChannels[0].id, - userChannelName: me.videoChannels[0].name + userChannelName: me.videoChannels[0].name, + password } } diff --git a/yarn.lock b/yarn.lock index 60fe262fa..8ccc4fd0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5945,6 +5945,11 @@ jsprim@^1.2.2: json-schema "0.4.0" verror "1.10.0" +jssha@~3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/jssha/-/jssha-3.2.0.tgz#88ec50b866dd1411deaddbe6b3e3692e4c710f16" + integrity sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q== + jstransformer@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" @@ -7007,6 +7012,13 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== +otpauth@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-8.0.3.tgz#fdbcb24503e93dd7d930a8651f2dc9f8f7ff9c1b" + integrity sha512-5abBweT/POpMdVuM0Zk/tvlTHw8Kc8606XX/w8QNLRBDib+FVpseAx12Z21/iVIeCrJOgCY1dBuLS057IOdybw== + dependencies: + jssha "~3.2.0" + p-cancelable@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" -- 2.41.0