1
0
Fork 0

Support two factor authentication in backend

This commit is contained in:
Chocobozzz 2022-10-05 15:37:15 +02:00
parent 7dd7ff4ceb
commit 56f4783075
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
27 changed files with 1016 additions and 92 deletions

View File

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

View File

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

View File

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

View 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
View 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
}
}

View File

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

View 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
}

View File

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

View File

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

View File

@ -1,5 +1,6 @@
export * from './abuses'
export * from './accounts'
export * from './users'
export * from './utils'
export * from './video-blacklists'
export * from './video-captions'

View 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
}

View 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
}

View File

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

View File

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

View File

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

View 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 ])
})
})

View File

@ -1,3 +1,4 @@
import './two-factor'
import './user-subscriptions'
import './user-videos'
import './users'

View 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 ])
})
})

View File

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

View File

@ -0,0 +1,7 @@
export interface TwoFactorEnableResult {
otpRequest: {
requestToken: string
secret: string
uri: string
}
}

View File

@ -62,6 +62,8 @@ export interface User {
pluginAuth: string | null
lastLoginDate: Date | null
twoFactorEnabled: boolean
}
export interface MyUserSpecialPlaylist {

View File

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

View File

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

View File

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

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

View File

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

View File

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