diff options
author | Chocobozzz <me@florianbigard.com> | 2022-10-05 15:37:15 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-10-07 10:51:16 +0200 |
commit | 56f47830758ff8e92abcfcc5f35d474ab12fe215 (patch) | |
tree | 854e57ec1b800d6ad740c8e42bee00cbd21e1724 | |
parent | 7dd7ff4cebc290b09fe00d82046bb58e4e8a800d (diff) | |
download | PeerTube-56f47830758ff8e92abcfcc5f35d474ab12fe215.tar.gz PeerTube-56f47830758ff8e92abcfcc5f35d474ab12fe215.tar.zst PeerTube-56f47830758ff8e92abcfcc5f35d474ab12fe215.zip |
Support two factor authentication in backend
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' | |||
51 | import { myNotificationsRouter } from './my-notifications' | 51 | import { myNotificationsRouter } from './my-notifications' |
52 | import { mySubscriptionsRouter } from './my-subscriptions' | 52 | import { mySubscriptionsRouter } from './my-subscriptions' |
53 | import { myVideoPlaylistsRouter } from './my-video-playlists' | 53 | import { myVideoPlaylistsRouter } from './my-video-playlists' |
54 | import { twoFactorRouter } from './two-factor' | ||
54 | 55 | ||
55 | const auditLogger = auditLoggerFactory('users') | 56 | const auditLogger = auditLoggerFactory('users') |
56 | 57 | ||
@@ -66,6 +67,7 @@ const askSendEmailLimiter = buildRateLimiter({ | |||
66 | }) | 67 | }) |
67 | 68 | ||
68 | const usersRouter = express.Router() | 69 | const usersRouter = express.Router() |
70 | usersRouter.use('/', twoFactorRouter) | ||
69 | usersRouter.use('/', tokensRouter) | 71 | usersRouter.use('/', tokensRouter) |
70 | usersRouter.use('/', myNotificationsRouter) | 72 | usersRouter.use('/', myNotificationsRouter) |
71 | usersRouter.use('/', mySubscriptionsRouter) | 73 | usersRouter.use('/', mySubscriptionsRouter) |
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts index 012a49791..c6afea67c 100644 --- a/server/controllers/api/users/token.ts +++ b/server/controllers/api/users/token.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { logger } from '@server/helpers/logger' | 2 | import { logger } from '@server/helpers/logger' |
3 | import { CONFIG } from '@server/initializers/config' | 3 | import { CONFIG } from '@server/initializers/config' |
4 | import { OTP } from '@server/initializers/constants' | ||
4 | import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' | 5 | import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' |
5 | import { handleOAuthToken } from '@server/lib/auth/oauth' | 6 | import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth' |
6 | import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' | 7 | import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' |
7 | import { Hooks } from '@server/lib/plugins/hooks' | 8 | import { Hooks } from '@server/lib/plugins/hooks' |
8 | import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares' | 9 | import { 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 @@ | |||
1 | import express from 'express' | ||
2 | import { generateOTPSecret, isOTPValid } from '@server/helpers/otp' | ||
3 | import { Redis } from '@server/lib/redis' | ||
4 | import { asyncMiddleware, authenticate, usersCheckCurrentPassword } from '@server/middlewares' | ||
5 | import { | ||
6 | confirmTwoFactorValidator, | ||
7 | disableTwoFactorValidator, | ||
8 | requestOrConfirmTwoFactorValidator | ||
9 | } from '@server/middlewares/validators/two-factor' | ||
10 | import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models' | ||
11 | |||
12 | const twoFactorRouter = express.Router() | ||
13 | |||
14 | twoFactorRouter.post('/:id/two-factor/request', | ||
15 | authenticate, | ||
16 | asyncMiddleware(usersCheckCurrentPassword), | ||
17 | asyncMiddleware(requestOrConfirmTwoFactorValidator), | ||
18 | asyncMiddleware(requestTwoFactor) | ||
19 | ) | ||
20 | |||
21 | twoFactorRouter.post('/:id/two-factor/confirm-request', | ||
22 | authenticate, | ||
23 | asyncMiddleware(requestOrConfirmTwoFactorValidator), | ||
24 | confirmTwoFactorValidator, | ||
25 | asyncMiddleware(confirmRequestTwoFactor) | ||
26 | ) | ||
27 | |||
28 | twoFactorRouter.post('/:id/two-factor/disable', | ||
29 | authenticate, | ||
30 | asyncMiddleware(usersCheckCurrentPassword), | ||
31 | asyncMiddleware(disableTwoFactorValidator), | ||
32 | asyncMiddleware(disableTwoFactor) | ||
33 | ) | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | export { | ||
38 | twoFactorRouter | ||
39 | } | ||
40 | |||
41 | // --------------------------------------------------------------------------- | ||
42 | |||
43 | async 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 | |||
58 | async 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 | |||
84 | async 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 @@ | |||
1 | import { Secret, TOTP } from 'otpauth' | ||
2 | import { WEBSERVER } from '@server/initializers/constants' | ||
3 | |||
4 | function 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 | |||
26 | function 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 | |||
40 | export { | ||
41 | isOTPValid, | ||
42 | generateOTPSecret | ||
43 | } | ||
44 | |||
45 | // --------------------------------------------------------------------------- | ||
46 | |||
47 | function 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 | ||
28 | const LAST_MIGRATION_VERSION = 740 | 28 | const LAST_MIGRATION_VERSION = 745 |
29 | 29 | ||
30 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
31 | 31 | ||
@@ -640,6 +640,8 @@ const BCRYPT_SALT_SIZE = 10 | |||
640 | const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes | 640 | const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes |
641 | const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days | 641 | const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days |
642 | 642 | ||
643 | const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes | ||
644 | |||
643 | const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes | 645 | const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes |
644 | 646 | ||
645 | const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { | 647 | const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { |
@@ -805,6 +807,10 @@ const REDUNDANCY = { | |||
805 | } | 807 | } |
806 | 808 | ||
807 | const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) | 809 | const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) |
810 | const OTP = { | ||
811 | HEADER_NAME: 'x-peertube-otp', | ||
812 | HEADER_REQUIRED_VALUE: 'required; app' | ||
813 | } | ||
808 | 814 | ||
809 | const ASSETS_PATH = { | 815 | const 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async 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 | |||
20 | async function down (utils: { | ||
21 | queryInterface: Sequelize.QueryInterface | ||
22 | transaction: Sequelize.Transaction | ||
23 | }) { | ||
24 | } | ||
25 | |||
26 | export { | ||
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, { | |||
11 | import { randomBytesPromise } from '@server/helpers/core-utils' | 11 | import { randomBytesPromise } from '@server/helpers/core-utils' |
12 | import { MOAuthClient } from '@server/types/models' | 12 | import { MOAuthClient } from '@server/types/models' |
13 | import { sha1 } from '@shared/extra-utils' | 13 | import { sha1 } from '@shared/extra-utils' |
14 | import { OAUTH_LIFETIME } from '../../initializers/constants' | 14 | import { HttpStatusCode } from '@shared/models' |
15 | import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' | ||
15 | import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' | 16 | import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' |
17 | import { isOTPValid } from '@server/helpers/otp' | ||
18 | |||
19 | class MissingTwoFactorError extends Error { | ||
20 | code = HttpStatusCode.UNAUTHORIZED_401 | ||
21 | name = 'missing_two_factor' | ||
22 | } | ||
23 | |||
24 | class 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 | ||
96 | export { | 108 | export { |
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 @@ | |||
1 | export * from './abuses' | 1 | export * from './abuses' |
2 | export * from './accounts' | 2 | export * from './accounts' |
3 | export * from './users' | ||
3 | export * from './utils' | 4 | export * from './utils' |
4 | export * from './video-blacklists' | 5 | export * from './video-blacklists' |
5 | export * from './video-captions' | 6 | export * from './video-captions' |
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts new file mode 100644 index 000000000..fbaa7db0e --- /dev/null +++ b/server/middlewares/validators/shared/users.ts | |||
@@ -0,0 +1,62 @@ | |||
1 | import express from 'express' | ||
2 | import { ActorModel } from '@server/models/actor/actor' | ||
3 | import { UserModel } from '@server/models/user/user' | ||
4 | import { MUserDefault } from '@server/types/models' | ||
5 | import { HttpStatusCode } from '@shared/models' | ||
6 | |||
7 | function 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 | |||
12 | function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { | ||
13 | return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) | ||
14 | } | ||
15 | |||
16 | async 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 | |||
39 | async 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 | |||
57 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { body, param } from 'express-validator' | ||
3 | import { HttpStatusCode, UserRight } from '@shared/models' | ||
4 | import { exists, isIdValid } from '../../helpers/custom-validators/misc' | ||
5 | import { areValidationErrors, checkUserIdExist } from './shared' | ||
6 | |||
7 | const 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 | |||
26 | const 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 | |||
37 | const 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 | |||
58 | export { | ||
59 | requestOrConfirmTwoFactorValidator, | ||
60 | confirmTwoFactorValidator, | ||
61 | disableTwoFactorValidator | ||
62 | } | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | async 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { body, param, query } from 'express-validator' | 2 | import { body, param, query } from 'express-validator' |
3 | import { Hooks } from '@server/lib/plugins/hooks' | 3 | import { Hooks } from '@server/lib/plugins/hooks' |
4 | import { MUserDefault } from '@server/types/models' | ||
5 | import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' | 4 | import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' |
6 | import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' | 5 | import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' |
7 | import { isThemeNameValid } from '../../helpers/custom-validators/plugins' | 6 | import { isThemeNameValid } from '../../helpers/custom-validators/plugins' |
8 | import { | 7 | import { |
9 | isUserAdminFlagsValid, | 8 | isUserAdminFlagsValid, |
@@ -30,8 +29,15 @@ import { isThemeRegistered } from '../../lib/plugins/theme-utils' | |||
30 | import { Redis } from '../../lib/redis' | 29 | import { Redis } from '../../lib/redis' |
31 | import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' | 30 | import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' |
32 | import { ActorModel } from '../../models/actor/actor' | 31 | import { ActorModel } from '../../models/actor/actor' |
33 | import { UserModel } from '../../models/user/user' | 32 | import { |
34 | import { 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 | ||
36 | const usersListValidator = [ | 42 | const 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 | ||
509 | const 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 | |||
503 | const userAutocompleteValidator = [ | 527 | const 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 | |||
586 | function 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 | |||
591 | function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { | ||
592 | return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) | ||
593 | } | ||
594 | |||
595 | async 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 | |||
618 | async 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' | |||
2 | import './accounts' | 2 | import './accounts' |
3 | import './blocklist' | 3 | import './blocklist' |
4 | import './bulk' | 4 | import './bulk' |
5 | import './channel-import-videos' | ||
5 | import './config' | 6 | import './config' |
6 | import './contact-form' | 7 | import './contact-form' |
7 | import './custom-pages' | 8 | import './custom-pages' |
@@ -17,6 +18,7 @@ import './redundancy' | |||
17 | import './search' | 18 | import './search' |
18 | import './services' | 19 | import './services' |
19 | import './transcoding' | 20 | import './transcoding' |
21 | import './two-factor' | ||
20 | import './upload-quota' | 22 | import './upload-quota' |
21 | import './user-notifications' | 23 | import './user-notifications' |
22 | import './user-subscriptions' | 24 | import './user-subscriptions' |
@@ -24,12 +26,11 @@ import './users-admin' | |||
24 | import './users' | 26 | import './users' |
25 | import './video-blacklist' | 27 | import './video-blacklist' |
26 | import './video-captions' | 28 | import './video-captions' |
29 | import './video-channel-syncs' | ||
27 | import './video-channels' | 30 | import './video-channels' |
28 | import './video-comments' | 31 | import './video-comments' |
29 | import './video-files' | 32 | import './video-files' |
30 | import './video-imports' | 33 | import './video-imports' |
31 | import './video-channel-syncs' | ||
32 | import './channel-import-videos' | ||
33 | import './video-playlists' | 34 | import './video-playlists' |
34 | import './video-source' | 35 | import './video-source' |
35 | import './video-studio' | 36 | import './video-studio' |
diff --git a/server/tests/api/check-params/two-factor.ts b/server/tests/api/check-params/two-factor.ts new file mode 100644 index 000000000..e7ca5490c --- /dev/null +++ b/server/tests/api/check-params/two-factor.ts | |||
@@ -0,0 +1,275 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { HttpStatusCode } from '@shared/models' | ||
4 | import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands' | ||
5 | |||
6 | describe('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 @@ | |||
1 | import './two-factor' | ||
1 | import './user-subscriptions' | 2 | import './user-subscriptions' |
2 | import './user-videos' | 3 | import './user-videos' |
3 | import './users' | 4 | import './users' |
diff --git a/server/tests/api/users/two-factor.ts b/server/tests/api/users/two-factor.ts new file mode 100644 index 000000000..450aac4dc --- /dev/null +++ b/server/tests/api/users/two-factor.ts | |||
@@ -0,0 +1,153 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { expect } from 'chai' | ||
4 | import { expectStartWith } from '@server/tests/shared' | ||
5 | import { HttpStatusCode } from '@shared/models' | ||
6 | import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands' | ||
7 | |||
8 | async 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 | |||
22 | describe('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 @@ | |||
1 | export * from './two-factor-enable-result.model' | ||
1 | export * from './user-create-result.model' | 2 | export * from './user-create-result.model' |
2 | export * from './user-create.model' | 3 | export * from './user-create.model' |
3 | export * from './user-flag.model' | 4 | export * from './user-flag.model' |
diff --git a/shared/models/users/two-factor-enable-result.model.ts b/shared/models/users/two-factor-enable-result.model.ts new file mode 100644 index 000000000..1fc801f0a --- /dev/null +++ b/shared/models/users/two-factor-enable-result.model.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | export 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 | ||
67 | export interface MyUserSpecialPlaylist { | 69 | export interface MyUserSpecialPlaylist { |
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index a8f8c1d84..7096faf21 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts | |||
@@ -13,7 +13,15 @@ import { AbusesCommand } from '../moderation' | |||
13 | import { OverviewsCommand } from '../overviews' | 13 | import { OverviewsCommand } from '../overviews' |
14 | import { SearchCommand } from '../search' | 14 | import { SearchCommand } from '../search' |
15 | import { SocketIOCommand } from '../socket' | 15 | import { SocketIOCommand } from '../socket' |
16 | import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users' | 16 | import { |
17 | AccountsCommand, | ||
18 | BlocklistCommand, | ||
19 | LoginCommand, | ||
20 | NotificationsCommand, | ||
21 | SubscriptionsCommand, | ||
22 | TwoFactorCommand, | ||
23 | UsersCommand | ||
24 | } from '../users' | ||
17 | import { | 25 | import { |
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' | |||
5 | export * from './login-command' | 5 | export * from './login-command' |
6 | export * from './notifications-command' | 6 | export * from './notifications-command' |
7 | export * from './subscriptions-command' | 7 | export * from './subscriptions-command' |
8 | export * from './two-factor-command' | ||
8 | export * from './users-command' | 9 | export * from './users-command' |
diff --git a/shared/server-commands/users/login-command.ts b/shared/server-commands/users/login-command.ts index 54070e426..f2fc6d1c5 100644 --- a/shared/server-commands/users/login-command.ts +++ b/shared/server-commands/users/login-command.ts | |||
@@ -2,34 +2,27 @@ import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models' | |||
2 | import { unwrapBody } from '../requests' | 2 | import { unwrapBody } from '../requests' |
3 | import { AbstractCommand, OverrideCommandOptions } from '../shared' | 3 | import { AbstractCommand, OverrideCommandOptions } from '../shared' |
4 | 4 | ||
5 | type LoginOptions = OverrideCommandOptions & { | ||
6 | client?: { id?: string, secret?: string } | ||
7 | user?: { username: string, password?: string } | ||
8 | otpToken?: string | ||
9 | } | ||
10 | |||
5 | export class LoginCommand extends AbstractCommand { | 11 | export 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 @@ | |||
1 | import { TOTP } from 'otpauth' | ||
2 | import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models' | ||
3 | import { unwrapBody } from '../requests' | ||
4 | import { AbstractCommand, OverrideCommandOptions } from '../shared' | ||
5 | |||
6 | export 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 | ||
@@ -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 | ||
5948 | jssha@~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 | |||
5948 | jstransformer@1.0.0: | 5953 | jstransformer@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 | ||
7015 | otpauth@^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 | |||
7010 | p-cancelable@^2.0.0: | 7022 | p-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" |