diff --git a/.eslintrc.json b/.eslintrc.json index 8160753bd..9b578b186 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -63,6 +63,13 @@ "allowNumber": "true" } ], + "@typescript-eslint/no-this-alias": [ + "error", + { + "allowDestructuring": true, // Allow `const { props, state } = this`; false by default + "allowedNames": ["self"] // Allow `const self = this`; `[]` by default + } + ], "@typescript-eslint/return-await": "off", "@typescript-eslint/no-base-to-string": "off", diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 98eb2beed..b30f42b43 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -17,7 +17,6 @@ import { paginationValidator, setDefaultPagination, setDefaultSort, - token, userAutocompleteValidator, usersAddValidator, usersGetValidator, @@ -50,6 +49,7 @@ import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model' import { UserRegister } from '../../../../shared/models/users/user-register.model' import { MUser, MUserAccountDefault } from '@server/typings/models' import { Hooks } from '@server/lib/plugins/hooks' +import { handleIdAndPassLogin } from '@server/lib/auth' const auditLogger = auditLoggerFactory('users') @@ -170,7 +170,17 @@ usersRouter.post('/:id/verify-email', usersRouter.post('/token', loginRateLimiter, - token, + handleIdAndPassLogin, + tokenSuccess +) +usersRouter.post('/token', + loginRateLimiter, + handleIdAndPassLogin, + tokenSuccess +) +usersRouter.post('/revoke-token', + loginRateLimiter, + handleIdAndPassLogin, tokenSuccess ) // TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route diff --git a/server/lib/auth.ts b/server/lib/auth.ts new file mode 100644 index 000000000..18d52fa5a --- /dev/null +++ b/server/lib/auth.ts @@ -0,0 +1,101 @@ +import * as express from 'express' +import { OAUTH_LIFETIME } from '@server/initializers/constants' +import * as OAuthServer from 'express-oauth-server' +import { PluginManager } from '@server/lib/plugins/plugin-manager' +import { RegisterServerAuthPassOptions } from '@shared/models/plugins/register-server-auth.model' +import { logger } from '@server/helpers/logger' +import { UserRole } from '@shared/models' + +const oAuthServer = new OAuthServer({ + useErrorHandler: true, + accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, + refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, + continueMiddleware: true, + model: require('./oauth-model') +}) + +function onExternalAuthPlugin (npmName: string, username: string, email: string) { + +} + +async function handleIdAndPassLogin (req: express.Request, res: express.Response, next: express.NextFunction) { + const plugins = PluginManager.Instance.getIdAndPassAuths() + const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] + + for (const plugin of plugins) { + const auths = plugin.idAndPassAuths + + for (const auth of auths) { + pluginAuths.push({ + npmName: plugin.npmName, + registerAuthOptions: auth + }) + } + } + + pluginAuths.sort((a, b) => { + const aWeight = a.registerAuthOptions.getWeight() + const bWeight = b.registerAuthOptions.getWeight() + + if (aWeight === bWeight) return 0 + if (aWeight > bWeight) return 1 + return -1 + }) + + const loginOptions = { + id: req.body.username, + password: req.body.password + } + + for (const pluginAuth of pluginAuths) { + logger.debug( + 'Using auth method of %s to login %s with weight %d.', + pluginAuth.npmName, loginOptions.id, pluginAuth.registerAuthOptions.getWeight() + ) + + const loginResult = await pluginAuth.registerAuthOptions.login(loginOptions) + if (loginResult) { + logger.info('Login success with plugin %s for %s.', pluginAuth.npmName, loginOptions.id) + + res.locals.bypassLogin = { + bypass: true, + pluginName: pluginAuth.npmName, + user: { + username: loginResult.username, + email: loginResult.email, + role: loginResult.role || UserRole.USER, + displayName: loginResult.displayName || loginResult.username + } + } + + break + } + } + + return localLogin(req, res, next) +} + +// --------------------------------------------------------------------------- + +export { + oAuthServer, + handleIdAndPassLogin, + onExternalAuthPlugin +} + +// --------------------------------------------------------------------------- + +function localLogin (req: express.Request, res: express.Response, next: express.NextFunction) { + return oAuthServer.token()(req, res, err => { + if (err) { + return res.status(err.status) + .json({ + error: err.message, + code: err.name + }) + .end() + } + + return next() + }) +} diff --git a/server/lib/oauth-model.ts b/server/lib/oauth-model.ts index 086856f41..ea4a67802 100644 --- a/server/lib/oauth-model.ts +++ b/server/lib/oauth-model.ts @@ -1,4 +1,5 @@ import * as Bluebird from 'bluebird' +import * as express from 'express' import { AccessDeniedError } from 'oauth2-server' import { logger } from '../helpers/logger' import { UserModel } from '../models/account/user' @@ -9,6 +10,10 @@ import { Transaction } from 'sequelize' import { CONFIG } from '../initializers/config' import * as LRUCache from 'lru-cache' import { MOAuthTokenUser } from '@server/typings/models/oauth/oauth-token' +import { MUser } from '@server/typings/models/user/user' +import { UserAdminFlag } from '@shared/models/users/user-flag.model' +import { createUserAccountAndChannelAndPlaylist } from './user' +import { UserRole } from '@shared/models/users/user-role' type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date } @@ -49,14 +54,14 @@ function getAccessToken (bearerToken: string) { if (accessTokenCache.has(bearerToken)) return Bluebird.resolve(accessTokenCache.get(bearerToken)) return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken) - .then(tokenModel => { - if (tokenModel) { - accessTokenCache.set(bearerToken, tokenModel) - userHavingToken.set(tokenModel.userId, tokenModel.accessToken) - } + .then(tokenModel => { + if (tokenModel) { + accessTokenCache.set(bearerToken, tokenModel) + userHavingToken.set(tokenModel.userId, tokenModel.accessToken) + } - return tokenModel - }) + return tokenModel + }) } function getClient (clientId: string, clientSecret: string) { @@ -72,6 +77,20 @@ function getRefreshToken (refreshToken: string) { } async function getUser (usernameOrEmail: string, password: string) { + const res: express.Response = this.request.res + if (res.locals.bypassLogin && res.locals.bypassLogin.bypass === true) { + const obj = res.locals.bypassLogin + logger.info('Bypassing oauth login by plugin %s.', obj.pluginName) + + let user = await UserModel.loadByEmail(obj.user.username) + if (!user) user = await createUserFromExternal(obj.pluginName, obj.user) + + // This user does not belong to this plugin, skip it + if (user.pluginAuth !== obj.pluginName) return null + + return user + } + logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).') const user = await UserModel.loadByUsernameOrEmail(usernameOrEmail) @@ -96,19 +115,11 @@ async function revokeToken (tokenInfo: TokenInfo) { token.destroy() .catch(err => logger.error('Cannot destroy token when revoking token.', { err })) + + return true } - /* - * Thanks to https://github.com/manjeshpv/node-oauth2-server-implementation/blob/master/components/oauth/mongo-models.js - * "As per the discussion we need set older date - * revokeToken will expected return a boolean in future version - * https://github.com/oauthjs/node-oauth2-server/pull/274 - * https://github.com/oauthjs/node-oauth2-server/issues/290" - */ - const expiredToken = token - expiredToken.refreshTokenExpiresAt = new Date('2015-05-28T06:59:53.000Z') - - return expiredToken + return false } async function saveToken (token: TokenInfo, client: OAuthClientModel, user: UserModel) { @@ -141,3 +152,30 @@ export { revokeToken, saveToken } + +async function createUserFromExternal (pluginAuth: string, options: { + username: string + email: string + role: UserRole + displayName: string +}) { + const userToCreate = new UserModel({ + username: options.username, + password: null, + email: options.email, + nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, + autoPlayVideo: true, + role: options.role, + videoQuota: CONFIG.USER.VIDEO_QUOTA, + videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY, + adminFlags: UserAdminFlag.NONE, + pluginAuth + }) as MUser + + const { user } = await createUserAccountAndChannelAndPlaylist({ + userToCreate, + userDisplayName: options.displayName + }) + + return user +} diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts index 37fb07716..f78b989f5 100644 --- a/server/lib/plugins/plugin-manager.ts +++ b/server/lib/plugins/plugin-manager.ts @@ -39,6 +39,7 @@ export interface RegisteredPlugin { css: string[] // Only if this is a plugin + registerHelpersStore?: RegisterHelpersStore unregister?: Function } @@ -58,11 +59,10 @@ export class PluginManager implements ServerHook { private static instance: PluginManager private registeredPlugins: { [name: string]: RegisteredPlugin } = {} + private hooks: { [name: string]: HookInformationValue[] } = {} private translations: PluginLocalesTranslations = {} - private registerHelpersStore: { [npmName: string]: RegisterHelpersStore } = {} - private constructor () { } @@ -102,18 +102,30 @@ export class PluginManager implements ServerHook { return this.getRegisteredPluginsOrThemes(PluginType.THEME) } - getRegisteredSettings (npmName: string) { - const store = this.registerHelpersStore[npmName] - if (store) return store.getSettings() + getIdAndPassAuths () { + return this.getRegisteredPlugins() + .map(p => ({ npmName: p.npmName, idAndPassAuths: p.registerHelpersStore.getIdAndPassAuths() })) + .filter(v => v.idAndPassAuths.length !== 0) + } - return [] + getExternalAuths () { + return this.getRegisteredPlugins() + .map(p => ({ npmName: p.npmName, externalAuths: p.registerHelpersStore.getExternalAuths() })) + .filter(v => v.externalAuths.length !== 0) + } + + getRegisteredSettings (npmName: string) { + const result = this.getRegisteredPluginOrTheme(npmName) + if (!result || result.type !== PluginType.PLUGIN) return [] + + return result.registerHelpersStore.getSettings() } getRouter (npmName: string) { - const store = this.registerHelpersStore[npmName] - if (!store) return null + const result = this.getRegisteredPluginOrTheme(npmName) + if (!result || result.type !== PluginType.PLUGIN) return null - return store.getRouter() + return result.registerHelpersStore.getRouter() } getTranslations (locale: string) { @@ -185,11 +197,9 @@ export class PluginManager implements ServerHook { this.hooks[key] = this.hooks[key].filter(h => h.npmName !== npmName) } - const store = this.registerHelpersStore[plugin.npmName] + const store = plugin.registerHelpersStore store.reinitVideoConstants(plugin.npmName) - delete this.registerHelpersStore[plugin.npmName] - logger.info('Regenerating registered plugin CSS to global file.') await this.regeneratePluginGlobalCSS() } @@ -294,8 +304,11 @@ export class PluginManager implements ServerHook { this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, plugin.type) let library: PluginLibrary + let registerHelpersStore: RegisterHelpersStore if (plugin.type === PluginType.PLUGIN) { - library = await this.registerPlugin(plugin, pluginPath, packageJSON) + const result = await this.registerPlugin(plugin, pluginPath, packageJSON) + library = result.library + registerHelpersStore = result.registerStore } const clientScripts: { [id: string]: ClientScript } = {} @@ -314,6 +327,7 @@ export class PluginManager implements ServerHook { staticDirs: packageJSON.staticDirs, clientScripts, css: packageJSON.css, + registerHelpersStore: registerHelpersStore || undefined, unregister: library ? library.unregister : undefined } @@ -332,15 +346,15 @@ export class PluginManager implements ServerHook { throw new Error('Library code is not valid (miss register or unregister function)') } - const registerHelpers = this.getRegisterHelpers(npmName, plugin) - library.register(registerHelpers) + const { registerOptions, registerStore } = this.getRegisterHelpers(npmName, plugin) + library.register(registerOptions) .catch(err => logger.error('Cannot register plugin %s.', npmName, { err })) logger.info('Add plugin %s CSS to global file.', npmName) await this.addCSSToGlobalFile(pluginPath, packageJSON.css) - return library + return { library, registerStore } } // ###################### Translations ###################### @@ -440,7 +454,10 @@ export class PluginManager implements ServerHook { // ###################### Generate register helpers ###################### - private getRegisterHelpers (npmName: string, plugin: PluginModel): RegisterServerOptions { + private getRegisterHelpers ( + npmName: string, + plugin: PluginModel + ): { registerStore: RegisterHelpersStore, registerOptions: RegisterServerOptions } { const onHookAdded = (options: RegisterServerHookOptions) => { if (!this.hooks[options.target]) this.hooks[options.target] = [] @@ -453,9 +470,11 @@ export class PluginManager implements ServerHook { } const registerHelpersStore = new RegisterHelpersStore(npmName, plugin, onHookAdded.bind(this)) - this.registerHelpersStore[npmName] = registerHelpersStore - return registerHelpersStore.buildRegisterHelpers() + return { + registerStore: registerHelpersStore, + registerOptions: registerHelpersStore.buildRegisterHelpers() + } } private sanitizeAndCheckPackageJSONOrThrow (packageJSON: PluginPackageJson, pluginType: PluginType) { diff --git a/server/lib/plugins/register-helpers-store.ts b/server/lib/plugins/register-helpers-store.ts index 5ca52b151..7e827401f 100644 --- a/server/lib/plugins/register-helpers-store.ts +++ b/server/lib/plugins/register-helpers-store.ts @@ -20,6 +20,12 @@ import { RegisterServerSettingOptions } from '@shared/models/plugins/register-se import * as express from 'express' import { PluginVideoPrivacyManager } from '@shared/models/plugins/plugin-video-privacy-manager.model' import { PluginPlaylistPrivacyManager } from '@shared/models/plugins/plugin-playlist-privacy-manager.model' +import { + RegisterServerAuthExternalOptions, + RegisterServerAuthExternalResult, + RegisterServerAuthPassOptions +} from '@shared/models/plugins/register-server-auth.model' +import { onExternalAuthPlugin } from '@server/lib/auth' type AlterableVideoConstant = 'language' | 'licence' | 'category' | 'privacy' | 'playlistPrivacy' type VideoConstant = { [key in number | string]: string } @@ -42,6 +48,9 @@ export class RegisterHelpersStore { private readonly settings: RegisterServerSettingOptions[] = [] + private readonly idAndPassAuths: RegisterServerAuthPassOptions[] = [] + private readonly externalAuths: RegisterServerAuthExternalOptions[] = [] + private readonly router: express.Router constructor ( @@ -69,6 +78,9 @@ export class RegisterHelpersStore { const videoPrivacyManager = this.buildVideoPrivacyManager() const playlistPrivacyManager = this.buildPlaylistPrivacyManager() + const registerIdAndPassAuth = this.buildRegisterIdAndPassAuth() + const registerExternalAuth = this.buildRegisterExternalAuth() + const peertubeHelpers = buildPluginHelpers(this.npmName) return { @@ -87,6 +99,9 @@ export class RegisterHelpersStore { videoPrivacyManager, playlistPrivacyManager, + registerIdAndPassAuth, + registerExternalAuth, + peertubeHelpers } } @@ -125,6 +140,14 @@ export class RegisterHelpersStore { return this.router } + getIdAndPassAuths () { + return this.idAndPassAuths + } + + getExternalAuths () { + return this.externalAuths + } + private buildGetRouter () { return () => this.router } @@ -146,6 +169,26 @@ export class RegisterHelpersStore { } } + private buildRegisterIdAndPassAuth () { + return (options: RegisterServerAuthPassOptions) => { + this.idAndPassAuths.push(options) + } + } + + private buildRegisterExternalAuth () { + const self = this + + return (options: RegisterServerAuthExternalOptions) => { + this.externalAuths.push(options) + + return { + onAuth (options: { username: string, email: string }): void { + onExternalAuthPlugin(self.npmName, options.username, options.email) + } + } as RegisterServerAuthExternalResult + } + } + private buildSettingsManager (): PluginSettingsManager { return { getSetting: (name: string) => PluginModel.getSetting(this.plugin.name, this.plugin.type, name), diff --git a/server/lib/user.ts b/server/lib/user.ts index 316c57359..8b447583e 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid' import { ActivityPubActorType } from '../../shared/models/activitypub' import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants' import { AccountModel } from '../models/account/account' -import { buildActorInstance, getAccountActivityPubUrl, setAsyncActorKeys } from './activitypub' +import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor' import { createLocalVideoChannel } from './video-channel' import { ActorModel } from '../models/activitypub/actor' import { UserNotificationSettingModel } from '../models/account/user-notification-setting' @@ -14,6 +14,7 @@ import { Redis } from './redis' import { Emailer } from './emailer' import { MAccountDefault, MActorDefault, MChannelActor } from '../typings/models' import { MUser, MUserDefault, MUserId } from '../typings/models/user' +import { getAccountActivityPubUrl } from './activitypub/url' type ChannelNames = { name: string, displayName: string } diff --git a/server/lib/video-channel.ts b/server/lib/video-channel.ts index c9887c667..102c1088d 100644 --- a/server/lib/video-channel.ts +++ b/server/lib/video-channel.ts @@ -2,9 +2,11 @@ import * as Sequelize from 'sequelize' import { v4 as uuidv4 } from 'uuid' import { VideoChannelCreate } from '../../shared/models' import { VideoChannelModel } from '../models/video/video-channel' -import { buildActorInstance, federateVideoIfNeeded, getVideoChannelActivityPubUrl } from './activitypub' +import { buildActorInstance } from './activitypub/actor' import { VideoModel } from '../models/video/video' import { MAccountId, MChannelDefault, MChannelId } from '../typings/models' +import { getVideoChannelActivityPubUrl } from './activitypub/url' +import { federateVideoIfNeeded } from './activitypub/videos' type CustomVideoChannelModelAccount = MChannelDefault & { Account?: T } diff --git a/server/middlewares/oauth.ts b/server/middlewares/oauth.ts index 9eef03bb4..4ae7f18c2 100644 --- a/server/middlewares/oauth.ts +++ b/server/middlewares/oauth.ts @@ -1,17 +1,8 @@ import * as express from 'express' -import * as OAuthServer from 'express-oauth-server' -import { OAUTH_LIFETIME } from '../initializers/constants' import { logger } from '../helpers/logger' import { Socket } from 'socket.io' import { getAccessToken } from '../lib/oauth-model' - -const oAuthServer = new OAuthServer({ - useErrorHandler: true, - accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, - refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, - continueMiddleware: true, - model: require('../lib/oauth-model') -}) +import { handleIdAndPassLogin, oAuthServer } from '@server/lib/auth' function authenticate (req: express.Request, res: express.Response, next: express.NextFunction, authenticateInQuery = false) { const options = authenticateInQuery ? { allowBearerTokensInQueryString: true } : {} @@ -73,27 +64,11 @@ function optionalAuthenticate (req: express.Request, res: express.Response, next return next() } -function token (req: express.Request, res: express.Response, next: express.NextFunction) { - return oAuthServer.token()(req, res, err => { - if (err) { - return res.status(err.status) - .json({ - error: err.message, - code: err.name - }) - .end() - } - - return next() - }) -} - // --------------------------------------------------------------------------- export { authenticate, authenticateSocket, authenticatePromiseIfNeeded, - optionalAuthenticate, - token + optionalAuthenticate } diff --git a/server/models/account/user.ts b/server/models/account/user.ts index da40bf290..d0d9a0508 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -221,7 +221,7 @@ enum ScopeNames { }) export class UserModel extends Model { - @AllowNull(false) + @AllowNull(true) @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password')) @Column password: string @@ -348,6 +348,11 @@ export class UserModel extends Model { @Column noWelcomeModal: boolean + @AllowNull(true) + @Default(null) + @Column + pluginAuth: string + @CreatedAt createdAt: Date diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index db82e8fc2..7ba04a4ca 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -40,7 +40,7 @@ import { getVideoAbusesList, updateCustomSubConfig, getCustomConfig, waitJobs } from '../../../../shared/extra-utils' import { follow } from '../../../../shared/extra-utils/server/follows' -import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login' +import { setAccessTokensToServers, logout } from '../../../../shared/extra-utils/users/login' import { getMyVideos } from '../../../../shared/extra-utils/videos/videos' import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model' import { CustomConfig } from '@shared/models/server' @@ -205,11 +205,17 @@ describe('Test users', function () { }) describe('Logout', function () { - it('Should logout (revoke token)') + it('Should logout (revoke token)', async function () { + await logout(server.url, server.accessToken) + }) - it('Should not be able to get the user information') + it('Should not be able to get the user information', async function () { + await getMyUserInformation(server.url, server.accessToken, 401) + }) - it('Should not be able to upload a video') + it('Should not be able to upload a video', async function () { + await uploadVideo(server.url, server.accessToken, { name: 'video' }, 401) + }) it('Should not be able to remove a video') diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-one/main.js new file mode 100644 index 000000000..4755ed643 --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-one/main.js @@ -0,0 +1,61 @@ +async function register ({ + registerIdAndPassAuth, + peertubeHelpers +}) { + registerIdAndPassAuth({ + type: 'id-and-pass', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 1 - 1') + }, + + getWeight: () => 15, + + login (body) { + if (body.id === 'spyro' && body.password === 'spyro password') { + return Promise.resolve({ + username: 'spyro', + email: 'spyro@example.com', + role: 0, + displayName: 'Spyro the Dragon' + }) + } + + return null + } + }) + + registerIdAndPassAuth({ + type: 'id-and-pass', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 1 - 2') + }, + + getWeight: () => 50, + + login (body) { + if (body.id === 'crash' && body.password === 'crash password') { + return Promise.resolve({ + username: 'crash', + email: 'crash@example.com', + role: 2, + displayName: 'Crash Bandicoot' + }) + } + + return null + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-one/package.json b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-one/package.json new file mode 100644 index 000000000..f8ad18a90 --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-one/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-id-pass-auth-one", + "version": "0.0.1", + "description": "Id and pass auth one", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-three/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-three/main.js new file mode 100644 index 000000000..2a15b3754 --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-three/main.js @@ -0,0 +1,37 @@ +async function register ({ + registerIdAndPassAuth, + peertubeHelpers +}) { + registerIdAndPassAuth({ + type: 'id-and-pass', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 3 - 1') + }, + + getWeight: () => 5, + + login (body) { + if (body.id === 'laguna' && body.password === 'laguna password') { + return Promise.resolve({ + username: 'laguna', + email: 'laguna@example.com', + displayName: 'Laguna Loire' + }) + } + + return null + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-three/package.json b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-three/package.json new file mode 100644 index 000000000..f9f107b1a --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-three/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-id-pass-auth-three", + "version": "0.0.1", + "description": "Id and pass auth three", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js new file mode 100644 index 000000000..edfc870c0 --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js @@ -0,0 +1,36 @@ +async function register ({ + registerIdAndPassAuth, + peertubeHelpers +}) { + registerIdAndPassAuth({ + type: 'id-and-pass', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 2 - 1') + }, + + getWeight: () => 30, + + login (body) { + if (body.id === 'laguna' && body.password === 'laguna password') { + return Promise.resolve({ + username: 'laguna', + email: 'laguna@example.com' + }) + } + + return null + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/package.json b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/package.json new file mode 100644 index 000000000..5df15fac1 --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-id-pass-auth-two", + "version": "0.0.1", + "description": "Id and pass auth two", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/server/tests/plugins/id-and-pass-auth.ts b/server/tests/plugins/id-and-pass-auth.ts new file mode 100644 index 000000000..5b4d1a1db --- /dev/null +++ b/server/tests/plugins/id-and-pass-auth.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { cleanupTests, flushAndRunServer, ServerInfo } from '../../../shared/extra-utils/server/servers' +import { getPluginTestPath, installPlugin, setAccessTokensToServers } from '../../../shared/extra-utils' + +describe('Test id and pass auth plugins', function () { + let server: ServerInfo + + before(async function () { + this.timeout(30000) + + server = await flushAndRunServer(1) + await setAccessTokensToServers([ server ]) + + await installPlugin({ + url: server.url, + accessToken: server.accessToken, + path: getPluginTestPath('-id-pass-auth-one') + }) + + await installPlugin({ + url: server.url, + accessToken: server.accessToken, + path: getPluginTestPath('-id-pass-auth-two') + }) + }) + + it('Should not login', async function() { + + }) + + it('Should login Spyro, create the user and use the token', async function() { + + }) + + it('Should login Crash, create the user and use the token', async function() { + + }) + + it('Should login the first Laguna, create the user and use the token', async function() { + + }) + + it('Should update Crash profile', async function () { + + }) + + it('Should logout Crash', async function () { + + // test token + }) + + it('Should have logged the Crash logout', async function () { + + }) + + it('Should login Crash and keep the old existing profile', async function () { + + }) + + it('Should uninstall the plugin one and do not login existing Crash', async function () { + + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/plugins/index.ts b/server/tests/plugins/index.ts index 1414e7e58..8aa30654a 100644 --- a/server/tests/plugins/index.ts +++ b/server/tests/plugins/index.ts @@ -1,4 +1,5 @@ import './action-hooks' +import './id-and-pass-auth' import './filter-hooks' import './translations' import './video-constants' diff --git a/server/typings/express.ts b/server/typings/express.ts index f4188bf3d..ebccf7f7d 100644 --- a/server/typings/express.ts +++ b/server/typings/express.ts @@ -28,12 +28,23 @@ import { MVideoChangeOwnershipFull } from './models/video/video-change-ownership import { MPlugin, MServer } from '@server/typings/models/server' import { MServerBlocklist } from './models/server/server-blocklist' import { MOAuthTokenUser } from '@server/typings/models/oauth/oauth-token' +import { UserRole } from '@shared/models' declare module 'express' { - interface Response { locals: { + bypassLogin?: { + bypass: boolean + pluginName: string + user: { + username: string + email: string + displayName: string + role: UserRole + } + } + videoAll?: MVideoFullLight onlyImmutableVideo?: MVideoImmutable onlyVideo?: MVideoThumbnail diff --git a/server/typings/plugins/register-server-option.model.ts b/server/typings/plugins/register-server-option.model.ts index 813e93003..0c0993c14 100644 --- a/server/typings/plugins/register-server-option.model.ts +++ b/server/typings/plugins/register-server-option.model.ts @@ -9,6 +9,7 @@ import { Logger } from 'winston' import { Router } from 'express' import { PluginVideoPrivacyManager } from '@shared/models/plugins/plugin-video-privacy-manager.model' import { PluginPlaylistPrivacyManager } from '@shared/models/plugins/plugin-playlist-privacy-manager.model' +import { RegisterServerAuthPassOptions, RegisterServerAuthExternalOptions, RegisterServerAuthExternalResult } from '@shared/models/plugins/register-server-auth.model' export type PeerTubeHelpers = { logger: Logger @@ -38,6 +39,9 @@ export type RegisterServerOptions = { videoPrivacyManager: PluginVideoPrivacyManager playlistPrivacyManager: PluginPlaylistPrivacyManager + registerIdAndPassAuth: (options: RegisterServerAuthPassOptions) => void + registerExternalAuth: (options: RegisterServerAuthExternalOptions) => RegisterServerAuthExternalResult + // Get plugin router to create custom routes // Base routes of this router are // * /plugins/:pluginName/:pluginVersion/router/... diff --git a/shared/extra-utils/users/login.ts b/shared/extra-utils/users/login.ts index 4fe54a74a..2d68337a6 100644 --- a/shared/extra-utils/users/login.ts +++ b/shared/extra-utils/users/login.ts @@ -27,6 +27,16 @@ function login (url: string, client: Client, user: User, expectedStatus = 200) { .expect(expectedStatus) } +function logout (url: string, token: string, expectedStatus = 200) { + const path = '/api/v1/users/revoke-token' + + return request(url) + .post(path) + .set('Authorization', 'Bearer ' + token) + .type('form') + .expect(expectedStatus) +} + async function serverLogin (server: Server) { const res = await login(server.url, server.client, server.user, 200) @@ -71,6 +81,7 @@ function setAccessTokensToServers (servers: ServerInfo[]) { export { login, + logout, serverLogin, userLogin, getAccessToken, diff --git a/shared/models/plugins/register-server-auth.model.ts b/shared/models/plugins/register-server-auth.model.ts new file mode 100644 index 000000000..34ebbe712 --- /dev/null +++ b/shared/models/plugins/register-server-auth.model.ts @@ -0,0 +1,33 @@ +import { UserRole } from '@shared/models' + +export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions + +export interface RegisterServerAuthPassOptions { + type: 'id-and-pass' + + onLogout?: Function + + getWeight(): number + + // Used by PeerTube to login a user + // Returns null if the login failed, or { username, email } on success + login(body: { + id: string + password: string + }): Promise<{ + username: string + email: string + role?: UserRole + displayName?: string + } | null> +} + +export interface RegisterServerAuthExternalOptions { + type: 'external' + + onLogout?: Function +} + +export interface RegisterServerAuthExternalResult { + onAuth (options: { username: string, email: string }): void +}