Support two factor authentication in backend
This commit is contained in:
parent
7dd7ff4ceb
commit
56f4783075
|
@ -147,6 +147,7 @@
|
||||||
"node-media-server": "^2.1.4",
|
"node-media-server": "^2.1.4",
|
||||||
"nodemailer": "^6.0.0",
|
"nodemailer": "^6.0.0",
|
||||||
"opentelemetry-instrumentation-sequelize": "^0.29.0",
|
"opentelemetry-instrumentation-sequelize": "^0.29.0",
|
||||||
|
"otpauth": "^8.0.3",
|
||||||
"p-queue": "^6",
|
"p-queue": "^6",
|
||||||
"parse-torrent": "^9.1.0",
|
"parse-torrent": "^9.1.0",
|
||||||
"password-generator": "^2.0.2",
|
"password-generator": "^2.0.2",
|
||||||
|
|
|
@ -51,6 +51,7 @@ import { myVideosHistoryRouter } from './my-history'
|
||||||
import { myNotificationsRouter } from './my-notifications'
|
import { myNotificationsRouter } from './my-notifications'
|
||||||
import { mySubscriptionsRouter } from './my-subscriptions'
|
import { mySubscriptionsRouter } from './my-subscriptions'
|
||||||
import { myVideoPlaylistsRouter } from './my-video-playlists'
|
import { myVideoPlaylistsRouter } from './my-video-playlists'
|
||||||
|
import { twoFactorRouter } from './two-factor'
|
||||||
|
|
||||||
const auditLogger = auditLoggerFactory('users')
|
const auditLogger = auditLoggerFactory('users')
|
||||||
|
|
||||||
|
@ -66,6 +67,7 @@ const askSendEmailLimiter = buildRateLimiter({
|
||||||
})
|
})
|
||||||
|
|
||||||
const usersRouter = express.Router()
|
const usersRouter = express.Router()
|
||||||
|
usersRouter.use('/', twoFactorRouter)
|
||||||
usersRouter.use('/', tokensRouter)
|
usersRouter.use('/', tokensRouter)
|
||||||
usersRouter.use('/', myNotificationsRouter)
|
usersRouter.use('/', myNotificationsRouter)
|
||||||
usersRouter.use('/', mySubscriptionsRouter)
|
usersRouter.use('/', mySubscriptionsRouter)
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
import { CONFIG } from '@server/initializers/config'
|
import { CONFIG } from '@server/initializers/config'
|
||||||
|
import { OTP } from '@server/initializers/constants'
|
||||||
import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
|
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 { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks'
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares'
|
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) {
|
} catch (err) {
|
||||||
logger.warn('Login error', { err })
|
logger.warn('Login error', { err })
|
||||||
|
|
||||||
|
if (err instanceof MissingTwoFactorError) {
|
||||||
|
res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
return res.fail({
|
return res.fail({
|
||||||
status: err.code,
|
status: err.code,
|
||||||
message: err.message,
|
message: err.message,
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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_RESET_LIFETIME = 60000 * 60 // 60 minutes
|
||||||
const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
|
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 USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
|
||||||
|
|
||||||
const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
|
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 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 = {
|
const ASSETS_PATH = {
|
||||||
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
|
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
|
||||||
|
@ -986,6 +992,7 @@ export {
|
||||||
FOLLOW_STATES,
|
FOLLOW_STATES,
|
||||||
DEFAULT_USER_THEME_NAME,
|
DEFAULT_USER_THEME_NAME,
|
||||||
SERVER_ACTOR_NAME,
|
SERVER_ACTOR_NAME,
|
||||||
|
TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
|
||||||
PLUGIN_GLOBAL_CSS_FILE_NAME,
|
PLUGIN_GLOBAL_CSS_FILE_NAME,
|
||||||
PLUGIN_GLOBAL_CSS_PATH,
|
PLUGIN_GLOBAL_CSS_PATH,
|
||||||
PRIVATE_RSA_KEY_SIZE,
|
PRIVATE_RSA_KEY_SIZE,
|
||||||
|
@ -1041,6 +1048,7 @@ export {
|
||||||
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME,
|
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME,
|
||||||
ASSETS_PATH,
|
ASSETS_PATH,
|
||||||
FILES_CONTENT_HASH,
|
FILES_CONTENT_HASH,
|
||||||
|
OTP,
|
||||||
loadLanguages,
|
loadLanguages,
|
||||||
buildLanguages,
|
buildLanguages,
|
||||||
generateContentHash
|
generateContentHash
|
||||||
|
|
|
@ -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<void> {
|
||||||
|
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
|
||||||
|
}
|
|
@ -11,8 +11,20 @@ import OAuth2Server, {
|
||||||
import { randomBytesPromise } from '@server/helpers/core-utils'
|
import { randomBytesPromise } from '@server/helpers/core-utils'
|
||||||
import { MOAuthClient } from '@server/types/models'
|
import { MOAuthClient } from '@server/types/models'
|
||||||
import { sha1 } from '@shared/extra-utils'
|
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 { 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 {
|
export {
|
||||||
|
MissingTwoFactorError,
|
||||||
|
InvalidTwoFactorError,
|
||||||
|
|
||||||
handleOAuthToken,
|
handleOAuthToken,
|
||||||
handleOAuthAuthenticate
|
handleOAuthAuthenticate
|
||||||
}
|
}
|
||||||
|
@ -118,6 +133,16 @@ async function handlePasswordGrant (options: {
|
||||||
const user = await getUser(request.body.username, request.body.password, bypassLogin)
|
const user = await getUser(request.body.username, request.body.password, bypassLogin)
|
||||||
if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
|
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()
|
const token = await buildToken()
|
||||||
|
|
||||||
return saveToken(token, client, user, { bypassLogin })
|
return saveToken(token, client, user, { bypassLogin })
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
CONTACT_FORM_LIFETIME,
|
CONTACT_FORM_LIFETIME,
|
||||||
RESUMABLE_UPLOAD_SESSION_LIFETIME,
|
RESUMABLE_UPLOAD_SESSION_LIFETIME,
|
||||||
TRACKER_RATE_LIMITS,
|
TRACKER_RATE_LIMITS,
|
||||||
|
TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
|
||||||
USER_EMAIL_VERIFY_LIFETIME,
|
USER_EMAIL_VERIFY_LIFETIME,
|
||||||
USER_PASSWORD_CREATE_LIFETIME,
|
USER_PASSWORD_CREATE_LIFETIME,
|
||||||
USER_PASSWORD_RESET_LIFETIME,
|
USER_PASSWORD_RESET_LIFETIME,
|
||||||
|
@ -108,10 +109,24 @@ class Redis {
|
||||||
return this.removeValue(this.generateResetPasswordKey(userId))
|
return this.removeValue(this.generateResetPasswordKey(userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
async getResetPasswordLink (userId: number) {
|
async getResetPasswordVerificationString (userId: number) {
|
||||||
return this.getValue(this.generateResetPasswordKey(userId))
|
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 ************ */
|
/* ************ Email verification ************ */
|
||||||
|
|
||||||
async setVerifyEmailVerificationString (userId: number) {
|
async setVerifyEmailVerificationString (userId: number) {
|
||||||
|
@ -342,6 +357,10 @@ class Redis {
|
||||||
return 'reset-password-' + userId
|
return 'reset-password-' + userId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateTwoFactorRequestKey (userId: number, token: string) {
|
||||||
|
return 'two-factor-request-' + userId + '-' + token
|
||||||
|
}
|
||||||
|
|
||||||
private generateVerifyEmailKey (userId: number) {
|
private generateVerifyEmailKey (userId: number) {
|
||||||
return 'verify-email-' + userId
|
return 'verify-email-' + userId
|
||||||
}
|
}
|
||||||
|
@ -391,8 +410,8 @@ class Redis {
|
||||||
return JSON.parse(value)
|
return JSON.parse(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private setObject (key: string, value: { [ id: string ]: number | string }) {
|
private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) {
|
||||||
return this.setValue(key, JSON.stringify(value))
|
return this.setValue(key, JSON.stringify(value), expirationMilliseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setValue (key: string, value: string, expirationMilliseconds?: number) {
|
private async setValue (key: string, value: string, expirationMilliseconds?: number) {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './abuses'
|
export * from './abuses'
|
||||||
export * from './accounts'
|
export * from './accounts'
|
||||||
|
export * from './users'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
export * from './video-blacklists'
|
export * from './video-blacklists'
|
||||||
export * from './video-captions'
|
export * from './video-captions'
|
||||||
|
|
|
@ -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<MUserDefault>, 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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -1,9 +1,8 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { body, param, query } from 'express-validator'
|
import { body, param, query } from 'express-validator'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks'
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
import { MUserDefault } from '@server/types/models'
|
|
||||||
import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/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 { isThemeNameValid } from '../../helpers/custom-validators/plugins'
|
||||||
import {
|
import {
|
||||||
isUserAdminFlagsValid,
|
isUserAdminFlagsValid,
|
||||||
|
@ -30,8 +29,15 @@ import { isThemeRegistered } from '../../lib/plugins/theme-utils'
|
||||||
import { Redis } from '../../lib/redis'
|
import { Redis } from '../../lib/redis'
|
||||||
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
|
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
|
||||||
import { ActorModel } from '../../models/actor/actor'
|
import { ActorModel } from '../../models/actor/actor'
|
||||||
import { UserModel } from '../../models/user/user'
|
import {
|
||||||
import { areValidationErrors, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam } from './shared'
|
areValidationErrors,
|
||||||
|
checkUserEmailExist,
|
||||||
|
checkUserIdExist,
|
||||||
|
checkUserNameOrEmailDoesNotAlreadyExist,
|
||||||
|
doesVideoChannelIdExist,
|
||||||
|
doesVideoExist,
|
||||||
|
isValidVideoIdParam
|
||||||
|
} from './shared'
|
||||||
|
|
||||||
const usersListValidator = [
|
const usersListValidator = [
|
||||||
query('blocked')
|
query('blocked')
|
||||||
|
@ -435,7 +441,7 @@ const usersResetPasswordValidator = [
|
||||||
if (!await checkUserIdExist(req.params.id, res)) return
|
if (!await checkUserIdExist(req.params.id, res)) return
|
||||||
|
|
||||||
const user = res.locals.user
|
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) {
|
if (redisVerificationString !== req.body.verificationString) {
|
||||||
return res.fail({
|
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 = [
|
const userAutocompleteValidator = [
|
||||||
param('search')
|
param('search')
|
||||||
.isString()
|
.isString()
|
||||||
|
@ -567,6 +591,7 @@ export {
|
||||||
usersUpdateValidator,
|
usersUpdateValidator,
|
||||||
usersUpdateMeValidator,
|
usersUpdateMeValidator,
|
||||||
usersVideoRatingValidator,
|
usersVideoRatingValidator,
|
||||||
|
usersCheckCurrentPassword,
|
||||||
ensureUserRegistrationAllowed,
|
ensureUserRegistrationAllowed,
|
||||||
ensureUserRegistrationAllowedForIP,
|
ensureUserRegistrationAllowedForIP,
|
||||||
usersGetValidator,
|
usersGetValidator,
|
||||||
|
@ -580,55 +605,3 @@ export {
|
||||||
ensureCanModerateUser,
|
ensureCanModerateUser,
|
||||||
ensureCanManageChannelOrAccount
|
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<MUserDefault>, 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
|
|
||||||
}
|
|
||||||
|
|
|
@ -403,6 +403,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
|
||||||
@Column
|
@Column
|
||||||
lastLoginDate: Date
|
lastLoginDate: Date
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Default(null)
|
||||||
|
@Column
|
||||||
|
otpSecret: string
|
||||||
|
|
||||||
@CreatedAt
|
@CreatedAt
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
|
|
||||||
|
@ -935,7 +940,9 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
|
||||||
|
|
||||||
pluginAuth: this.pluginAuth,
|
pluginAuth: this.pluginAuth,
|
||||||
|
|
||||||
lastLoginDate: this.lastLoginDate
|
lastLoginDate: this.lastLoginDate,
|
||||||
|
|
||||||
|
twoFactorEnabled: !!this.otpSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parameters.withAdminFlags) {
|
if (parameters.withAdminFlags) {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import './abuses'
|
||||||
import './accounts'
|
import './accounts'
|
||||||
import './blocklist'
|
import './blocklist'
|
||||||
import './bulk'
|
import './bulk'
|
||||||
|
import './channel-import-videos'
|
||||||
import './config'
|
import './config'
|
||||||
import './contact-form'
|
import './contact-form'
|
||||||
import './custom-pages'
|
import './custom-pages'
|
||||||
|
@ -17,6 +18,7 @@ import './redundancy'
|
||||||
import './search'
|
import './search'
|
||||||
import './services'
|
import './services'
|
||||||
import './transcoding'
|
import './transcoding'
|
||||||
|
import './two-factor'
|
||||||
import './upload-quota'
|
import './upload-quota'
|
||||||
import './user-notifications'
|
import './user-notifications'
|
||||||
import './user-subscriptions'
|
import './user-subscriptions'
|
||||||
|
@ -24,12 +26,11 @@ import './users-admin'
|
||||||
import './users'
|
import './users'
|
||||||
import './video-blacklist'
|
import './video-blacklist'
|
||||||
import './video-captions'
|
import './video-captions'
|
||||||
|
import './video-channel-syncs'
|
||||||
import './video-channels'
|
import './video-channels'
|
||||||
import './video-comments'
|
import './video-comments'
|
||||||
import './video-files'
|
import './video-files'
|
||||||
import './video-imports'
|
import './video-imports'
|
||||||
import './video-channel-syncs'
|
|
||||||
import './channel-import-videos'
|
|
||||||
import './video-playlists'
|
import './video-playlists'
|
||||||
import './video-source'
|
import './video-source'
|
||||||
import './video-studio'
|
import './video-studio'
|
||||||
|
|
|
@ -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 ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,3 +1,4 @@
|
||||||
|
import './two-factor'
|
||||||
import './user-subscriptions'
|
import './user-subscriptions'
|
||||||
import './user-videos'
|
import './user-videos'
|
||||||
import './users'
|
import './users'
|
||||||
|
|
|
@ -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 ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from './two-factor-enable-result.model'
|
||||||
export * from './user-create-result.model'
|
export * from './user-create-result.model'
|
||||||
export * from './user-create.model'
|
export * from './user-create.model'
|
||||||
export * from './user-flag.model'
|
export * from './user-flag.model'
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface TwoFactorEnableResult {
|
||||||
|
otpRequest: {
|
||||||
|
requestToken: string
|
||||||
|
secret: string
|
||||||
|
uri: string
|
||||||
|
}
|
||||||
|
}
|
|
@ -62,6 +62,8 @@ export interface User {
|
||||||
pluginAuth: string | null
|
pluginAuth: string | null
|
||||||
|
|
||||||
lastLoginDate: Date | null
|
lastLoginDate: Date | null
|
||||||
|
|
||||||
|
twoFactorEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MyUserSpecialPlaylist {
|
export interface MyUserSpecialPlaylist {
|
||||||
|
|
|
@ -13,7 +13,15 @@ import { AbusesCommand } from '../moderation'
|
||||||
import { OverviewsCommand } from '../overviews'
|
import { OverviewsCommand } from '../overviews'
|
||||||
import { SearchCommand } from '../search'
|
import { SearchCommand } from '../search'
|
||||||
import { SocketIOCommand } from '../socket'
|
import { SocketIOCommand } from '../socket'
|
||||||
import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users'
|
import {
|
||||||
|
AccountsCommand,
|
||||||
|
BlocklistCommand,
|
||||||
|
LoginCommand,
|
||||||
|
NotificationsCommand,
|
||||||
|
SubscriptionsCommand,
|
||||||
|
TwoFactorCommand,
|
||||||
|
UsersCommand
|
||||||
|
} from '../users'
|
||||||
import {
|
import {
|
||||||
BlacklistCommand,
|
BlacklistCommand,
|
||||||
CaptionsCommand,
|
CaptionsCommand,
|
||||||
|
@ -136,6 +144,7 @@ export class PeerTubeServer {
|
||||||
videos?: VideosCommand
|
videos?: VideosCommand
|
||||||
videoStats?: VideoStatsCommand
|
videoStats?: VideoStatsCommand
|
||||||
views?: ViewsCommand
|
views?: ViewsCommand
|
||||||
|
twoFactor?: TwoFactorCommand
|
||||||
|
|
||||||
constructor (options: { serverNumber: number } | { url: string }) {
|
constructor (options: { serverNumber: number } | { url: string }) {
|
||||||
if ((options as any).url) {
|
if ((options as any).url) {
|
||||||
|
@ -417,5 +426,6 @@ export class PeerTubeServer {
|
||||||
this.videoStudio = new VideoStudioCommand(this)
|
this.videoStudio = new VideoStudioCommand(this)
|
||||||
this.videoStats = new VideoStatsCommand(this)
|
this.videoStats = new VideoStatsCommand(this)
|
||||||
this.views = new ViewsCommand(this)
|
this.views = new ViewsCommand(this)
|
||||||
|
this.twoFactor = new TwoFactorCommand(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,4 +5,5 @@ export * from './login'
|
||||||
export * from './login-command'
|
export * from './login-command'
|
||||||
export * from './notifications-command'
|
export * from './notifications-command'
|
||||||
export * from './subscriptions-command'
|
export * from './subscriptions-command'
|
||||||
|
export * from './two-factor-command'
|
||||||
export * from './users-command'
|
export * from './users-command'
|
||||||
|
|
|
@ -2,34 +2,27 @@ import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models'
|
||||||
import { unwrapBody } from '../requests'
|
import { unwrapBody } from '../requests'
|
||||||
import { AbstractCommand, OverrideCommandOptions } from '../shared'
|
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 {
|
export class LoginCommand extends AbstractCommand {
|
||||||
|
|
||||||
login (options: OverrideCommandOptions & {
|
async login (options: LoginOptions = {}) {
|
||||||
client?: { id?: string, secret?: string }
|
const res = await this._login(options)
|
||||||
user?: { username: string, password?: string }
|
|
||||||
} = {}) {
|
|
||||||
const { client = this.server.store.client, user = this.server.store.user } = options
|
|
||||||
const path = '/api/v1/users/token'
|
|
||||||
|
|
||||||
const body = {
|
return this.unwrapLoginBody(res.body)
|
||||||
client_id: client.id,
|
}
|
||||||
client_secret: client.secret,
|
|
||||||
username: user.username,
|
async loginAndGetResponse (options: LoginOptions = {}) {
|
||||||
password: user.password ?? 'password',
|
const res = await this._login(options)
|
||||||
response_type: 'code',
|
|
||||||
grant_type: 'password',
|
return {
|
||||||
scope: 'upload'
|
res,
|
||||||
|
body: this.unwrapLoginBody(res.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({
|
|
||||||
...options,
|
|
||||||
|
|
||||||
path,
|
|
||||||
requestType: 'form',
|
|
||||||
fields: body,
|
|
||||||
implicitToken: false,
|
|
||||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
|
getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
|
||||||
|
@ -129,4 +122,38 @@ export class LoginCommand extends AbstractCommand {
|
||||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<TwoFactorEnableResult>(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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -202,7 +202,8 @@ export class UsersCommand extends AbstractCommand {
|
||||||
token,
|
token,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userChannelId: me.videoChannels[0].id,
|
userChannelId: me.videoChannels[0].id,
|
||||||
userChannelName: me.videoChannels[0].name
|
userChannelName: me.videoChannels[0].name,
|
||||||
|
password
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -5945,6 +5945,11 @@ jsprim@^1.2.2:
|
||||||
json-schema "0.4.0"
|
json-schema "0.4.0"
|
||||||
verror "1.10.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:
|
jstransformer@1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3"
|
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"
|
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||||
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
|
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:
|
p-cancelable@^2.0.0:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"
|
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"
|
||||||
|
|
Loading…
Reference in New Issue