From dd9d362ae06530057e42813c14a64a37c56a586e Mon Sep 17 00:00:00 2001 From: Toby Lawrence Date: Mon, 31 Oct 2022 22:08:47 -0400 Subject: [PATCH] feat(auth): add ForwardAuth support via X-Plex-Token header This adds ForwardAuth support based on a user's Plex token being sent as an HTTP header (X-Plex-Token) within a request to Overseerr. When a session has no current user, but this feature is enabled and the header is present, Overseerr will attempt to create and/or log in the user in a manner nearly identical to the /api/v1/auth/plex endpoint. --- cypress/config/settings.cypress.json | 257 +++++++++--------- overseerr-api.yml | 8 + server/api/plextv.ts | 6 +- server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/auth.ts | 148 ++++++++++ server/lib/settings.ts | 4 + server/lib/watchlistsync.ts | 2 +- server/middleware/auth.ts | 24 +- server/routes/auth.ts | 140 ++-------- server/routes/discover.ts | 2 +- server/routes/settings/index.ts | 2 +- server/routes/user/index.ts | 2 +- .../Settings/SettingsUsers/index.tsx | 28 ++ src/context/SettingsContext.tsx | 1 + src/i18n/locale/en.json | 2 + src/pages/_app.tsx | 12 +- 16 files changed, 379 insertions(+), 260 deletions(-) create mode 100644 server/lib/auth.ts diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index bb7b661b0a..153ce8a2c1 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -3,147 +3,148 @@ "vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M", "vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs", "main": { - "apiKey": "testkey", - "applicationTitle": "Overseerr", - "applicationUrl": "", - "csrfProtection": false, - "cacheImages": false, - "defaultPermissions": 32, - "defaultQuotas": { - "movie": {}, - "tv": {} - }, - "hideAvailable": false, - "localLogin": true, - "newPlexLogin": true, - "region": "", - "originalLanguage": "", - "trustProxy": false, - "partialRequestsEnabled": true, - "locale": "en" + "apiKey": "testkey", + "applicationTitle": "Overseerr", + "applicationUrl": "", + "csrfProtection": false, + "cacheImages": false, + "defaultPermissions": 32, + "defaultQuotas": { + "movie": {}, + "tv": {} + }, + "hideAvailable": false, + "localLogin": true, + "newPlexLogin": true, + "region": "", + "originalLanguage": "", + "trustProxy": false, + "partialRequestsEnabled": true, + "locale": "en", + "enableForwardAuth": true }, "plex": { - "name": "Seerr", - "ip": "192.168.1.1", - "port": 32400, - "useSsl": false, - "libraries": [ - { - "id": "1", - "name": "Movies", - "enabled": true, - "type": "movie" - } - ], - "machineId": "test" + "name": "Seerr", + "ip": "192.168.1.1", + "port": 32400, + "useSsl": false, + "libraries": [ + { + "id": "1", + "name": "Movies", + "enabled": true, + "type": "movie" + } + ], + "machineId": "test" }, "tautulli": {}, "radarr": [], "sonarr": [], "public": { - "initialized": true + "initialized": true }, "notifications": { - "agents": { - "email": { - "enabled": false, - "options": { - "emailFrom": "", - "smtpHost": "", - "smtpPort": 587, - "secure": false, - "ignoreTls": false, - "requireTls": false, - "allowSelfSigned": false, - "senderName": "Overseerr" - } - }, - "discord": { - "enabled": false, - "types": 0, - "options": { - "webhookUrl": "", - "enableMentions": true - } - }, - "lunasea": { - "enabled": false, - "types": 0, - "options": { - "webhookUrl": "" - } - }, - "slack": { - "enabled": false, - "types": 0, - "options": { - "webhookUrl": "" - } - }, - "telegram": { - "enabled": false, - "types": 0, - "options": { - "botAPI": "", - "chatId": "", - "sendSilently": false - } + "agents": { + "email": { + "enabled": false, + "options": { + "emailFrom": "", + "smtpHost": "", + "smtpPort": 587, + "secure": false, + "ignoreTls": false, + "requireTls": false, + "allowSelfSigned": false, + "senderName": "Overseerr" + } + }, + "discord": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "", + "enableMentions": true + } + }, + "lunasea": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "" + } + }, + "slack": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "" + } + }, + "telegram": { + "enabled": false, + "types": 0, + "options": { + "botAPI": "", + "chatId": "", + "sendSilently": false + } + }, + "pushbullet": { + "enabled": false, + "types": 0, + "options": { + "accessToken": "" + } + }, + "pushover": { + "enabled": false, + "types": 0, + "options": { + "accessToken": "", + "userToken": "" + } + }, + "webhook": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "", + "jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i" + } + }, + "webpush": { + "enabled": false, + "options": {} + }, + "gotify": { + "enabled": false, + "types": 0, + "options": { + "url": "", + "token": "" + } + } + } + }, + "jobs": { + "plex-recently-added-scan": { + "schedule": "0 */5 * * * *" }, - "pushbullet": { - "enabled": false, - "types": 0, - "options": { - "accessToken": "" - } + "plex-full-scan": { + "schedule": "0 0 3 * * *" }, - "pushover": { - "enabled": false, - "types": 0, - "options": { - "accessToken": "", - "userToken": "" - } + "radarr-scan": { + "schedule": "0 0 4 * * *" }, - "webhook": { - "enabled": false, - "types": 0, - "options": { - "webhookUrl": "", - "jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i" - } + "sonarr-scan": { + "schedule": "0 30 4 * * *" }, - "webpush": { - "enabled": false, - "options": {} + "download-sync": { + "schedule": "0 * * * * *" }, - "gotify": { - "enabled": false, - "types": 0, - "options": { - "url": "", - "token": "" - } + "download-sync-reset": { + "schedule": "0 0 1 * * *" } - } - }, - "jobs": { - "plex-recently-added-scan": { - "schedule": "0 */5 * * * *" - }, - "plex-full-scan": { - "schedule": "0 0 3 * * *" - }, - "radarr-scan": { - "schedule": "0 0 4 * * *" - }, - "sonarr-scan": { - "schedule": "0 30 4 * * *" - }, - "download-sync": { - "schedule": "0 * * * * *" - }, - "download-sync-reset": { - "schedule": "0 0 1 * * *" - } } - } +} diff --git a/overseerr-api.yml b/overseerr-api.yml index f114cce1a4..72eb397843 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -133,6 +133,9 @@ components: defaultPermissions: type: number example: 32 + enableForwardAuth: + type: boolean + example: true PlexLibrary: type: object properties: @@ -1789,6 +1792,10 @@ components: type: apiKey in: header name: X-Api-Key + plexToken: + type: apiKey + in: header + name: X-Plex-Token paths: /status: @@ -5920,3 +5927,4 @@ paths: security: - cookieAuth: [] - apiKey: [] + - plexToken: [] diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 76ee66188c..30d28bbe65 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -9,7 +9,7 @@ interface PlexAccountResponse { user: PlexUser; } -interface PlexUser { +export interface PlexUser { id: number; uuid: string; email: string; @@ -142,7 +142,7 @@ export interface PlexWatchlistItem { title: string; } -class PlexTvAPI extends ExternalAPI { +export class PlexTvAPI extends ExternalAPI { private authToken: string; constructor(authToken: string) { @@ -365,5 +365,3 @@ class PlexTvAPI extends ExternalAPI { } } } - -export default PlexTvAPI; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 0cd2f171ae..bb931cc531 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -37,6 +37,7 @@ export interface PublicSettingsResponse { locale: string; emailEnabled: boolean; newPlexLogin: boolean; + enableForwardAuth: boolean; } export interface CacheItem { diff --git a/server/lib/auth.ts b/server/lib/auth.ts new file mode 100644 index 0000000000..7639f85c81 --- /dev/null +++ b/server/lib/auth.ts @@ -0,0 +1,148 @@ +import { PlexTvAPI, type PlexUser } from '@server/api/plextv'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; + +export enum PlexUserErrorCause { + NotAuthorized = 'user has no access to the media server', + AuthorizedNotImported = 'user has access to the media server, but has not yet been imported', +} + +export interface PlexUserError { + cause: PlexUserErrorCause; + account: PlexUser; +} + +const getUserByPlexAccount = async ( + account: PlexUser +): Promise => { + const userRepository = getRepository(User); + + return await userRepository + .createQueryBuilder('user') + .where('user.plexId = :id', { id: account.id }) + .orWhere('user.email = :email', { + email: account.email.toLowerCase(), + }) + .getOne(); +}; +const createPlexUser = async ( + account: PlexUser, + permissions: Permission +): Promise => { + const userRepository = getRepository(User); + + const user = new User({ + email: account.email, + plexUsername: account.username, + plexId: account.id, + plexToken: account.authToken, + permissions: permissions, + avatar: account.thumb, + userType: UserType.PLEX, + }); + + await userRepository.save(user); + + return user; +}; + +const updateUserToPlexUser = async (user: User, account: PlexUser) => { + const userRepository = getRepository(User); + + user.email = account.email; + user.plexToken = account.authToken; + user.plexId = account.id; + user.avatar = account.thumb; + user.plexUsername = account.username; + user.userType = UserType.PLEX; + + await userRepository.save(user); +}; + +export const getOrCreatePlexUser = async ( + plexAuthToken: string +): Promise => { + const settings = getSettings(); + const userRepository = getRepository(User); + + // First, we try to get the Plex user associated with the given authentication token, if any. + const plextv = new PlexTvAPI(plexAuthToken); + const account = await plextv.getUser(); + + // Check to see if a local user already exists that is mapped to this Plex user, either by their + // Plex account ID, or the e-mail attached to the account. If it does, we're done: just return the + // user. + let user = await getUserByPlexAccount(account); + + // If no such local user exists, and there are no other users at all, create one and assign it + // as the administrator. This supports the initial setup experience as the user has to log in + // anyways for us to query Plex servers to import. + const anyUsersExist = (await userRepository.count()) > 0; + if (!user && !anyUsersExist) { + return await createPlexUser(account, Permission.ADMIN); + } + + // We found an associated user, and it's the main user: we always grant access to the main user. + if (user && user.id === 1) { + return user; + } + + // Check to see if the Plex account is authorized to access the main Plex machine associated with + // this instance. Whether or not we found an associated user, if they aren't authorized, we don't + // want to return their user object, or create a new one. + const mainUser = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true, plexId: true }, + where: { id: 1 }, + }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + const plexAccountHasAccess = await mainPlexTv.checkUserAccess(account.id); + if (!plexAccountHasAccess) { + return { + cause: PlexUserErrorCause.NotAuthorized, + account: account, + } as PlexUserError; + } + + // If the Plex account is authorized, but we don't have a local user, we'll want to create one, + // but we can only do that if "Enable New Plex Sign-In" is true. + if (!user && !settings.main.newPlexLogin) { + return { + cause: PlexUserErrorCause.AuthorizedNotImported, + account: account, + } as PlexUserError; + } + + if (user) { + if (!user.plexId) { + logger.info( + 'Imported Plex user logged in for the first time; updating Overseerr user with Plex account data', + { + label: 'API', + email: user.email, + userId: user.id, + plexId: account.id, + plexUsername: account.username, + } + ); + + await updateUserToPlexUser(user, account); + } + } else { + logger.info( + 'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user', + { + label: 'API', + email: account.email, + plexId: account.id, + plexUsername: account.username, + } + ); + + user = await createPlexUser(account, settings.main.defaultPermissions); + } + return user; +}; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index cf475554ff..dac271d8c1 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -101,6 +101,7 @@ export interface MainSettings { trustProxy: boolean; partialRequestsEnabled: boolean; locale: string; + enableForwardAuth: boolean; } interface PublicSettings { @@ -123,6 +124,7 @@ interface FullPublicSettings extends PublicSettings { locale: string; emailEnabled: boolean; newPlexLogin: boolean; + enableForwardAuth: boolean; } export interface NotificationAgentConfig { @@ -295,6 +297,7 @@ class Settings { trustProxy: false, partialRequestsEnabled: true, locale: 'en', + enableForwardAuth: true, }, plex: { name: '', @@ -499,6 +502,7 @@ class Settings { locale: this.data.main.locale, emailEnabled: this.data.notifications.agents.email.enabled, newPlexLogin: this.data.main.newPlexLogin, + enableForwardAuth: this.data.main.enableForwardAuth, }; } diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 46147f3fca..7ddb510c53 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -1,4 +1,4 @@ -import PlexTvAPI from '@server/api/plextv'; +import { PlexTvAPI } from '@server/api/plextv'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 326d460d8b..3ecd91647f 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,5 +1,6 @@ import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; +import { getOrCreatePlexUser } from '@server/lib/auth'; import type { Permission, PermissionCheckOptions, @@ -27,6 +28,14 @@ export const checkUser: Middleware = async (req, _res, next) => { user = await userRepository.findOne({ where: { id: req.session.userId }, }); + } else if (settings.main.enableForwardAuth) { + const plexToken = req.header('X-Plex-Token'); + if (plexToken) { + const maybeUser = await getOrCreatePlexUser(plexToken); + if (maybeUser instanceof User) { + user = maybeUser; + } + } } if (user) { @@ -44,7 +53,20 @@ export const isAuthenticated = ( permissions?: Permission | Permission[], options?: PermissionCheckOptions ): Middleware => { - const authMiddleware: Middleware = (req, res, next) => { + const authMiddleware: Middleware = async (req, res, next) => { + if (!req.user) { + const settings = getSettings(); + if (settings.main.enableForwardAuth) { + const authToken = req.header('X-Plex-Token'); + if (authToken) { + const user = await getOrCreatePlexUser(authToken); + if (user instanceof User) { + req.user = user; + } + } + } + } + if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) { res.status(403).json({ status: 403, diff --git a/server/routes/auth.ts b/server/routes/auth.ts index cf4a4e869e..c860c410d8 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,8 +1,8 @@ -import PlexTvAPI from '@server/api/plextv'; +import { PlexTvAPI } from '@server/api/plextv'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; -import { Permission } from '@server/lib/permissions'; +import { getOrCreatePlexUser } from '@server/lib/auth'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -26,8 +26,6 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { }); authRoutes.post('/plex', async (req, res, next) => { - const settings = getSettings(); - const userRepository = getRepository(User); const body = req.body as { authToken?: string }; if (!body.authToken) { @@ -37,127 +35,27 @@ authRoutes.post('/plex', async (req, res, next) => { }); } try { - // First we need to use this auth token to get the user's email from plex.tv - const plextv = new PlexTvAPI(body.authToken); - const account = await plextv.getUser(); - - // Next let's see if the user already exists - let user = await userRepository - .createQueryBuilder('user') - .where('user.plexId = :id', { id: account.id }) - .orWhere('user.email = :email', { - email: account.email.toLowerCase(), - }) - .getOne(); - - if (!user && !(await userRepository.count())) { - user = new User({ - email: account.email, - plexUsername: account.username, - plexId: account.id, - plexToken: account.authToken, - permissions: Permission.ADMIN, - avatar: account.thumb, - userType: UserType.PLEX, - }); + const result = await getOrCreatePlexUser(body.authToken); + if (result instanceof User) { + // Set logged in session + if (req.session) { + req.session.userId = result.id; + } - await userRepository.save(user); + return res.status(200).json(result?.filter() ?? {}); } else { - const mainUser = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true, plexId: true }, - where: { id: 1 }, + logger.warn(`Failed sign-in attempt by Plex user: ${result.cause}`, { + label: 'API', + ip: req.ip, + email: result.account.email, + plexId: result.account.id, + plexUsername: result.account.username, + }); + return next({ + status: 403, + message: 'Access denied.', }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - - if ( - account.id === mainUser.plexId || - (await mainPlexTv.checkUserAccess(account.id)) - ) { - if (user) { - if (!user.plexId) { - logger.info( - 'Found matching Plex user; updating user with Plex data', - { - label: 'API', - ip: req.ip, - email: user.email, - userId: user.id, - plexId: account.id, - plexUsername: account.username, - } - ); - } - - user.plexToken = body.authToken; - user.plexId = account.id; - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; - user.userType = UserType.PLEX; - - await userRepository.save(user); - } else if (!settings.main.newPlexLogin) { - logger.warn( - 'Failed sign-in attempt by unimported Plex user with access to the media server', - { - label: 'API', - ip: req.ip, - email: account.email, - plexId: account.id, - plexUsername: account.username, - } - ); - return next({ - status: 403, - message: 'Access denied.', - }); - } else { - logger.info( - 'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user', - { - label: 'API', - ip: req.ip, - email: account.email, - plexId: account.id, - plexUsername: account.username, - } - ); - user = new User({ - email: account.email, - plexUsername: account.username, - plexId: account.id, - plexToken: account.authToken, - permissions: settings.main.defaultPermissions, - avatar: account.thumb, - userType: UserType.PLEX, - }); - - await userRepository.save(user); - } - } else { - logger.warn( - 'Failed sign-in attempt by Plex user without access to the media server', - { - label: 'API', - ip: req.ip, - email: account.email, - plexId: account.id, - plexUsername: account.username, - } - ); - return next({ - status: 403, - message: 'Access denied.', - }); - } - } - - // Set logged in session - if (req.session) { - req.session.userId = user.id; } - - return res.status(200).json(user?.filter() ?? {}); } catch (e) { logger.error('Something went wrong authenticating with Plex account', { label: 'API', diff --git a/server/routes/discover.ts b/server/routes/discover.ts index b39a83325e..8d5f3c028b 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,4 +1,4 @@ -import PlexTvAPI from '@server/api/plextv'; +import { PlexTvAPI } from '@server/api/plextv'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 7205b18966..b803c97608 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,5 +1,5 @@ import PlexAPI from '@server/api/plexapi'; -import PlexTvAPI from '@server/api/plextv'; +import { PlexTvAPI } from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index f77b7e5155..325a9e6b87 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -1,4 +1,4 @@ -import PlexTvAPI from '@server/api/plextv'; +import { PlexTvAPI } from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { MediaType } from '@server/constants/media'; import { UserType } from '@server/constants/user'; diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index fb01ba48c5..47da3767cc 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -27,6 +27,9 @@ const messages = defineMessages({ tvRequestLimitLabel: 'Global Series Request Limit', defaultPermissions: 'Default Permissions', defaultPermissionsTip: 'Initial permissions assigned to new users', + enableForwardAuth: 'Enable Forward Auth Via Plex Token', + enableForwardAuthTip: + 'Allow Plex users to be authenticated via Forward Auth (via X-Plex-Token header)', }); const SettingsUsers = () => { @@ -66,6 +69,7 @@ const SettingsUsers = () => { tvQuotaLimit: data?.defaultQuotas.tv.quotaLimit ?? 0, tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7, defaultPermissions: data?.defaultPermissions ?? 0, + enableForwardAuth: data?.enableForwardAuth, }} enableReinitialize onSubmit={async (values) => { @@ -84,6 +88,7 @@ const SettingsUsers = () => { }, }, defaultPermissions: values.defaultPermissions, + enableForwardAuth: values.enableForwardAuth, }); mutate('/api/v1/settings/public'); @@ -140,6 +145,29 @@ const SettingsUsers = () => { /> +
+ +
+ { + setFieldValue( + 'enableForwardAuth', + !values.enableForwardAuth + ); + }} + /> +
+