mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(login): add local users functionality (#591)
This commit is contained in:
@@ -9,6 +9,12 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Permission, hasPermission } from '../lib/permissions';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import path from 'path';
|
||||
import PreparedEmail from '../lib/email';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { default as generatePassword } from 'secure-random-password';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@@ -16,7 +22,7 @@ export class User {
|
||||
return users.map((u) => u.filter());
|
||||
}
|
||||
|
||||
static readonly filteredFields: string[] = ['plexToken'];
|
||||
static readonly filteredFields: string[] = ['plexToken', 'password'];
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
@@ -27,8 +33,14 @@ export class User {
|
||||
@Column()
|
||||
public username: string;
|
||||
|
||||
@Column({ select: false })
|
||||
public plexId: number;
|
||||
@Column({ nullable: true, select: false })
|
||||
public password?: string;
|
||||
|
||||
@Column({ type: 'integer', default: 1 })
|
||||
public userType = 1;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public plexId?: number;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public plexToken?: string;
|
||||
@@ -69,4 +81,47 @@ export class User {
|
||||
public hasPermission(permissions: Permission | Permission[]): boolean {
|
||||
return !!hasPermission(permissions, this.permissions);
|
||||
}
|
||||
|
||||
public passwordMatch(password: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.password) {
|
||||
resolve(bcrypt.compare(password, this.password));
|
||||
} else {
|
||||
return reject(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async setPassword(password: string): Promise<void> {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
this.password = hashedPassword;
|
||||
}
|
||||
|
||||
public async resetPassword(): Promise<void> {
|
||||
const password = generatePassword.randomPassword({ length: 16 });
|
||||
this.setPassword(password);
|
||||
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
logger.info(`Sending password email for ${this.email}`, {
|
||||
label: 'User creation',
|
||||
});
|
||||
const email = new PreparedEmail();
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../templates/email/password'),
|
||||
message: {
|
||||
to: this.email,
|
||||
},
|
||||
locals: {
|
||||
password: password,
|
||||
applicationUrl,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to send out password email', {
|
||||
label: 'User creation',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
38
server/lib/email/index.ts
Normal file
38
server/lib/email/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import Email from 'email-templates';
|
||||
import { getSettings } from '../settings';
|
||||
class PreparedEmail extends Email {
|
||||
public constructor() {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: settings.options.smtpHost,
|
||||
port: settings.options.smtpPort,
|
||||
secure: settings.options.secure,
|
||||
tls: settings.options.allowSelfSigned
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: undefined,
|
||||
auth:
|
||||
settings.options.authUser && settings.options.authPass
|
||||
? {
|
||||
user: settings.options.authUser,
|
||||
pass: settings.options.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
super({
|
||||
message: {
|
||||
from: {
|
||||
name: settings.options.senderName,
|
||||
address: settings.options.emailFrom,
|
||||
},
|
||||
},
|
||||
send: true,
|
||||
transport: transport,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PreparedEmail;
|
@@ -2,12 +2,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import path from 'path';
|
||||
import { getSettings, NotificationAgentEmail } from '../../settings';
|
||||
import nodemailer from 'nodemailer';
|
||||
import Email from 'email-templates';
|
||||
import logger from '../../../logger';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../../entity/User';
|
||||
import { Permission } from '../../permissions';
|
||||
import PreparedEmail from '../../email';
|
||||
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
@@ -35,42 +34,6 @@ class EmailAgent
|
||||
return false;
|
||||
}
|
||||
|
||||
private getSmtpTransport() {
|
||||
const emailSettings = this.getSettings().options;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: emailSettings.smtpHost,
|
||||
port: emailSettings.smtpPort,
|
||||
secure: emailSettings.secure,
|
||||
tls: emailSettings.allowSelfSigned
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: undefined,
|
||||
auth:
|
||||
emailSettings.authUser && emailSettings.authPass
|
||||
? {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private getNewEmail() {
|
||||
const settings = this.getSettings();
|
||||
return new Email({
|
||||
message: {
|
||||
from: {
|
||||
name: settings.options.senderName,
|
||||
address: settings.options.emailFrom,
|
||||
},
|
||||
},
|
||||
send: true,
|
||||
transport: this.getSmtpTransport(),
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMediaRequestEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
@@ -82,7 +45,7 @@ class EmailAgent
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
@@ -127,7 +90,7 @@ class EmailAgent
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
@@ -166,7 +129,7 @@ class EmailAgent
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
@@ -203,7 +166,7 @@ class EmailAgent
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
@@ -240,7 +203,7 @@ class EmailAgent
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||
|
43
server/migration/1610070934506-LocalUsers.ts
Normal file
43
server/migration/1610070934506-LocalUsers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class LocalUsers1610070934506 implements MigrationInterface {
|
||||
name = 'LocalUsers1610070934506';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
@@ -6,6 +6,7 @@ import { isAuthenticated } from '../middleware/auth';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { UserType } from '../../src/hooks/useUser';
|
||||
|
||||
const authRoutes = Router();
|
||||
|
||||
@@ -126,6 +127,53 @@ authRoutes.post('/login', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { email?: string; password?: string };
|
||||
|
||||
if (!body.email || !body.password) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'You must provide an email and a password' });
|
||||
}
|
||||
try {
|
||||
const user = await userRepository.findOne({
|
||||
select: ['id', 'password'],
|
||||
where: { email: body.email, userType: UserType.LOCAL },
|
||||
});
|
||||
|
||||
const isCorrectCredentials = await user?.passwordMatch(body.password);
|
||||
|
||||
// User doesn't exist or credentials are incorrect
|
||||
if (!isCorrectCredentials) {
|
||||
logger.info('Failed login attempt from user with incorrect credentials', {
|
||||
label: 'Auth',
|
||||
account: {
|
||||
email: body.email,
|
||||
password: '__REDACTED__',
|
||||
},
|
||||
});
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have access to this Plex server',
|
||||
});
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (user && req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/logout', (req, res, next) => {
|
||||
req.session?.destroy((err) => {
|
||||
if (err) {
|
||||
|
@@ -6,6 +6,7 @@ import { User } from '../entity/User';
|
||||
import { hasPermission, Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -19,13 +20,34 @@ router.get('/', async (_req, res) => {
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
|
||||
const body = req.body;
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||
const avatar = gravatarUrl(body.email);
|
||||
|
||||
if (!passedExplicitPassword && !settings.enabled) {
|
||||
throw new Error('Email notifications must be enabled');
|
||||
}
|
||||
|
||||
const user = new User({
|
||||
email: req.body.email,
|
||||
permissions: req.body.permissions,
|
||||
avatar: body.avatar ?? avatar,
|
||||
username: body.username ?? body.email,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
permissions: body.permissions,
|
||||
plexToken: '',
|
||||
userType: body.userType,
|
||||
});
|
||||
|
||||
if (passedExplicitPassword) {
|
||||
await user?.setPassword(body.password);
|
||||
} else {
|
||||
await user?.resetPassword();
|
||||
}
|
||||
|
||||
await userRepository.save(user);
|
||||
return res.status(201).json(user.filter());
|
||||
} catch (e) {
|
||||
|
98
server/templates/email/password/html.pug
Normal file
98
server/templates/email/password/html.pug
Normal file
@@ -0,0 +1,98 @@
|
||||
doctype html
|
||||
head
|
||||
meta(charset='utf-8')
|
||||
meta(name='x-apple-disable-message-reformatting')
|
||||
meta(http-equiv='x-ua-compatible' content='ie=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
@media (max-width: 600px) {
|
||||
.sm-w-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
table(style="\
|
||||
background-color: #f2f4f6;\
|
||||
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
|
||||
width: 100%;\
|
||||
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center')
|
||||
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='\
|
||||
font-size: 16px;\
|
||||
padding-top: 25px;\
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href=applicationUrl style='\
|
||||
text-shadow: 0 1px 0 #ffffff;\
|
||||
font-weight: 700;\
|
||||
font-size: 16px;\
|
||||
color: #a8aaaf;\
|
||||
text-decoration: none;\
|
||||
')
|
||||
| Overseerr
|
||||
tr
|
||||
td(style='width: 100%' width='100%')
|
||||
table.sm-w-full(align='center' style='\
|
||||
background-color: #ffffff;\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
width: 570px;\
|
||||
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(style='padding: 45px')
|
||||
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
|
||||
| Your new password is:
|
||||
div(style='font-size: 16px; text-align: center')
|
||||
| #{password}
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
color: #51545e;\
|
||||
')
|
||||
a(href=applicationUrl style='color: #3869d4') Open Overseerr
|
||||
tr
|
||||
td
|
||||
table.sm-w-full(align='center' style='\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
text-align: center;\
|
||||
width: 570px;\
|
||||
' width='570' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='font-size: 16px; padding: 45px')
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
text-align: center;\
|
||||
color: #a8aaaf;\
|
||||
')
|
||||
| Overseerr.
|
1
server/templates/email/password/subject.pug
Normal file
1
server/templates/email/password/subject.pug
Normal file
@@ -0,0 +1 @@
|
||||
= `Password reset - Overseerr`
|
Reference in New Issue
Block a user