mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: verify Plex server access during auth for existing users with Plex IDs (#2458)
* feat: if local sign-in disabled, verify Plex server access during auth for existing users * fix: disable local/password login by default * fix: set localLogin to disabled in getInitialProps * fix: verify Plex server access on local logins as well
This commit is contained in:
@@ -15,8 +15,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
|||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
status: 500,
|
status: 500,
|
||||||
error:
|
error: 'Please sign in.',
|
||||||
'Requested user endpoint without valid authenticated user in session',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const user = await userRepository.findOneOrFail({
|
const user = await userRepository.findOneOrFail({
|
||||||
@@ -32,10 +31,13 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
const body = req.body as { authToken?: string };
|
const body = req.body as { authToken?: string };
|
||||||
|
|
||||||
if (!body.authToken) {
|
if (!body.authToken) {
|
||||||
return res.status(500).json({ error: 'You must provide an auth token' });
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Authentication token required.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// First we need to use this auth token to get the users email from plex.tv
|
// First we need to use this auth token to get the user's email from plex.tv
|
||||||
const plextv = new PlexTvAPI(body.authToken);
|
const plextv = new PlexTvAPI(body.authToken);
|
||||||
const account = await plextv.getUser();
|
const account = await plextv.getUser();
|
||||||
|
|
||||||
@@ -48,30 +50,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (user) {
|
if (!user && !(await userRepository.count())) {
|
||||||
// Let's check if their Plex token is up-to-date
|
|
||||||
if (user.plexToken !== body.authToken) {
|
|
||||||
user.plexToken = body.authToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the user's avatar with their Plex thumbnail, in case it changed
|
|
||||||
user.avatar = account.thumb;
|
|
||||||
user.email = account.email;
|
|
||||||
user.plexUsername = account.username;
|
|
||||||
|
|
||||||
// In case the user was previously a local account
|
|
||||||
if (user.userType === UserType.LOCAL) {
|
|
||||||
user.userType = UserType.PLEX;
|
|
||||||
user.plexId = account.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
await userRepository.save(user);
|
|
||||||
} else {
|
|
||||||
// Here we check if it's the first user. If it is, we create the user with no check
|
|
||||||
// and give them admin permissions
|
|
||||||
const totalUsers = await userRepository.count();
|
|
||||||
|
|
||||||
if (totalUsers === 0) {
|
|
||||||
user = new User({
|
user = new User({
|
||||||
email: account.email,
|
email: account.email,
|
||||||
plexUsername: account.username,
|
plexUsername: account.username,
|
||||||
@@ -81,38 +60,68 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
avatar: account.thumb,
|
avatar: account.thumb,
|
||||||
userType: UserType.PLEX,
|
userType: UserType.PLEX,
|
||||||
});
|
});
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
|
} else {
|
||||||
|
const mainUser = await userRepository.findOneOrFail({
|
||||||
|
select: ['id', 'plexToken', 'plexId'],
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double check that we didn't create the first admin user before running this
|
user.plexToken = body.authToken;
|
||||||
if (!user) {
|
user.plexId = account.id;
|
||||||
if (!settings.main.newPlexLogin) {
|
user.avatar = account.thumb;
|
||||||
logger.info(
|
user.email = account.email;
|
||||||
'Failed sign-in attempt from user who has not been imported to Overseerr.',
|
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: 'Auth',
|
label: 'API',
|
||||||
account: {
|
ip: req.ip,
|
||||||
...account,
|
email: account.email,
|
||||||
authentication_token: '__REDACTED__',
|
plexId: account.id,
|
||||||
authToken: '__REDACTED__',
|
plexUsername: account.username,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return next({
|
return next({
|
||||||
status: 403,
|
status: 403,
|
||||||
message: 'Access denied.',
|
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,
|
||||||
}
|
}
|
||||||
|
);
|
||||||
// If we get to this point, the user does not already exist so we need to create the
|
|
||||||
// user _assuming_ they have access to the Plex server
|
|
||||||
const mainUser = await userRepository.findOneOrFail({
|
|
||||||
select: ['id', 'plexToken'],
|
|
||||||
order: { id: 'ASC' },
|
|
||||||
});
|
|
||||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
|
||||||
|
|
||||||
if (await mainPlexTv.checkUserAccess(account.id)) {
|
|
||||||
user = new User({
|
user = new User({
|
||||||
email: account.email,
|
email: account.email,
|
||||||
plexUsername: account.username,
|
plexUsername: account.username,
|
||||||
@@ -122,17 +131,18 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
avatar: account.thumb,
|
avatar: account.thumb,
|
||||||
userType: UserType.PLEX,
|
userType: UserType.PLEX,
|
||||||
});
|
});
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.warn(
|
||||||
'Failed sign-in attempt from user without access to the Plex server.',
|
'Failed sign-in attempt by Plex user without access to the media server',
|
||||||
{
|
{
|
||||||
label: 'Auth',
|
label: 'API',
|
||||||
account: {
|
ip: req.ip,
|
||||||
...account,
|
email: account.email,
|
||||||
authentication_token: '__REDACTED__',
|
plexId: account.id,
|
||||||
authToken: '__REDACTED__',
|
plexUsername: account.username,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return next({
|
return next({
|
||||||
@@ -141,7 +151,6 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Set logged in session
|
// Set logged in session
|
||||||
if (req.session) {
|
if (req.session) {
|
||||||
@@ -150,10 +159,14 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
|
|
||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e.message, { label: 'Auth' });
|
logger.error('Something went wrong authenticating with Plex account', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
ip: req.ip,
|
||||||
|
});
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Something went wrong.',
|
message: 'Unable to authenticate.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -164,7 +177,7 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
const body = req.body as { email?: string; password?: string };
|
const body = req.body as { email?: string; password?: string };
|
||||||
|
|
||||||
if (!settings.main.localLogin) {
|
if (!settings.main.localLogin) {
|
||||||
return res.status(500).json({ error: 'Local user sign-in is disabled.' });
|
return res.status(500).json({ error: 'Password sign-in is disabled.' });
|
||||||
} else if (!body.email || !body.password) {
|
} else if (!body.email || !body.password) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: 'You must provide both an email address and a password.',
|
error: 'You must provide both an email address and a password.',
|
||||||
@@ -173,28 +186,77 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const user = await userRepository
|
const user = await userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder('user')
|
||||||
.select(['user.id', 'user.password'])
|
.select(['user.id', 'user.email', 'user.password', 'user.plexId'])
|
||||||
.where('user.email = :email', { email: body.email.toLowerCase() })
|
.where('user.email = :email', { email: body.email.toLowerCase() })
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
const isCorrectCredentials = await user?.passwordMatch(body.password);
|
if (!user || !(await user.passwordMatch(body.password))) {
|
||||||
|
logger.warn('Failed sign-in attempt using invalid Overseerr password', {
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
email: body.email,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 403,
|
||||||
|
message: 'Access denied.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// User doesn't exist or credentials are incorrect
|
const mainUser = await userRepository.findOneOrFail({
|
||||||
if (!isCorrectCredentials) {
|
select: ['id', 'plexToken', 'plexId'],
|
||||||
logger.info(
|
order: { id: 'ASC' },
|
||||||
'Failed sign-in attempt from user with incorrect credentials.',
|
});
|
||||||
|
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||||
|
|
||||||
|
if (!user.plexId) {
|
||||||
|
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||||
|
const account = plexUsersResponse.MediaContainer.User.find(
|
||||||
|
(account) =>
|
||||||
|
account.$.email &&
|
||||||
|
account.$.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
)?.$;
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
logger.info('Found matching Plex user; updating user with Plex data', {
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
email: body.email,
|
||||||
|
userId: user.id,
|
||||||
|
plexId: account.id,
|
||||||
|
plexUsername: account.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
user.plexId = parseInt(account.id);
|
||||||
|
user.avatar = account.thumb;
|
||||||
|
user.email = account.email;
|
||||||
|
user.plexUsername = account.username;
|
||||||
|
user.userType = UserType.PLEX;
|
||||||
|
|
||||||
|
await userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.plexId &&
|
||||||
|
user.plexId !== mainUser.plexId &&
|
||||||
|
!(await mainPlexTv.checkUserAccess(user.plexId))
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
'Failed sign-in attempt from Plex user without access to the media server',
|
||||||
{
|
{
|
||||||
label: 'Auth',
|
label: 'API',
|
||||||
account: {
|
account: {
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
password: '__REDACTED__',
|
userId: user.id,
|
||||||
|
plexId: user.plexId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return next({
|
return next({
|
||||||
status: 403,
|
status: 403,
|
||||||
message: 'Your sign-in credentials are incorrect.',
|
message: 'Access denied.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,13 +267,18 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
|
|
||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong while attempting to authenticate.', {
|
logger.error(
|
||||||
label: 'Auth',
|
'Something went wrong authenticating with Overseerr password',
|
||||||
error: e.message,
|
{
|
||||||
});
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
ip: req.ip,
|
||||||
|
email: body.email,
|
||||||
|
}
|
||||||
|
);
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Something went wrong.',
|
message: 'Unable to authenticate.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -221,7 +288,7 @@ authRoutes.post('/logout', (req, res, next) => {
|
|||||||
if (err) {
|
if (err) {
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Something went wrong while attempting to sign out.',
|
message: 'Something went wrong.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,14 +296,15 @@ authRoutes.post('/logout', (req, res, next) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
authRoutes.post('/reset-password', async (req, res) => {
|
authRoutes.post('/reset-password', async (req, res, next) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const body = req.body as { email?: string };
|
const body = req.body as { email?: string };
|
||||||
|
|
||||||
if (!body.email) {
|
if (!body.email) {
|
||||||
return res
|
return next({
|
||||||
.status(500)
|
status: 500,
|
||||||
.json({ error: 'You must provide an email address.' });
|
message: 'Email address required.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userRepository
|
const user = await userRepository
|
||||||
@@ -247,14 +315,16 @@ authRoutes.post('/reset-password', async (req, res) => {
|
|||||||
if (user) {
|
if (user) {
|
||||||
await user.resetPassword();
|
await user.resetPassword();
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
logger.info('Successful request made for recovery link.', {
|
logger.info('Successfully sent password reset link', {
|
||||||
label: 'User Management',
|
label: 'API',
|
||||||
context: { ip: req.ip, email: body.email },
|
ip: req.ip,
|
||||||
|
email: body.email,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info('Failed request made to reset a password.', {
|
logger.error('Something went wrong sending password reset link', {
|
||||||
label: 'User Management',
|
label: 'API',
|
||||||
context: { ip: req.ip, email: body.email },
|
ip: req.ip,
|
||||||
|
email: body.email,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,15 +334,16 @@ authRoutes.post('/reset-password', async (req, res) => {
|
|||||||
authRoutes.post('/reset-password/:guid', async (req, res, next) => {
|
authRoutes.post('/reset-password/:guid', async (req, res, next) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
try {
|
|
||||||
if (!req.body.password || req.body.password?.length < 8) {
|
if (!req.body.password || req.body.password?.length < 8) {
|
||||||
const message =
|
logger.warn('Failed password reset attempt using invalid new password', {
|
||||||
'Failed to reset password. Password must be at least 8 characters long.';
|
label: 'API',
|
||||||
logger.info(message, {
|
ip: req.ip,
|
||||||
label: 'User Management',
|
guid: req.params.guid,
|
||||||
context: { ip: req.ip, guid: req.params.guid },
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Password must be at least 8 characters long.',
|
||||||
});
|
});
|
||||||
return next({ status: 500, message: message });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userRepository.findOne({
|
const user = await userRepository.findOne({
|
||||||
@@ -280,32 +351,44 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Guid invalid.');
|
logger.warn('Failed password reset attempt using invalid recovery link', {
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
guid: req.params.guid,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Invalid password reset link.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!user.recoveryLinkExpirationDate ||
|
!user.recoveryLinkExpirationDate ||
|
||||||
user.recoveryLinkExpirationDate <= new Date()
|
user.recoveryLinkExpirationDate <= new Date()
|
||||||
) {
|
) {
|
||||||
throw new Error('Recovery link expired.');
|
logger.warn('Failed password reset attempt using expired recovery link', {
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
guid: req.params.guid,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Invalid password reset link.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await user.setPassword(req.body.password);
|
await user.setPassword(req.body.password);
|
||||||
user.recoveryLinkExpirationDate = null;
|
user.recoveryLinkExpirationDate = null;
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
logger.info(`Successfully reset password`, {
|
logger.info('Successfully reset password', {
|
||||||
label: 'User Management',
|
label: 'API',
|
||||||
context: { ip: req.ip, guid: req.params.guid, email: user.email },
|
ip: req.ip,
|
||||||
|
guid: req.params.guid,
|
||||||
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json({ status: 'ok' });
|
return res.status(200).json({ status: 'ok' });
|
||||||
} catch (e) {
|
|
||||||
logger.info(`Failed to reset password. ${e.message}`, {
|
|
||||||
label: 'User Management',
|
|
||||||
context: { ip: req.ip, guid: req.params.guid },
|
|
||||||
});
|
|
||||||
return res.status(200).json({ status: 'ok' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default authRoutes;
|
export default authRoutes;
|
||||||
|
Reference in New Issue
Block a user