aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--server/controllers/api/users/index.ts2
-rw-r--r--server/controllers/api/users/token.ts7
-rw-r--r--server/controllers/api/users/two-factor.ts91
-rw-r--r--server/helpers/otp.ts54
-rw-r--r--server/initializers/constants.ts10
-rw-r--r--server/initializers/migrations/0745-user-otp.ts29
-rw-r--r--server/lib/auth/oauth.ts27
-rw-r--r--server/lib/redis.ts25
-rw-r--r--server/middlewares/validators/shared/index.ts1
-rw-r--r--server/middlewares/validators/shared/users.ts62
-rw-r--r--server/middlewares/validators/two-factor.ts81
-rw-r--r--server/middlewares/validators/users.ts87
-rw-r--r--server/models/user/user.ts9
-rw-r--r--server/tests/api/check-params/index.ts5
-rw-r--r--server/tests/api/check-params/two-factor.ts275
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/two-factor.ts153
-rw-r--r--shared/models/users/index.ts1
-rw-r--r--shared/models/users/two-factor-enable-result.model.ts7
-rw-r--r--shared/models/users/user.model.ts2
-rw-r--r--shared/server-commands/server/server.ts12
-rw-r--r--shared/server-commands/users/index.ts1
-rw-r--r--shared/server-commands/users/login-command.ts73
-rw-r--r--shared/server-commands/users/two-factor-command.ts75
-rw-r--r--shared/server-commands/users/users-command.ts3
-rw-r--r--yarn.lock12
27 files changed, 1015 insertions, 91 deletions
diff --git a/package.json b/package.json
index dd913896d..6dcf26253 100644
--- a/package.json
+++ b/package.json
@@ -147,6 +147,7 @@
147 "node-media-server": "^2.1.4", 147 "node-media-server": "^2.1.4",
148 "nodemailer": "^6.0.0", 148 "nodemailer": "^6.0.0",
149 "opentelemetry-instrumentation-sequelize": "^0.29.0", 149 "opentelemetry-instrumentation-sequelize": "^0.29.0",
150 "otpauth": "^8.0.3",
150 "p-queue": "^6", 151 "p-queue": "^6",
151 "parse-torrent": "^9.1.0", 152 "parse-torrent": "^9.1.0",
152 "password-generator": "^2.0.2", 153 "password-generator": "^2.0.2",
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 07b9ae395..a8677a1d3 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -51,6 +51,7 @@ import { myVideosHistoryRouter } from './my-history'
51import { myNotificationsRouter } from './my-notifications' 51import { myNotificationsRouter } from './my-notifications'
52import { mySubscriptionsRouter } from './my-subscriptions' 52import { mySubscriptionsRouter } from './my-subscriptions'
53import { myVideoPlaylistsRouter } from './my-video-playlists' 53import { myVideoPlaylistsRouter } from './my-video-playlists'
54import { twoFactorRouter } from './two-factor'
54 55
55const auditLogger = auditLoggerFactory('users') 56const auditLogger = auditLoggerFactory('users')
56 57
@@ -66,6 +67,7 @@ const askSendEmailLimiter = buildRateLimiter({
66}) 67})
67 68
68const usersRouter = express.Router() 69const usersRouter = express.Router()
70usersRouter.use('/', twoFactorRouter)
69usersRouter.use('/', tokensRouter) 71usersRouter.use('/', tokensRouter)
70usersRouter.use('/', myNotificationsRouter) 72usersRouter.use('/', myNotificationsRouter)
71usersRouter.use('/', mySubscriptionsRouter) 73usersRouter.use('/', mySubscriptionsRouter)
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
index 012a49791..c6afea67c 100644
--- a/server/controllers/api/users/token.ts
+++ b/server/controllers/api/users/token.ts
@@ -1,8 +1,9 @@
1import express from 'express' 1import express from 'express'
2import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config' 3import { CONFIG } from '@server/initializers/config'
4import { OTP } from '@server/initializers/constants'
4import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' 5import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
5import { handleOAuthToken } from '@server/lib/auth/oauth' 6import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth'
6import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' 7import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
7import { Hooks } from '@server/lib/plugins/hooks' 8import { Hooks } from '@server/lib/plugins/hooks'
8import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares' 9import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares'
@@ -79,6 +80,10 @@ async function handleToken (req: express.Request, res: express.Response, next: e
79 } catch (err) { 80 } catch (err) {
80 logger.warn('Login error', { err }) 81 logger.warn('Login error', { err })
81 82
83 if (err instanceof MissingTwoFactorError) {
84 res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE)
85 }
86
82 return res.fail({ 87 return res.fail({
83 status: err.code, 88 status: err.code,
84 message: err.message, 89 message: err.message,
diff --git a/server/controllers/api/users/two-factor.ts b/server/controllers/api/users/two-factor.ts
new file mode 100644
index 000000000..1725294e7
--- /dev/null
+++ b/server/controllers/api/users/two-factor.ts
@@ -0,0 +1,91 @@
1import express from 'express'
2import { generateOTPSecret, isOTPValid } from '@server/helpers/otp'
3import { Redis } from '@server/lib/redis'
4import { asyncMiddleware, authenticate, usersCheckCurrentPassword } from '@server/middlewares'
5import {
6 confirmTwoFactorValidator,
7 disableTwoFactorValidator,
8 requestOrConfirmTwoFactorValidator
9} from '@server/middlewares/validators/two-factor'
10import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
11
12const twoFactorRouter = express.Router()
13
14twoFactorRouter.post('/:id/two-factor/request',
15 authenticate,
16 asyncMiddleware(usersCheckCurrentPassword),
17 asyncMiddleware(requestOrConfirmTwoFactorValidator),
18 asyncMiddleware(requestTwoFactor)
19)
20
21twoFactorRouter.post('/:id/two-factor/confirm-request',
22 authenticate,
23 asyncMiddleware(requestOrConfirmTwoFactorValidator),
24 confirmTwoFactorValidator,
25 asyncMiddleware(confirmRequestTwoFactor)
26)
27
28twoFactorRouter.post('/:id/two-factor/disable',
29 authenticate,
30 asyncMiddleware(usersCheckCurrentPassword),
31 asyncMiddleware(disableTwoFactorValidator),
32 asyncMiddleware(disableTwoFactor)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 twoFactorRouter
39}
40
41// ---------------------------------------------------------------------------
42
43async function requestTwoFactor (req: express.Request, res: express.Response) {
44 const user = res.locals.user
45
46 const { secret, uri } = generateOTPSecret(user.email)
47 const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, secret)
48
49 return res.json({
50 otpRequest: {
51 requestToken,
52 secret,
53 uri
54 }
55 } as TwoFactorEnableResult)
56}
57
58async function confirmRequestTwoFactor (req: express.Request, res: express.Response) {
59 const requestToken = req.body.requestToken
60 const otpToken = req.body.otpToken
61 const user = res.locals.user
62
63 const secret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
64 if (!secret) {
65 return res.fail({
66 message: 'Invalid request token',
67 status: HttpStatusCode.FORBIDDEN_403
68 })
69 }
70
71 if (isOTPValid({ secret, token: otpToken }) !== true) {
72 return res.fail({
73 message: 'Invalid OTP token',
74 status: HttpStatusCode.FORBIDDEN_403
75 })
76 }
77
78 user.otpSecret = secret
79 await user.save()
80
81 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
82}
83
84async function disableTwoFactor (req: express.Request, res: express.Response) {
85 const user = res.locals.user
86
87 user.otpSecret = null
88 await user.save()
89
90 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
91}
diff --git a/server/helpers/otp.ts b/server/helpers/otp.ts
new file mode 100644
index 000000000..a13edc5e2
--- /dev/null
+++ b/server/helpers/otp.ts
@@ -0,0 +1,54 @@
1import { Secret, TOTP } from 'otpauth'
2import { WEBSERVER } from '@server/initializers/constants'
3
4function isOTPValid (options: {
5 secret: string
6 token: string
7}) {
8 const { token, secret } = options
9
10 const totp = new TOTP({
11 ...baseOTPOptions(),
12
13 secret
14 })
15
16 const delta = totp.validate({
17 token,
18 window: 1
19 })
20
21 if (delta === null) return false
22
23 return true
24}
25
26function generateOTPSecret (email: string) {
27 const totp = new TOTP({
28 ...baseOTPOptions(),
29
30 label: email,
31 secret: new Secret()
32 })
33
34 return {
35 secret: totp.secret.base32,
36 uri: totp.toString()
37 }
38}
39
40export {
41 isOTPValid,
42 generateOTPSecret
43}
44
45// ---------------------------------------------------------------------------
46
47function baseOTPOptions () {
48 return {
49 issuer: WEBSERVER.HOST,
50 algorithm: 'SHA1',
51 digits: 6,
52 period: 30
53 }
54}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 9257ebf93..9d6087867 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -25,7 +25,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
25 25
26// --------------------------------------------------------------------------- 26// ---------------------------------------------------------------------------
27 27
28const LAST_MIGRATION_VERSION = 740 28const LAST_MIGRATION_VERSION = 745
29 29
30// --------------------------------------------------------------------------- 30// ---------------------------------------------------------------------------
31 31
@@ -640,6 +640,8 @@ const BCRYPT_SALT_SIZE = 10
640const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes 640const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes
641const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days 641const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
642 642
643const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
644
643const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes 645const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
644 646
645const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { 647const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
@@ -805,6 +807,10 @@ const REDUNDANCY = {
805} 807}
806 808
807const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) 809const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
810const OTP = {
811 HEADER_NAME: 'x-peertube-otp',
812 HEADER_REQUIRED_VALUE: 'required; app'
813}
808 814
809const ASSETS_PATH = { 815const ASSETS_PATH = {
810 DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'), 816 DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
@@ -986,6 +992,7 @@ export {
986 FOLLOW_STATES, 992 FOLLOW_STATES,
987 DEFAULT_USER_THEME_NAME, 993 DEFAULT_USER_THEME_NAME,
988 SERVER_ACTOR_NAME, 994 SERVER_ACTOR_NAME,
995 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
989 PLUGIN_GLOBAL_CSS_FILE_NAME, 996 PLUGIN_GLOBAL_CSS_FILE_NAME,
990 PLUGIN_GLOBAL_CSS_PATH, 997 PLUGIN_GLOBAL_CSS_PATH,
991 PRIVATE_RSA_KEY_SIZE, 998 PRIVATE_RSA_KEY_SIZE,
@@ -1041,6 +1048,7 @@ export {
1041 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME, 1048 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME,
1042 ASSETS_PATH, 1049 ASSETS_PATH,
1043 FILES_CONTENT_HASH, 1050 FILES_CONTENT_HASH,
1051 OTP,
1044 loadLanguages, 1052 loadLanguages,
1045 buildLanguages, 1053 buildLanguages,
1046 generateContentHash 1054 generateContentHash
diff --git a/server/initializers/migrations/0745-user-otp.ts b/server/initializers/migrations/0745-user-otp.ts
new file mode 100644
index 000000000..157308ea1
--- /dev/null
+++ b/server/initializers/migrations/0745-user-otp.ts
@@ -0,0 +1,29 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 const { transaction } = utils
10
11 const data = {
12 type: Sequelize.STRING,
13 defaultValue: null,
14 allowNull: true
15 }
16 await utils.queryInterface.addColumn('user', 'otpSecret', data, { transaction })
17
18}
19
20async function down (utils: {
21 queryInterface: Sequelize.QueryInterface
22 transaction: Sequelize.Transaction
23}) {
24}
25
26export {
27 up,
28 down
29}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index fa1887315..b541142a5 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -11,8 +11,20 @@ import OAuth2Server, {
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { MOAuthClient } from '@server/types/models' 12import { MOAuthClient } from '@server/types/models'
13import { sha1 } from '@shared/extra-utils' 13import { sha1 } from '@shared/extra-utils'
14import { OAUTH_LIFETIME } from '../../initializers/constants' 14import { HttpStatusCode } from '@shared/models'
15import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
15import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 16import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
17import { isOTPValid } from '@server/helpers/otp'
18
19class MissingTwoFactorError extends Error {
20 code = HttpStatusCode.UNAUTHORIZED_401
21 name = 'missing_two_factor'
22}
23
24class InvalidTwoFactorError extends Error {
25 code = HttpStatusCode.BAD_REQUEST_400
26 name = 'invalid_two_factor'
27}
16 28
17/** 29/**
18 * 30 *
@@ -94,6 +106,9 @@ function handleOAuthAuthenticate (
94} 106}
95 107
96export { 108export {
109 MissingTwoFactorError,
110 InvalidTwoFactorError,
111
97 handleOAuthToken, 112 handleOAuthToken,
98 handleOAuthAuthenticate 113 handleOAuthAuthenticate
99} 114}
@@ -118,6 +133,16 @@ async function handlePasswordGrant (options: {
118 const user = await getUser(request.body.username, request.body.password, bypassLogin) 133 const user = await getUser(request.body.username, request.body.password, bypassLogin)
119 if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid') 134 if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
120 135
136 if (user.otpSecret) {
137 if (!request.headers[OTP.HEADER_NAME]) {
138 throw new MissingTwoFactorError('Missing two factor header')
139 }
140
141 if (isOTPValid({ secret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
142 throw new InvalidTwoFactorError('Invalid two factor header')
143 }
144 }
145
121 const token = await buildToken() 146 const token = await buildToken()
122 147
123 return saveToken(token, client, user, { bypassLogin }) 148 return saveToken(token, client, user, { bypassLogin })
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index 9b3c72300..b7523492a 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -9,6 +9,7 @@ import {
9 CONTACT_FORM_LIFETIME, 9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME, 10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TRACKER_RATE_LIMITS, 11 TRACKER_RATE_LIMITS,
12 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
12 USER_EMAIL_VERIFY_LIFETIME, 13 USER_EMAIL_VERIFY_LIFETIME,
13 USER_PASSWORD_CREATE_LIFETIME, 14 USER_PASSWORD_CREATE_LIFETIME,
14 USER_PASSWORD_RESET_LIFETIME, 15 USER_PASSWORD_RESET_LIFETIME,
@@ -108,10 +109,24 @@ class Redis {
108 return this.removeValue(this.generateResetPasswordKey(userId)) 109 return this.removeValue(this.generateResetPasswordKey(userId))
109 } 110 }
110 111
111 async getResetPasswordLink (userId: number) { 112 async getResetPasswordVerificationString (userId: number) {
112 return this.getValue(this.generateResetPasswordKey(userId)) 113 return this.getValue(this.generateResetPasswordKey(userId))
113 } 114 }
114 115
116 /* ************ Two factor auth request ************ */
117
118 async setTwoFactorRequest (userId: number, otpSecret: string) {
119 const requestToken = await generateRandomString(32)
120
121 await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME)
122
123 return requestToken
124 }
125
126 async getTwoFactorRequestToken (userId: number, requestToken: string) {
127 return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken))
128 }
129
115 /* ************ Email verification ************ */ 130 /* ************ Email verification ************ */
116 131
117 async setVerifyEmailVerificationString (userId: number) { 132 async setVerifyEmailVerificationString (userId: number) {
@@ -342,6 +357,10 @@ class Redis {
342 return 'reset-password-' + userId 357 return 'reset-password-' + userId
343 } 358 }
344 359
360 private generateTwoFactorRequestKey (userId: number, token: string) {
361 return 'two-factor-request-' + userId + '-' + token
362 }
363
345 private generateVerifyEmailKey (userId: number) { 364 private generateVerifyEmailKey (userId: number) {
346 return 'verify-email-' + userId 365 return 'verify-email-' + userId
347 } 366 }
@@ -391,8 +410,8 @@ class Redis {
391 return JSON.parse(value) 410 return JSON.parse(value)
392 } 411 }
393 412
394 private setObject (key: string, value: { [ id: string ]: number | string }) { 413 private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) {
395 return this.setValue(key, JSON.stringify(value)) 414 return this.setValue(key, JSON.stringify(value), expirationMilliseconds)
396 } 415 }
397 416
398 private async setValue (key: string, value: string, expirationMilliseconds?: number) { 417 private async setValue (key: string, value: string, expirationMilliseconds?: number) {
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts
index bbd03b248..de98cd442 100644
--- a/server/middlewares/validators/shared/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -1,5 +1,6 @@
1export * from './abuses' 1export * from './abuses'
2export * from './accounts' 2export * from './accounts'
3export * from './users'
3export * from './utils' 4export * from './utils'
4export * from './video-blacklists' 5export * from './video-blacklists'
5export * from './video-captions' 6export * from './video-captions'
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts
new file mode 100644
index 000000000..fbaa7db0e
--- /dev/null
+++ b/server/middlewares/validators/shared/users.ts
@@ -0,0 +1,62 @@
1import express from 'express'
2import { ActorModel } from '@server/models/actor/actor'
3import { UserModel } from '@server/models/user/user'
4import { MUserDefault } from '@server/types/models'
5import { HttpStatusCode } from '@shared/models'
6
7function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
8 const id = parseInt(idArg + '', 10)
9 return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
10}
11
12function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
13 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
14}
15
16async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
17 const user = await UserModel.loadByUsernameOrEmail(username, email)
18
19 if (user) {
20 res.fail({
21 status: HttpStatusCode.CONFLICT_409,
22 message: 'User with this username or email already exists.'
23 })
24 return false
25 }
26
27 const actor = await ActorModel.loadLocalByName(username)
28 if (actor) {
29 res.fail({
30 status: HttpStatusCode.CONFLICT_409,
31 message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
32 })
33 return false
34 }
35
36 return true
37}
38
39async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
40 const user = await finder()
41
42 if (!user) {
43 if (abortResponse === true) {
44 res.fail({
45 status: HttpStatusCode.NOT_FOUND_404,
46 message: 'User not found'
47 })
48 }
49
50 return false
51 }
52
53 res.locals.user = user
54 return true
55}
56
57export {
58 checkUserIdExist,
59 checkUserEmailExist,
60 checkUserNameOrEmailDoesNotAlreadyExist,
61 checkUserExist
62}
diff --git a/server/middlewares/validators/two-factor.ts b/server/middlewares/validators/two-factor.ts
new file mode 100644
index 000000000..106b579b5
--- /dev/null
+++ b/server/middlewares/validators/two-factor.ts
@@ -0,0 +1,81 @@
1import express from 'express'
2import { body, param } from 'express-validator'
3import { HttpStatusCode, UserRight } from '@shared/models'
4import { exists, isIdValid } from '../../helpers/custom-validators/misc'
5import { areValidationErrors, checkUserIdExist } from './shared'
6
7const requestOrConfirmTwoFactorValidator = [
8 param('id').custom(isIdValid),
9
10 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
11 if (areValidationErrors(req, res)) return
12
13 if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
14
15 if (res.locals.user.otpSecret) {
16 return res.fail({
17 status: HttpStatusCode.BAD_REQUEST_400,
18 message: `Two factor is already enabled.`
19 })
20 }
21
22 return next()
23 }
24]
25
26const confirmTwoFactorValidator = [
27 body('requestToken').custom(exists),
28 body('otpToken').custom(exists),
29
30 (req: express.Request, res: express.Response, next: express.NextFunction) => {
31 if (areValidationErrors(req, res)) return
32
33 return next()
34 }
35]
36
37const disableTwoFactorValidator = [
38 param('id').custom(isIdValid),
39
40 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
41 if (areValidationErrors(req, res)) return
42
43 if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
44
45 if (!res.locals.user.otpSecret) {
46 return res.fail({
47 status: HttpStatusCode.BAD_REQUEST_400,
48 message: `Two factor is already disabled.`
49 })
50 }
51
52 return next()
53 }
54]
55
56// ---------------------------------------------------------------------------
57
58export {
59 requestOrConfirmTwoFactorValidator,
60 confirmTwoFactorValidator,
61 disableTwoFactorValidator
62}
63
64// ---------------------------------------------------------------------------
65
66async function checkCanEnableOrDisableTwoFactor (userId: number | string, res: express.Response) {
67 const authUser = res.locals.oauth.token.user
68
69 if (!await checkUserIdExist(userId, res)) return
70
71 if (res.locals.user.id !== authUser.id && authUser.hasRight(UserRight.MANAGE_USERS) !== true) {
72 res.fail({
73 status: HttpStatusCode.FORBIDDEN_403,
74 message: `User ${authUser.username} does not have right to change two factor setting of this user.`
75 })
76
77 return false
78 }
79
80 return true
81}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index eb693318f..046029547 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -1,9 +1,8 @@
1import express from 'express' 1import express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { Hooks } from '@server/lib/plugins/hooks' 3import { Hooks } from '@server/lib/plugins/hooks'
4import { MUserDefault } from '@server/types/models'
5import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' 4import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models'
6import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 5import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
7import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 6import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
8import { 7import {
9 isUserAdminFlagsValid, 8 isUserAdminFlagsValid,
@@ -30,8 +29,15 @@ import { isThemeRegistered } from '../../lib/plugins/theme-utils'
30import { Redis } from '../../lib/redis' 29import { Redis } from '../../lib/redis'
31import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' 30import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
32import { ActorModel } from '../../models/actor/actor' 31import { ActorModel } from '../../models/actor/actor'
33import { UserModel } from '../../models/user/user' 32import {
34import { areValidationErrors, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam } from './shared' 33 areValidationErrors,
34 checkUserEmailExist,
35 checkUserIdExist,
36 checkUserNameOrEmailDoesNotAlreadyExist,
37 doesVideoChannelIdExist,
38 doesVideoExist,
39 isValidVideoIdParam
40} from './shared'
35 41
36const usersListValidator = [ 42const usersListValidator = [
37 query('blocked') 43 query('blocked')
@@ -435,7 +441,7 @@ const usersResetPasswordValidator = [
435 if (!await checkUserIdExist(req.params.id, res)) return 441 if (!await checkUserIdExist(req.params.id, res)) return
436 442
437 const user = res.locals.user 443 const user = res.locals.user
438 const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) 444 const redisVerificationString = await Redis.Instance.getResetPasswordVerificationString(user.id)
439 445
440 if (redisVerificationString !== req.body.verificationString) { 446 if (redisVerificationString !== req.body.verificationString) {
441 return res.fail({ 447 return res.fail({
@@ -500,6 +506,24 @@ const usersVerifyEmailValidator = [
500 } 506 }
501] 507]
502 508
509const usersCheckCurrentPassword = [
510 body('currentPassword').custom(exists),
511
512 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
513 if (areValidationErrors(req, res)) return
514
515 const user = res.locals.oauth.token.User
516 if (await user.isPasswordMatch(req.body.currentPassword) !== true) {
517 return res.fail({
518 status: HttpStatusCode.FORBIDDEN_403,
519 message: 'currentPassword is invalid.'
520 })
521 }
522
523 return next()
524 }
525]
526
503const userAutocompleteValidator = [ 527const userAutocompleteValidator = [
504 param('search') 528 param('search')
505 .isString() 529 .isString()
@@ -567,6 +591,7 @@ export {
567 usersUpdateValidator, 591 usersUpdateValidator,
568 usersUpdateMeValidator, 592 usersUpdateMeValidator,
569 usersVideoRatingValidator, 593 usersVideoRatingValidator,
594 usersCheckCurrentPassword,
570 ensureUserRegistrationAllowed, 595 ensureUserRegistrationAllowed,
571 ensureUserRegistrationAllowedForIP, 596 ensureUserRegistrationAllowedForIP,
572 usersGetValidator, 597 usersGetValidator,
@@ -580,55 +605,3 @@ export {
580 ensureCanModerateUser, 605 ensureCanModerateUser,
581 ensureCanManageChannelOrAccount 606 ensureCanManageChannelOrAccount
582} 607}
583
584// ---------------------------------------------------------------------------
585
586function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
587 const id = parseInt(idArg + '', 10)
588 return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
589}
590
591function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
592 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
593}
594
595async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
596 const user = await UserModel.loadByUsernameOrEmail(username, email)
597
598 if (user) {
599 res.fail({
600 status: HttpStatusCode.CONFLICT_409,
601 message: 'User with this username or email already exists.'
602 })
603 return false
604 }
605
606 const actor = await ActorModel.loadLocalByName(username)
607 if (actor) {
608 res.fail({
609 status: HttpStatusCode.CONFLICT_409,
610 message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
611 })
612 return false
613 }
614
615 return true
616}
617
618async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
619 const user = await finder()
620
621 if (!user) {
622 if (abortResponse === true) {
623 res.fail({
624 status: HttpStatusCode.NOT_FOUND_404,
625 message: 'User not found'
626 })
627 }
628
629 return false
630 }
631
632 res.locals.user = user
633 return true
634}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 1a7c84390..34329580b 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -403,6 +403,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
403 @Column 403 @Column
404 lastLoginDate: Date 404 lastLoginDate: Date
405 405
406 @AllowNull(true)
407 @Default(null)
408 @Column
409 otpSecret: string
410
406 @CreatedAt 411 @CreatedAt
407 createdAt: Date 412 createdAt: Date
408 413
@@ -935,7 +940,9 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
935 940
936 pluginAuth: this.pluginAuth, 941 pluginAuth: this.pluginAuth,
937 942
938 lastLoginDate: this.lastLoginDate 943 lastLoginDate: this.lastLoginDate,
944
945 twoFactorEnabled: !!this.otpSecret
939 } 946 }
940 947
941 if (parameters.withAdminFlags) { 948 if (parameters.withAdminFlags) {
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index cd7a38459..33dc8fb76 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -2,6 +2,7 @@ import './abuses'
2import './accounts' 2import './accounts'
3import './blocklist' 3import './blocklist'
4import './bulk' 4import './bulk'
5import './channel-import-videos'
5import './config' 6import './config'
6import './contact-form' 7import './contact-form'
7import './custom-pages' 8import './custom-pages'
@@ -17,6 +18,7 @@ import './redundancy'
17import './search' 18import './search'
18import './services' 19import './services'
19import './transcoding' 20import './transcoding'
21import './two-factor'
20import './upload-quota' 22import './upload-quota'
21import './user-notifications' 23import './user-notifications'
22import './user-subscriptions' 24import './user-subscriptions'
@@ -24,12 +26,11 @@ import './users-admin'
24import './users' 26import './users'
25import './video-blacklist' 27import './video-blacklist'
26import './video-captions' 28import './video-captions'
29import './video-channel-syncs'
27import './video-channels' 30import './video-channels'
28import './video-comments' 31import './video-comments'
29import './video-files' 32import './video-files'
30import './video-imports' 33import './video-imports'
31import './video-channel-syncs'
32import './channel-import-videos'
33import './video-playlists' 34import './video-playlists'
34import './video-source' 35import './video-source'
35import './video-studio' 36import './video-studio'
diff --git a/server/tests/api/check-params/two-factor.ts b/server/tests/api/check-params/two-factor.ts
new file mode 100644
index 000000000..e7ca5490c
--- /dev/null
+++ b/server/tests/api/check-params/two-factor.ts
@@ -0,0 +1,275 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { HttpStatusCode } from '@shared/models'
4import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
5
6describe('Test two factor API validators', function () {
7 let server: PeerTubeServer
8
9 let rootId: number
10 let rootPassword: string
11 let rootRequestToken: string
12 let rootOTPToken: string
13
14 let userId: number
15 let userToken = ''
16 let userPassword: string
17 let userRequestToken: string
18 let userOTPToken: string
19
20 // ---------------------------------------------------------------
21
22 before(async function () {
23 this.timeout(30000)
24
25 {
26 server = await createSingleServer(1)
27 await setAccessTokensToServers([ server ])
28 }
29
30 {
31 const result = await server.users.generate('user1')
32 userToken = result.token
33 userId = result.userId
34 userPassword = result.password
35 }
36
37 {
38 const { id } = await server.users.getMyInfo()
39 rootId = id
40 rootPassword = server.store.user.password
41 }
42 })
43
44 describe('When requesting two factor', function () {
45
46 it('Should fail with an unknown user id', async function () {
47 await server.twoFactor.request({ userId: 42, currentPassword: rootPassword, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
48 })
49
50 it('Should fail with an invalid user id', async function () {
51 await server.twoFactor.request({
52 userId: 'invalid' as any,
53 currentPassword: rootPassword,
54 expectedStatus: HttpStatusCode.BAD_REQUEST_400
55 })
56 })
57
58 it('Should fail to request another user two factor without the appropriate rights', async function () {
59 await server.twoFactor.request({
60 userId: rootId,
61 token: userToken,
62 currentPassword: userPassword,
63 expectedStatus: HttpStatusCode.FORBIDDEN_403
64 })
65 })
66
67 it('Should succeed to request another user two factor with the appropriate rights', async function () {
68 await server.twoFactor.request({ userId, currentPassword: rootPassword })
69 })
70
71 it('Should fail to request two factor without a password', async function () {
72 await server.twoFactor.request({
73 userId,
74 token: userToken,
75 currentPassword: undefined,
76 expectedStatus: HttpStatusCode.BAD_REQUEST_400
77 })
78 })
79
80 it('Should fail to request two factor with an incorrect password', async function () {
81 await server.twoFactor.request({
82 userId,
83 token: userToken,
84 currentPassword: rootPassword,
85 expectedStatus: HttpStatusCode.FORBIDDEN_403
86 })
87 })
88
89 it('Should succeed to request my two factor auth', async function () {
90 {
91 const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword })
92 userRequestToken = otpRequest.requestToken
93 userOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
94 }
95
96 {
97 const { otpRequest } = await server.twoFactor.request({ userId: rootId, currentPassword: rootPassword })
98 rootRequestToken = otpRequest.requestToken
99 rootOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
100 }
101 })
102 })
103
104 describe('When confirming two factor request', function () {
105
106 it('Should fail with an unknown user id', async function () {
107 await server.twoFactor.confirmRequest({
108 userId: 42,
109 requestToken: rootRequestToken,
110 otpToken: rootOTPToken,
111 expectedStatus: HttpStatusCode.NOT_FOUND_404
112 })
113 })
114
115 it('Should fail with an invalid user id', async function () {
116 await server.twoFactor.confirmRequest({
117 userId: 'invalid' as any,
118 requestToken: rootRequestToken,
119 otpToken: rootOTPToken,
120 expectedStatus: HttpStatusCode.BAD_REQUEST_400
121 })
122 })
123
124 it('Should fail to confirm another user two factor request without the appropriate rights', async function () {
125 await server.twoFactor.confirmRequest({
126 userId: rootId,
127 token: userToken,
128 requestToken: rootRequestToken,
129 otpToken: rootOTPToken,
130 expectedStatus: HttpStatusCode.FORBIDDEN_403
131 })
132 })
133
134 it('Should fail without request token', async function () {
135 await server.twoFactor.confirmRequest({
136 userId,
137 requestToken: undefined,
138 otpToken: userOTPToken,
139 expectedStatus: HttpStatusCode.BAD_REQUEST_400
140 })
141 })
142
143 it('Should fail with an invalid request token', async function () {
144 await server.twoFactor.confirmRequest({
145 userId,
146 requestToken: 'toto',
147 otpToken: userOTPToken,
148 expectedStatus: HttpStatusCode.FORBIDDEN_403
149 })
150 })
151
152 it('Should fail with request token of another user', async function () {
153 await server.twoFactor.confirmRequest({
154 userId,
155 requestToken: rootRequestToken,
156 otpToken: userOTPToken,
157 expectedStatus: HttpStatusCode.FORBIDDEN_403
158 })
159 })
160
161 it('Should fail without an otp token', async function () {
162 await server.twoFactor.confirmRequest({
163 userId,
164 requestToken: userRequestToken,
165 otpToken: undefined,
166 expectedStatus: HttpStatusCode.BAD_REQUEST_400
167 })
168 })
169
170 it('Should fail with a bad otp token', async function () {
171 await server.twoFactor.confirmRequest({
172 userId,
173 requestToken: userRequestToken,
174 otpToken: '123456',
175 expectedStatus: HttpStatusCode.FORBIDDEN_403
176 })
177 })
178
179 it('Should succeed to confirm another user two factor request with the appropriate rights', async function () {
180 await server.twoFactor.confirmRequest({
181 userId,
182 requestToken: userRequestToken,
183 otpToken: userOTPToken
184 })
185
186 // Reinit
187 await server.twoFactor.disable({ userId, currentPassword: rootPassword })
188 })
189
190 it('Should succeed to confirm my two factor request', async function () {
191 await server.twoFactor.confirmRequest({
192 userId,
193 token: userToken,
194 requestToken: userRequestToken,
195 otpToken: userOTPToken
196 })
197 })
198
199 it('Should fail to confirm again two factor request', async function () {
200 await server.twoFactor.confirmRequest({
201 userId,
202 token: userToken,
203 requestToken: userRequestToken,
204 otpToken: userOTPToken,
205 expectedStatus: HttpStatusCode.BAD_REQUEST_400
206 })
207 })
208 })
209
210 describe('When disabling two factor', function () {
211
212 it('Should fail with an unknown user id', async function () {
213 await server.twoFactor.disable({
214 userId: 42,
215 currentPassword: rootPassword,
216 expectedStatus: HttpStatusCode.NOT_FOUND_404
217 })
218 })
219
220 it('Should fail with an invalid user id', async function () {
221 await server.twoFactor.disable({
222 userId: 'invalid' as any,
223 currentPassword: rootPassword,
224 expectedStatus: HttpStatusCode.BAD_REQUEST_400
225 })
226 })
227
228 it('Should fail to disable another user two factor without the appropriate rights', async function () {
229 await server.twoFactor.disable({
230 userId: rootId,
231 token: userToken,
232 currentPassword: userPassword,
233 expectedStatus: HttpStatusCode.FORBIDDEN_403
234 })
235 })
236
237 it('Should fail to disabled two factor with an incorrect password', async function () {
238 await server.twoFactor.disable({
239 userId,
240 token: userToken,
241 currentPassword: rootPassword,
242 expectedStatus: HttpStatusCode.FORBIDDEN_403
243 })
244 })
245
246 it('Should succeed to disable another user two factor with the appropriate rights', async function () {
247 await server.twoFactor.disable({ userId, currentPassword: rootPassword })
248
249 // Reinit
250 const { otpRequest } = await server.twoFactor.request({ userId, currentPassword: rootPassword })
251 await server.twoFactor.confirmRequest({
252 userId,
253 requestToken: otpRequest.requestToken,
254 otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
255 })
256 })
257
258 it('Should succeed to update my two factor auth', async function () {
259 await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword })
260 })
261
262 it('Should fail to disable again two factor', async function () {
263 await server.twoFactor.disable({
264 userId,
265 token: userToken,
266 currentPassword: userPassword,
267 expectedStatus: HttpStatusCode.BAD_REQUEST_400
268 })
269 })
270 })
271
272 after(async function () {
273 await cleanupTests([ server ])
274 })
275})
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index c65152c6f..643f1a531 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,3 +1,4 @@
1import './two-factor'
1import './user-subscriptions' 2import './user-subscriptions'
2import './user-videos' 3import './user-videos'
3import './users' 4import './users'
diff --git a/server/tests/api/users/two-factor.ts b/server/tests/api/users/two-factor.ts
new file mode 100644
index 000000000..450aac4dc
--- /dev/null
+++ b/server/tests/api/users/two-factor.ts
@@ -0,0 +1,153 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { expectStartWith } from '@server/tests/shared'
5import { HttpStatusCode } from '@shared/models'
6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
7
8async function login (options: {
9 server: PeerTubeServer
10 password?: string
11 otpToken?: string
12 expectedStatus?: HttpStatusCode
13}) {
14 const { server, password = server.store.user.password, otpToken, expectedStatus } = options
15
16 const user = { username: server.store.user.username, password }
17 const { res, body: { access_token: token } } = await server.login.loginAndGetResponse({ user, otpToken, expectedStatus })
18
19 return { res, token }
20}
21
22describe('Test users', function () {
23 let server: PeerTubeServer
24 let rootId: number
25 let otpSecret: string
26 let requestToken: string
27
28 before(async function () {
29 this.timeout(30000)
30
31 server = await createSingleServer(1)
32
33 await setAccessTokensToServers([ server ])
34
35 const { id } = await server.users.getMyInfo()
36 rootId = id
37 })
38
39 it('Should not add the header on login if two factor is not enabled', async function () {
40 const { res, token } = await login({ server })
41
42 expect(res.header['x-peertube-otp']).to.not.exist
43
44 await server.users.getMyInfo({ token })
45 })
46
47 it('Should request two factor and get the secret and uri', async function () {
48 const { otpRequest } = await server.twoFactor.request({
49 userId: rootId,
50 currentPassword: server.store.user.password
51 })
52
53 expect(otpRequest.requestToken).to.exist
54
55 expect(otpRequest.secret).to.exist
56 expect(otpRequest.secret).to.have.lengthOf(32)
57
58 expect(otpRequest.uri).to.exist
59 expectStartWith(otpRequest.uri, 'otpauth://')
60 expect(otpRequest.uri).to.include(otpRequest.secret)
61
62 requestToken = otpRequest.requestToken
63 otpSecret = otpRequest.secret
64 })
65
66 it('Should not have two factor confirmed yet', async function () {
67 const { twoFactorEnabled } = await server.users.getMyInfo()
68 expect(twoFactorEnabled).to.be.false
69 })
70
71 it('Should confirm two factor', async function () {
72 await server.twoFactor.confirmRequest({
73 userId: rootId,
74 otpToken: TwoFactorCommand.buildOTP({ secret: otpSecret }).generate(),
75 requestToken
76 })
77 })
78
79 it('Should not add the header on login if two factor is enabled and password is incorrect', async function () {
80 const { res, token } = await login({ server, password: 'fake', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
81
82 expect(res.header['x-peertube-otp']).to.not.exist
83 expect(token).to.not.exist
84 })
85
86 it('Should add the header on login if two factor is enabled and password is correct', async function () {
87 const { res, token } = await login({ server, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
88
89 expect(res.header['x-peertube-otp']).to.exist
90 expect(token).to.not.exist
91
92 await server.users.getMyInfo({ token })
93 })
94
95 it('Should not login with correct password and incorrect otp secret', async function () {
96 const otp = TwoFactorCommand.buildOTP({ secret: 'a'.repeat(32) })
97
98 const { res, token } = await login({ server, otpToken: otp.generate(), expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
99
100 expect(res.header['x-peertube-otp']).to.not.exist
101 expect(token).to.not.exist
102 })
103
104 it('Should not login with correct password and incorrect otp code', async function () {
105 const { res, token } = await login({ server, otpToken: '123456', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
106
107 expect(res.header['x-peertube-otp']).to.not.exist
108 expect(token).to.not.exist
109 })
110
111 it('Should not login with incorrect password and correct otp code', async function () {
112 const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
113
114 const { res, token } = await login({ server, password: 'fake', otpToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
115
116 expect(res.header['x-peertube-otp']).to.not.exist
117 expect(token).to.not.exist
118 })
119
120 it('Should correctly login with correct password and otp code', async function () {
121 const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
122
123 const { res, token } = await login({ server, otpToken })
124
125 expect(res.header['x-peertube-otp']).to.not.exist
126 expect(token).to.exist
127
128 await server.users.getMyInfo({ token })
129 })
130
131 it('Should have two factor enabled when getting my info', async function () {
132 const { twoFactorEnabled } = await server.users.getMyInfo()
133 expect(twoFactorEnabled).to.be.true
134 })
135
136 it('Should disable two factor and be able to login without otp token', async function () {
137 await server.twoFactor.disable({ userId: rootId, currentPassword: server.store.user.password })
138
139 const { res, token } = await login({ server })
140 expect(res.header['x-peertube-otp']).to.not.exist
141
142 await server.users.getMyInfo({ token })
143 })
144
145 it('Should have two factor disabled when getting my info', async function () {
146 const { twoFactorEnabled } = await server.users.getMyInfo()
147 expect(twoFactorEnabled).to.be.false
148 })
149
150 after(async function () {
151 await cleanupTests([ server ])
152 })
153})
diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts
index b25978587..32f7a441c 100644
--- a/shared/models/users/index.ts
+++ b/shared/models/users/index.ts
@@ -1,3 +1,4 @@
1export * from './two-factor-enable-result.model'
1export * from './user-create-result.model' 2export * from './user-create-result.model'
2export * from './user-create.model' 3export * from './user-create.model'
3export * from './user-flag.model' 4export * from './user-flag.model'
diff --git a/shared/models/users/two-factor-enable-result.model.ts b/shared/models/users/two-factor-enable-result.model.ts
new file mode 100644
index 000000000..1fc801f0a
--- /dev/null
+++ b/shared/models/users/two-factor-enable-result.model.ts
@@ -0,0 +1,7 @@
1export interface TwoFactorEnableResult {
2 otpRequest: {
3 requestToken: string
4 secret: string
5 uri: string
6 }
7}
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 63c5c8a92..7b6494ff8 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -62,6 +62,8 @@ export interface User {
62 pluginAuth: string | null 62 pluginAuth: string | null
63 63
64 lastLoginDate: Date | null 64 lastLoginDate: Date | null
65
66 twoFactorEnabled: boolean
65} 67}
66 68
67export interface MyUserSpecialPlaylist { 69export interface MyUserSpecialPlaylist {
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
index a8f8c1d84..7096faf21 100644
--- a/shared/server-commands/server/server.ts
+++ b/shared/server-commands/server/server.ts
@@ -13,7 +13,15 @@ import { AbusesCommand } from '../moderation'
13import { OverviewsCommand } from '../overviews' 13import { OverviewsCommand } from '../overviews'
14import { SearchCommand } from '../search' 14import { SearchCommand } from '../search'
15import { SocketIOCommand } from '../socket' 15import { SocketIOCommand } from '../socket'
16import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users' 16import {
17 AccountsCommand,
18 BlocklistCommand,
19 LoginCommand,
20 NotificationsCommand,
21 SubscriptionsCommand,
22 TwoFactorCommand,
23 UsersCommand
24} from '../users'
17import { 25import {
18 BlacklistCommand, 26 BlacklistCommand,
19 CaptionsCommand, 27 CaptionsCommand,
@@ -136,6 +144,7 @@ export class PeerTubeServer {
136 videos?: VideosCommand 144 videos?: VideosCommand
137 videoStats?: VideoStatsCommand 145 videoStats?: VideoStatsCommand
138 views?: ViewsCommand 146 views?: ViewsCommand
147 twoFactor?: TwoFactorCommand
139 148
140 constructor (options: { serverNumber: number } | { url: string }) { 149 constructor (options: { serverNumber: number } | { url: string }) {
141 if ((options as any).url) { 150 if ((options as any).url) {
@@ -417,5 +426,6 @@ export class PeerTubeServer {
417 this.videoStudio = new VideoStudioCommand(this) 426 this.videoStudio = new VideoStudioCommand(this)
418 this.videoStats = new VideoStatsCommand(this) 427 this.videoStats = new VideoStatsCommand(this)
419 this.views = new ViewsCommand(this) 428 this.views = new ViewsCommand(this)
429 this.twoFactor = new TwoFactorCommand(this)
420 } 430 }
421} 431}
diff --git a/shared/server-commands/users/index.ts b/shared/server-commands/users/index.ts
index f6f93b4d2..1afc02dc1 100644
--- a/shared/server-commands/users/index.ts
+++ b/shared/server-commands/users/index.ts
@@ -5,4 +5,5 @@ export * from './login'
5export * from './login-command' 5export * from './login-command'
6export * from './notifications-command' 6export * from './notifications-command'
7export * from './subscriptions-command' 7export * from './subscriptions-command'
8export * from './two-factor-command'
8export * from './users-command' 9export * from './users-command'
diff --git a/shared/server-commands/users/login-command.ts b/shared/server-commands/users/login-command.ts
index 54070e426..f2fc6d1c5 100644
--- a/shared/server-commands/users/login-command.ts
+++ b/shared/server-commands/users/login-command.ts
@@ -2,34 +2,27 @@ import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models'
2import { unwrapBody } from '../requests' 2import { unwrapBody } from '../requests'
3import { AbstractCommand, OverrideCommandOptions } from '../shared' 3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4 4
5type LoginOptions = OverrideCommandOptions & {
6 client?: { id?: string, secret?: string }
7 user?: { username: string, password?: string }
8 otpToken?: string
9}
10
5export class LoginCommand extends AbstractCommand { 11export class LoginCommand extends AbstractCommand {
6 12
7 login (options: OverrideCommandOptions & { 13 async login (options: LoginOptions = {}) {
8 client?: { id?: string, secret?: string } 14 const res = await this._login(options)
9 user?: { username: string, password?: string }
10 } = {}) {
11 const { client = this.server.store.client, user = this.server.store.user } = options
12 const path = '/api/v1/users/token'
13 15
14 const body = { 16 return this.unwrapLoginBody(res.body)
15 client_id: client.id, 17 }
16 client_secret: client.secret,
17 username: user.username,
18 password: user.password ?? 'password',
19 response_type: 'code',
20 grant_type: 'password',
21 scope: 'upload'
22 }
23 18
24 return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({ 19 async loginAndGetResponse (options: LoginOptions = {}) {
25 ...options, 20 const res = await this._login(options)
26 21
27 path, 22 return {
28 requestType: 'form', 23 res,
29 fields: body, 24 body: this.unwrapLoginBody(res.body)
30 implicitToken: false, 25 }
31 defaultExpectedStatus: HttpStatusCode.OK_200
32 }))
33 } 26 }
34 27
35 getAccessToken (arg1?: { username: string, password?: string }): Promise<string> 28 getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
@@ -129,4 +122,38 @@ export class LoginCommand extends AbstractCommand {
129 defaultExpectedStatus: HttpStatusCode.OK_200 122 defaultExpectedStatus: HttpStatusCode.OK_200
130 }) 123 })
131 } 124 }
125
126 private _login (options: LoginOptions) {
127 const { client = this.server.store.client, user = this.server.store.user, otpToken } = options
128 const path = '/api/v1/users/token'
129
130 const body = {
131 client_id: client.id,
132 client_secret: client.secret,
133 username: user.username,
134 password: user.password ?? 'password',
135 response_type: 'code',
136 grant_type: 'password',
137 scope: 'upload'
138 }
139
140 const headers = otpToken
141 ? { 'x-peertube-otp': otpToken }
142 : {}
143
144 return this.postBodyRequest({
145 ...options,
146
147 path,
148 headers,
149 requestType: 'form',
150 fields: body,
151 implicitToken: false,
152 defaultExpectedStatus: HttpStatusCode.OK_200
153 })
154 }
155
156 private unwrapLoginBody (body: any) {
157 return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument
158 }
132} 159}
diff --git a/shared/server-commands/users/two-factor-command.ts b/shared/server-commands/users/two-factor-command.ts
new file mode 100644
index 000000000..6c9d270ae
--- /dev/null
+++ b/shared/server-commands/users/two-factor-command.ts
@@ -0,0 +1,75 @@
1import { TOTP } from 'otpauth'
2import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
3import { unwrapBody } from '../requests'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class TwoFactorCommand extends AbstractCommand {
7
8 static buildOTP (options: {
9 secret: string
10 }) {
11 const { secret } = options
12
13 return new TOTP({
14 issuer: 'PeerTube',
15 algorithm: 'SHA1',
16 digits: 6,
17 period: 30,
18 secret
19 })
20 }
21
22 request (options: OverrideCommandOptions & {
23 userId: number
24 currentPassword: string
25 }) {
26 const { currentPassword, userId } = options
27
28 const path = '/api/v1/users/' + userId + '/two-factor/request'
29
30 return unwrapBody<TwoFactorEnableResult>(this.postBodyRequest({
31 ...options,
32
33 path,
34 fields: { currentPassword },
35 implicitToken: true,
36 defaultExpectedStatus: HttpStatusCode.OK_200
37 }))
38 }
39
40 confirmRequest (options: OverrideCommandOptions & {
41 userId: number
42 requestToken: string
43 otpToken: string
44 }) {
45 const { userId, requestToken, otpToken } = options
46
47 const path = '/api/v1/users/' + userId + '/two-factor/confirm-request'
48
49 return this.postBodyRequest({
50 ...options,
51
52 path,
53 fields: { requestToken, otpToken },
54 implicitToken: true,
55 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
56 })
57 }
58
59 disable (options: OverrideCommandOptions & {
60 userId: number
61 currentPassword: string
62 }) {
63 const { userId, currentPassword } = options
64 const path = '/api/v1/users/' + userId + '/two-factor/disable'
65
66 return this.postBodyRequest({
67 ...options,
68
69 path,
70 fields: { currentPassword },
71 implicitToken: true,
72 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
73 })
74 }
75}
diff --git a/shared/server-commands/users/users-command.ts b/shared/server-commands/users/users-command.ts
index e7d021059..811b9685b 100644
--- a/shared/server-commands/users/users-command.ts
+++ b/shared/server-commands/users/users-command.ts
@@ -202,7 +202,8 @@ export class UsersCommand extends AbstractCommand {
202 token, 202 token,
203 userId: user.id, 203 userId: user.id,
204 userChannelId: me.videoChannels[0].id, 204 userChannelId: me.videoChannels[0].id,
205 userChannelName: me.videoChannels[0].name 205 userChannelName: me.videoChannels[0].name,
206 password
206 } 207 }
207 } 208 }
208 209
diff --git a/yarn.lock b/yarn.lock
index 60fe262fa..8ccc4fd0d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5945,6 +5945,11 @@ jsprim@^1.2.2:
5945 json-schema "0.4.0" 5945 json-schema "0.4.0"
5946 verror "1.10.0" 5946 verror "1.10.0"
5947 5947
5948jssha@~3.2.0:
5949 version "3.2.0"
5950 resolved "https://registry.yarnpkg.com/jssha/-/jssha-3.2.0.tgz#88ec50b866dd1411deaddbe6b3e3692e4c710f16"
5951 integrity sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==
5952
5948jstransformer@1.0.0: 5953jstransformer@1.0.0:
5949 version "1.0.0" 5954 version "1.0.0"
5950 resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" 5955 resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3"
@@ -7007,6 +7012,13 @@ os-tmpdir@~1.0.2:
7007 resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" 7012 resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
7008 integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== 7013 integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
7009 7014
7015otpauth@^8.0.3:
7016 version "8.0.3"
7017 resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-8.0.3.tgz#fdbcb24503e93dd7d930a8651f2dc9f8f7ff9c1b"
7018 integrity sha512-5abBweT/POpMdVuM0Zk/tvlTHw8Kc8606XX/w8QNLRBDib+FVpseAx12Z21/iVIeCrJOgCY1dBuLS057IOdybw==
7019 dependencies:
7020 jssha "~3.2.0"
7021
7010p-cancelable@^2.0.0: 7022p-cancelable@^2.0.0:
7011 version "2.1.1" 7023 version "2.1.1"
7012 resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" 7024 resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"