Support two factor authentication in backend
This commit is contained in:
parent
7dd7ff4ceb
commit
56f4783075
27 changed files with 1016 additions and 92 deletions
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
91
server/controllers/api/users/two-factor.ts
Normal file
91
server/controllers/api/users/two-factor.ts
Normal file
|
@ -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)
|
||||
}
|
54
server/helpers/otp.ts
Normal file
54
server/helpers/otp.ts
Normal file
|
@ -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_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
|
||||
|
|
29
server/initializers/migrations/0745-user-otp.ts
Normal file
29
server/initializers/migrations/0745-user-otp.ts
Normal file
|
@ -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 { 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 })
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export * from './abuses'
|
||||
export * from './accounts'
|
||||
export * from './users'
|
||||
export * from './utils'
|
||||
export * from './video-blacklists'
|
||||
export * from './video-captions'
|
||||
|
|
62
server/middlewares/validators/shared/users.ts
Normal file
62
server/middlewares/validators/shared/users.ts
Normal file
|
@ -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
|
||||
}
|
81
server/middlewares/validators/two-factor.ts
Normal file
81
server/middlewares/validators/two-factor.ts
Normal file
|
@ -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 { 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<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
|
||||
lastLoginDate: Date
|
||||
|
||||
@AllowNull(true)
|
||||
@Default(null)
|
||||
@Column
|
||||
otpSecret: string
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date
|
||||
|
||||
|
@ -935,7 +940,9 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
|
|||
|
||||
pluginAuth: this.pluginAuth,
|
||||
|
||||
lastLoginDate: this.lastLoginDate
|
||||
lastLoginDate: this.lastLoginDate,
|
||||
|
||||
twoFactorEnabled: !!this.otpSecret
|
||||
}
|
||||
|
||||
if (parameters.withAdminFlags) {
|
||||
|
|
|
@ -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'
|
||||
|
|
275
server/tests/api/check-params/two-factor.ts
Normal file
275
server/tests/api/check-params/two-factor.ts
Normal file
|
@ -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-videos'
|
||||
import './users'
|
||||
|
|
153
server/tests/api/users/two-factor.ts
Normal file
153
server/tests/api/users/two-factor.ts
Normal file
|
@ -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.model'
|
||||
export * from './user-flag.model'
|
||||
|
|
7
shared/models/users/two-factor-enable-result.model.ts
Normal file
7
shared/models/users/two-factor-enable-result.model.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface TwoFactorEnableResult {
|
||||
otpRequest: {
|
||||
requestToken: string
|
||||
secret: string
|
||||
uri: string
|
||||
}
|
||||
}
|
|
@ -62,6 +62,8 @@ export interface User {
|
|||
pluginAuth: string | null
|
||||
|
||||
lastLoginDate: Date | null
|
||||
|
||||
twoFactorEnabled: boolean
|
||||
}
|
||||
|
||||
export interface MyUserSpecialPlaylist {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
async loginAndGetResponse (options: LoginOptions = {}) {
|
||||
const res = await this._login(options)
|
||||
|
||||
return {
|
||||
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>
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
75
shared/server-commands/users/two-factor-command.ts
Normal file
75
shared/server-commands/users/two-factor-command.ts
Normal file
|
@ -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,
|
||||
userId: user.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"
|
||||
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"
|
||||
|
|
Loading…
Reference in a new issue