Add pw reset support; add logging

This commit is contained in:
Harvey Tindall 2020-04-12 21:25:27 +01:00
parent 2e22e5f840
commit d22ba6133b
12 changed files with 680 additions and 27 deletions

5
.gitignore vendored
View File

@ -1,6 +1,9 @@
__pycache__ __pycache__/
notes.md notes.md
MANIFEST.in MANIFEST.in
dist/ dist/
build/ build/
test.txt
data/node_modules/
*.egg-info/ *.egg-info/
pw-reset/

View File

@ -1,8 +1,13 @@
# jellyfin-accounts # jellyfin-accounts
A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jellyfin). ***New: Now capable of sending password reset emails!***
A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin).
* Provides a web interface for creating invite codes
* Sends out emails when a user requests a password reset
* Uses a basic python jellyfin API client for communication with the server. * Uses a basic python jellyfin API client for communication with the server.
* Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress) * Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress)
* Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object) * Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object)
* Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja)
## Screenshots ## Screenshots
<p align="center"> <p align="center">
<img src="images/admin.png" width="45%"></img> <img src="images/create.png" width="45%"></img> <img src="images/admin.png" width="45%"></img> <img src="images/create.png" width="45%"></img>
@ -10,16 +15,23 @@ A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jel
## Get it ## Get it
### Requirements ### Requirements
* This should work anywhere Python does, i've tried to not use anything OS-specific. Drop an issue if you encounter issues, of course.
```
* python >= 3.6 * python >= 3.6
* flask * flask
* flask_httpauth * flask_httpauth
* jinja2
* requests * requests
* itsdangerous * itsdangerous
* passlib * passlib
* secrets * secrets
* configparser * configparser
* waitress * waitress
* pytz
* dateutil
* watchdog
```
### Install ### Install
``` ```
git clone https://github.com/hrfee/jellyfin-accounts.git git clone https://github.com/hrfee/jellyfin-accounts.git
@ -47,16 +59,21 @@ optional arguments:
``` ```
### Setup ### Setup
#### Policy template #### Policy template
* You may want to restrict from accessing certain libraries (e.g 4K Movies), or display their account on the login screen by default. Jellyfin stores these settings as a user's policy. * You may want to restrict a user from accessing certain libraries (e.g 4K Movies), or display their account on the login screen by default. Jellyfin stores these settings as a user's policy.
* Make a temporary account and change its settings, then run `jf-accounts --get_policy`. Choose your user, and the policy will be stored at the location you set in `user_template`, and used for all subsequent new accounts. * Make a temporary account and change its settings, then run `jf-accounts --get_policy`. Choose your user, and the policy will be stored at the location you set in `user_template`, and used for all subsequent new accounts.
#### Emails/Password Resets
* When someone initiates forget password on Jellyfin, a file named `passwordreset*.json` is created in its configuration directory. This directory is monitored and when created, the program reads the username, expiry time and PIN, puts it into a template and sends it to whatever address is specified in `emails.json`.
* **The default forget password popup references the `passwordreset*.json` file created. This is confusing for users, so a quick fix is to edit the `MessageForgotPasswordFileCreated` string in Jellyfin's language folder.**
* Currently, jellyfin-accounts supports generic SSL/TLS secured SMTP, and the [mailgun](https://mailgun.com) REST API.
* Email html is created using [mjml](https://mjml.io), and [jinja](https://github.com/pallets/jinja) templating is used. If you wish to create your own, ensure you use the same jinja expressions (`{{ pin }}`, etc.) as used in `data/email.mjml`, and also create a plain text version for legacy email clients.
### Configuration #### Configuration
* Note: Make sure to put this behind a reverse proxy with HTTPS. * Note: Make sure to put this behind a reverse proxy with HTTPS.
On first run, the default configuration is copied to `~/.jf-accounts/config.ini`. On first run, the default configuration is copied to `~/.jf-accounts/config.ini`.
``` ```
; It is reccommended to create a limited admin account for this program.
[jellyfin] [jellyfin]
; It is reccommended to create a limited admin account for this program.
username = username username = username
password = password password = password
; Server will also be used in the invite form, so make sure it's publicly accessible. ; Server will also be used in the invite form, so make sure it's publicly accessible.
@ -72,23 +89,56 @@ port = 8056
username = your username username = your username
password = your password password = your password
debug = false debug = false
; Enable to store request email address and store. Useful for sending password reset emails.
emails_enabled = false
; Displayed at the bottom of all pages except admin. ; Displayed at the bottom of all pages except admin
contact_message = Need help? contact me. contact_message = Need help? contact me.
; Displayed at top of form page. ; Displayed at top of form page.
help_message = Enter your details to create an account. help_message = Enter your details to create an account.
; Displayed when an account is created. ; Displayed when an account is created.
success_message = Your account has been created. Click below to continue to Jellyfin. success_message = Your account has been created. Click below to continue to Jellyfin.
[email]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
use_24h = true
; Date format follows datetime's strftime.
date_format = %-d/%-m/%-y
; Path to custom email html. If blank, uses the internal template.
email_template =
; Path to alternate plaintext email. If blank, uses the internal template.
email_plaintext =
; Displayed at bottom of emails
message = Need help? contact me.
; Mail methods: mailgun, smtp
method = mailgun
; Subject of emails
subject = Password Reset - Jellyfin
; Address to send from
address = jellyfin@jellyf.in
; The name of the sender
from = Jellyfin
[mailgun]
api_url = https://api.mailgun.net...
api_key = your api key
[smtp]
; Insecure SMTP hasn't been implemented, although I doubt many will need it.
ssl = true
server = smtp.jellyf.in
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
port = 465
password = smtp password
[files] [files]
; When the below paths are left blank, files are stored in ~/.jf-accounts/. ; When the below paths are left blank, files are stored in ~/.jf-accounts/.
; Path to store valid invites. ; Path to store valid invites.
invites = invites =
; Path to store emails in JSON ; Path to store email addresses in JSON
emails = emails =
; Path to the user policy template. Can be acquired with get-template. ; Path to the user policy template. Can be acquired with get-template.
user_template = user_template =

View File

@ -15,25 +15,57 @@ port = 8056
username = your username username = your username
password = your password password = your password
debug = false debug = false
; Enable to store request email address and store. Useful for sending password reset emails.
emails_enabled = false
; Displayed at the bottom of all pages except admin. ; Displayed at the bottom of all pages except admin
contact_message = Need help? contact me. contact_message = Need help? contact me.
; Displayed at top of form page. ; Displayed at top of form page.
help_message = Enter your details to create an account. help_message = Enter your details to create an account.
; Displayed when an account is created. ; Displayed when an account is created.
success_message = Your account has been created. Click below to continue to Jellyfin. success_message = Your account has been created. Click below to continue to Jellyfin.
[email]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
use_24h = true
; Date format follows datetime's strftime.
date_format = %-d/%-m/%-y
; Path to custom email html. If blank, uses the internal template.
email_template =
; Path to alternate plaintext email. If blank, uses the internal template.
email_plaintext =
; Displayed at bottom of emails
message = Need help? contact me.
; Mail methods: mailgun, smtp
method = smtp
; Subject of emails
subject = Password Reset - Jellyfin
; Address to send from
address = jellyfin@jellyf.in
; The name of the sender
from = Jellyfin
[mailgun]
api_url = https://api.mailgun.net...
api_key = your api key
[smtp]
; Insecure SMTP hasn't been implemented, although I doubt many will need it.
ssl = true
server = smtp.jellyf.in
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
port = 465
password = smtp password
[files] [files]
; When the below paths are left blank, files are stored in ~/.jf-accounts/. ; When the below paths are left blank, files are stored in ~/.jf-accounts/.
; Path to store valid invites. ; Path to store valid invites.
invites = invites =
; Path to store emails in JSON ; Path to store emails addresses in JSON
emails = emails =
; Path to the user policy template. Can be acquired with get-template. ; Path to the user policy template. Can be acquired with get-template.
user_template = user_template =

242
data/email.html Normal file
View File

@ -0,0 +1,242 @@
<!-- FILE: email.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style type="text/css">
</style>
</head>
<body>
<div style="">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Noto Sans;font-size:15px;line-height:1;text-align:left;color:#000000;">
<p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
<p style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
{{ pin }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>

34
data/email.mjml Normal file
View File

@ -0,0 +1,34 @@
<mjml>
<mj-head>
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="bold" font-size="20px">
Jellyfin
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="15px" font-family="Noto Sans">
<p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
</mj-text>
<mj-button>{{ pin }}</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="italic" font-size="10px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

10
data/email.txt Normal file
View File

@ -0,0 +1,10 @@
Hi {{ username }},
Someone has recently requests a password reset on Jellyfin.
If this was you, enter the below pin into the prompt.
This code will expire on {{ expiry_date }}, at {{ expiry_time }} , which is in {{ expires_in }}.
If this wasn't you, please ignore this email.
PIN: {{ pin }}
{{ message }}

View File

@ -7,6 +7,7 @@ from itsdangerous import (TimedJSONWebSignatureSerializer
from passlib.apps import custom_app_context as pwd_context from passlib.apps import custom_app_context as pwd_context
import uuid import uuid
from __main__ import config, app, g from __main__ import config, app, g
from __main__ import auth_log as log
class Account(): class Account():
@ -44,11 +45,13 @@ def verify_password(username, password):
if not user: if not user:
if username == adminAccount.username and adminAccount.verify_password(password): if username == adminAccount.username and adminAccount.verify_password(password):
g.user = adminAccount g.user = adminAccount
print(g) log.debug("HTTPAuth Allowed")
return True return True
else: else:
log.debug("HTTPAuth Denied")
return False return False
g.user = adminAccount g.user = adminAccount
log.debug("HTTPAuth Allowed")
return True return True

194
jellyfin_accounts/pw_reset.py Executable file
View File

@ -0,0 +1,194 @@
import time
import json
import os
import datetime
import pytz
import requests
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from dateutil import parser as date_parser
from jinja2 import Environment, FileSystemLoader, Template
from __main__ import config
from __main__ import email_log as log
class Email():
def __init__(self, address):
self.address = address
log.debug(f'{self.address}: Creating email')
self.content = {}
self.subject = config['email']['subject']
log.debug(f'{self.address}: Using subject {self.subject}')
self.from_address = config['email']['address']
self.from_name = config['email']['from']
log.debug((
f'{self.address}: Sending from {self.from_address} ' +
f'({self.from_name})'))
def construct(self, reset):
log.debug(f'{self.address}: Constructing email content')
try:
expiry = date_parser.parse(reset['ExpirationDate'])
expiry = expiry.replace(tzinfo=None)
except:
log.error(f"{self.address}: Couldn't parse expiry time")
return False
current_time = datetime.datetime.now()
if expiry >= current_time:
log.debug(f'{self.address}: Invite valid')
date = expiry.strftime(config['email']['date_format'])
if config.getboolean('email', 'use_24h'):
log.debug(f'{self.address}: Using 24h time')
time = expiry.strftime('%H:%M')
else:
log.debug(f'{self.address}: Using 12h time')
time = expiry.strftime('%-I:%M %p')
expiry_delta = (expiry - current_time).seconds
expires_in = {'hours': expiry_delta//3600,
'minutes': (expiry_delta//60) % 60}
if expires_in['hours'] == 0:
expires_in = f'{str(expires_in["minutes"])}m'
else:
expires_in = (f'{str(expires_in["hours"])}h ' +
f'{str(expires_in["minutes"])}m')
log.debug(f'{self.address}: Expires in {expires_in}')
sp = Path(config['email']['email_template']) / '..'
sp = str(sp.resolve()) + '/'
templateLoader = FileSystemLoader(searchpath=sp)
templateEnv = Environment(loader=templateLoader)
file_text = Path(config['email']['email_plaintext']).name
file_html = Path(config['email']['email_template']).name
template = {}
template['text'] = templateEnv.get_template(file_text)
template['html'] = templateEnv.get_template(file_html)
email_message = config['email']['message']
for key in template:
c = template[key].render(username=reset['UserName'],
expiry_date=date,
expiry_time=time,
expires_in=expires_in,
pin=reset['Pin'],
message=email_message)
self.content[key] = c
log.info(f'{self.address}: {key} constructed')
return True
else:
err = ((f"{self.address}: " +
"Reset has reportedly already expired. " +
"Ensure timezones are correctly configured."))
log.error(err)
return False
class Mailgun(Email):
def __init__(self, address):
super().__init__(address)
self.api_url = config['mailgun']['api_url']
self.api_key = config['mailgun']['api_key']
self.from_mg = f'{self.from_name} <{self.from_address}>'
def send(self):
response = requests.post(self.api_url,
auth=("api", self.api_key),
data={"from": self.from_mg,
"to": [self.address],
"subject": self.subject,
"text": self.content['text'],
"html": self.content['html']})
if response.ok:
log.info(f'{self.address}: Sent via mailgun.')
return True
log.debug(f'{self.address}: Mailgun: {response.status_code}')
return response
class Smtp(Email):
def __init__(self, address):
super().__init__(address)
self.server = config['smtp']['server']
self.password = config['smtp']['password']
try:
self.port = int(config['smtp']['port'])
except ValueError:
self.port = 465
log.debug(f'{self.address}: Defaulting to port {self.port}')
self.context = ssl.create_default_context()
def send(self):
message = MIMEMultipart("alternative")
message["Subject"] = self.subject
message["From"] = self.from_address
message["To"] = self.address
text = MIMEText(self.content['text'], 'plain')
html = MIMEText(self.content['html'], 'html')
message.attach(text)
message.attach(html)
try:
with smtplib.SMTP_SSL(self.server,
self.port,
context=self.context) as server:
server.login(self.from_address, self.password)
server.sendmail(self.from_address,
self.address,
message.as_string())
log.info(f'{self.address}: Sent via smtp')
return True
except Exception as e:
err = f'{self.address}: Failed to send via smtp: '
err += type(e).__name__
log.error(err)
class Watcher:
def __init__(self, dir):
self.observer = Observer()
self.dir = str(dir)
def run(self):
event_handler = Handler()
self.observer.schedule(event_handler, self.dir, recursive=True)
self.observer.start()
try:
while True:
time.sleep(5)
except:
self.observer.stop()
log.info('Watchdog stopped')
class Handler(FileSystemEventHandler):
@staticmethod
def on_any_event(event):
if event.is_directory:
return None
elif (event.event_type == 'created' and
'passwordreset' in event.src_path):
with open(event.src_path, 'r') as f:
reset = json.load(f)
log.info(f'New password reset for {reset["UserName"]}')
try:
with open(config['files']['emails'], 'r') as f:
emails = json.load(f)
address = emails[reset['UserName']]
method = config['email']['method']
if method == 'mailgun':
email = Mailgun(address)
elif method == 'smtp':
email = Smtp(address)
if email.construct(reset):
email.send()
except (FileNotFoundError,
json.decoder.JSONDecodeError,
IndexError) as e:
err = f'{address}: Failed: ' + type(e).__name__
log.error(err)
def start():
log.info(f'Monitoring {config["email"]["watch_directory"]}')
w = Watcher(config['email']['watch_directory'])
w.run()

View File

@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from flask import Flask, send_from_directory, render_template from flask import Flask, send_from_directory, render_template
from __main__ import config, app, g from __main__ import config, app, g
from __main__ import web_log as log
@app.errorhandler(404) @app.errorhandler(404)
@ -29,6 +30,7 @@ from jellyfin_accounts.web_api import checkInvite
@app.route('/invite/<path:path>') @app.route('/invite/<path:path>')
def inviteProxy(path): def inviteProxy(path):
if checkInvite(path): if checkInvite(path):
log.info(f'Invite {path} used to request form')
return render_template('form.html', return render_template('form.html',
contactMessage=config['ui']['contact_message'], contactMessage=config['ui']['contact_message'],
helpMessage=config['ui']['help_message'], helpMessage=config['ui']['help_message'],
@ -37,5 +39,6 @@ def inviteProxy(path):
elif 'admin.html' not in path and 'admin.html' not in path: elif 'admin.html' not in path and 'admin.html' not in path:
return app.send_static_file(path) return app.send_static_file(path)
else: else:
log.debug('Attempted use of invalid invite')
return render_template('invalidCode.html', return render_template('invalidCode.html',
contactMessage=config['ui']['contact_message']) contactMessage=config['ui']['contact_message'])

View File

@ -3,7 +3,9 @@ from jellyfin_accounts.jf_api import Jellyfin
import json import json
import datetime import datetime
import secrets import secrets
import time
from __main__ import config, app, g from __main__ import config, app, g
from __main__ import web_log as log
from jellyfin_accounts.login import auth from jellyfin_accounts.login import auth
def resp(success=True, code=500): def resp(success=True, code=500):
@ -28,6 +30,8 @@ def checkInvite(code, delete=False):
expiry = datetime.datetime.strptime(i['valid_till'], expiry = datetime.datetime.strptime(i['valid_till'],
'%Y-%m-%dT%H:%M:%S.%f') '%Y-%m-%dT%H:%M:%S.%f')
if current_time >= expiry: if current_time >= expiry:
log.debug(('Housekeeping: Deleting old invite ' +
invites['invites'][index]['code']))
del invites['invites'][index] del invites['invites'][index]
else: else:
if i['code'] == code: if i['code'] == code:
@ -45,13 +49,26 @@ jf = Jellyfin(config['jellyfin']['server'],
config['jellyfin']['device'], config['jellyfin']['device'],
config['jellyfin']['device_id']) config['jellyfin']['device_id'])
jf.authenticate(config['jellyfin']['username'], attempts = 0
while attempts != 3:
try:
jf.authenticate(config['jellyfin']['username'],
config['jellyfin']['password']) config['jellyfin']['password'])
log.info(('Successfully authenticated with ' +
config['jellyfin']['server']))
break
except jellyfin_accounts.jf_api.AuthenticationError:
attempts += 1
log.error(('Failed to authenticate with ' +
config['jellyfin']['server'] +
'. Retrying...'))
time.sleep(5)
@app.route('/newUser', methods=['GET', 'POST']) @app.route('/newUser', methods=['GET', 'POST'])
def newUser(): def newUser():
data = request.get_json() data = request.get_json()
log.debug('Attempted newUser')
if checkInvite(data['code'], delete=True): if checkInvite(data['code'], delete=True):
user = jf.newUser(data['username'], data['password']) user = jf.newUser(data['username'], data['password'])
if user.status_code == 200: if user.status_code == 200:
@ -60,8 +77,9 @@ def newUser():
default_policy = json.load(f) default_policy = json.load(f)
jf.setPolicy(user.json()['Id'], default_policy) jf.setPolicy(user.json()['Id'], default_policy)
except: except:
log.debug('setPolicy failed')
pass pass
if config['ui']['emails_enabled'] == 'true': if config.getboolean('email', 'enabled'):
try: try:
with open(config['files']['emails'], 'r') as f: with open(config['files']['emails'], 'r') as f:
emails = json.load(f) emails = json.load(f)
@ -70,10 +88,14 @@ def newUser():
emails[data['username']] = data['email'] emails[data['username']] = data['email']
with open(config['files']['emails'], 'w') as f: with open(config['files']['emails'], 'w') as f:
f.write(json.dumps(emails, indent=4)) f.write(json.dumps(emails, indent=4))
log.debug('Email address stored')
log.info('New User created.')
return resp() return resp()
else: else:
log.error(f'New user creation failed: {user.status_code}')
return resp(False) return resp(False)
else: else:
log.debug('Attempted newUser unauthorized')
return resp(False, code=401) return resp(False, code=401)
@ -85,6 +107,7 @@ def generateInvite():
delta = datetime.timedelta(hours=int(data['hours']), delta = datetime.timedelta(hours=int(data['hours']),
minutes=int(data['minutes'])) minutes=int(data['minutes']))
invite = {'code': secrets.token_urlsafe(16)} invite = {'code': secrets.token_urlsafe(16)}
log.debug(f'Creating new invite: {invite["code"]}')
invite['valid_till'] = (current_time + invite['valid_till'] = (current_time +
delta).strftime('%Y-%m-%dT%H:%M:%S.%f') delta).strftime('%Y-%m-%dT%H:%M:%S.%f')
try: try:
@ -95,12 +118,14 @@ def generateInvite():
invites['invites'].append(invite) invites['invites'].append(invite)
with open(config['files']['invites'], 'w') as f: with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(invites, indent=4, default=str)) f.write(json.dumps(invites, indent=4, default=str))
log.info(f'New invite created: {invite["code"]}')
return resp() return resp()
@app.route('/getInvites', methods=['GET']) @app.route('/getInvites', methods=['GET'])
@auth.login_required @auth.login_required
def getInvites(): def getInvites():
log.debug('Invites requested')
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
try: try:
with open(config['files']['invites'], 'r') as f: with open(config['files']['invites'], 'r') as f:
@ -111,6 +136,8 @@ def getInvites():
for index, i in enumerate(invites['invites']): for index, i in enumerate(invites['invites']):
expiry = datetime.datetime.strptime(i['valid_till'], '%Y-%m-%dT%H:%M:%S.%f') expiry = datetime.datetime.strptime(i['valid_till'], '%Y-%m-%dT%H:%M:%S.%f')
if current_time >= expiry: if current_time >= expiry:
log.debug(('Housekeeping: Deleting old invite ' +
invites['invites'][index]['code']))
del invites['invites'][index] del invites['invites'][index]
else: else:
valid_for = expiry - current_time valid_for = expiry - current_time
@ -137,6 +164,7 @@ def deleteInvite():
del invites['invites'][index] del invites['invites'][index]
with open(config['files']['invites'], 'w') as f: with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(invites, indent=4, default=str)) f.write(json.dumps(invites, indent=4, default=str))
log.info(f'Invite deleted: {code}')
return resp() return resp()
@ -144,6 +172,7 @@ def deleteInvite():
@auth.login_required @auth.login_required
def get_token(): def get_token():
token = g.user.generate_token() token = g.user.generate_token()
log.debug('Token generated')
return jsonify({'token': token.decode('ascii')}) return jsonify({'token': token.decode('ascii')})

View File

@ -1,9 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import secrets import secrets
import configparser import configparser
import shutil import shutil
import argparse import argparse
import logging
import threading
import signal
import sys
from pathlib import Path from pathlib import Path
from flask import Flask, g from flask import Flask, g
@ -48,18 +51,49 @@ if not data_dir.exists():
else: else:
config_path = data_dir / 'config.ini' config_path = data_dir / 'config.ini'
config = configparser.ConfigParser() config = configparser.RawConfigParser()
config.read(config_path) config.read(config_path)
def create_log(name):
log = logging.getLogger(name)
handler = logging.StreamHandler(sys.stdout)
if config.getboolean('ui', 'debug'):
log.setLevel(logging.DEBUG)
handler.setLevel(logging.DEBUG)
else:
log.setLevel(logging.INFO)
handler.setLevel(logging.INFO)
fmt = ' %(name)s - %(levelname)s - %(message)s'
format = logging.Formatter(fmt)
handler.setFormatter(format)
log.addHandler(handler)
log.propagate = False
return log
log = create_log('main')
email_log = create_log('emails')
web_log = create_log('waitress')
auth_log = create_log('auth')
if args.host is not None: if args.host is not None:
log.debug(f'Using specified host {args.host}')
config['ui']['host'] = args.host config['ui']['host'] = args.host
if args.port is not None: if args.port is not None:
log.debug(f'Using specified port {args.port}')
config['ui']['port'] = args.port config['ui']['port'] = args.port
for key in config['files']: for key in config['files']:
if config['files'][key] == '': if config['files'][key] == '':
log.debug(f'Using default {key}')
config['files'][key] = str(data_dir / (key + '.json')) config['files'][key] = str(data_dir / (key + '.json'))
if config['email']['email_template'] == '':
log.debug('Using default email HTML template')
config['email']['email_template'] = str(local_dir / 'email.html')
if config['email']['email_plaintext'] == '':
log.debug('Using default email plaintext template')
config['email']['email_plaintext'] = str(local_dir / 'email.txt')
if args.get_policy: if args.get_policy:
import json import json
from jellyfin_accounts.jf_api import Jellyfin from jellyfin_accounts.jf_api import Jellyfin
@ -85,6 +119,12 @@ if args.get_policy:
print(f'Policy written to "{config["files"]["user_template"]}".') print(f'Policy written to "{config["files"]["user_template"]}".')
print('In future, this policy will be copied to all new users.') print('In future, this policy will be copied to all new users.')
else: else:
def signal_handler(sig, frame):
print('Quitting...')
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
app = Flask(__name__, root_path=str(local_dir)) app = Flask(__name__, root_path=str(local_dir))
app.config['DEBUG'] = config.getboolean('ui', 'debug') app.config['DEBUG'] = config.getboolean('ui', 'debug')
app.config['SECRET_KEY'] = secrets.token_urlsafe(16) app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
@ -92,8 +132,18 @@ else:
if __name__ == '__main__': if __name__ == '__main__':
import jellyfin_accounts.web_api import jellyfin_accounts.web_api
import jellyfin_accounts.web import jellyfin_accounts.web
print(jellyfin_accounts.web.__file__)
from waitress import serve from waitress import serve
host = config['ui']['host']
port = config['ui']['port']
log.info(f'Starting web UI on {host}:{port}')
if config.getboolean('email', 'enabled'):
def start_pwr():
import jellyfin_accounts.pw_reset
jellyfin_accounts.pw_reset.start()
pwr = threading.Thread(target=start_pwr, daemon=True)
log.info('Starting email thread')
pwr.start()
serve(app, serve(app,
host=config['ui']['host'], host=host,
port=int(config['ui']['port'])) port=int(port))

View File

@ -20,7 +20,10 @@ setup(
], ],
packages=find_packages(), packages=find_packages(),
# include_package_data=True, # include_package_data=True,
data_files=[('data', ['data/config-default.ini']), data_files=[('data', ['data/config-default.ini',
'data/email.html',
'data/email.mjml',
'data/email.txt']),
('data/static', ['data/static/admin.js']), ('data/static', ['data/static/admin.js']),
('data/templates', [ ('data/templates', [
'data/templates/404.html', 'data/templates/404.html',