Add pw reset support; add logging

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

View File

@@ -7,6 +7,7 @@ from itsdangerous import (TimedJSONWebSignatureSerializer
from passlib.apps import custom_app_context as pwd_context
import uuid
from __main__ import config, app, g
from __main__ import auth_log as log
class Account():
@@ -44,11 +45,13 @@ def verify_password(username, password):
if not user:
if username == adminAccount.username and adminAccount.verify_password(password):
g.user = adminAccount
print(g)
log.debug("HTTPAuth Allowed")
return True
else:
log.debug("HTTPAuth Denied")
return False
g.user = adminAccount
log.debug("HTTPAuth Allowed")
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 flask import Flask, send_from_directory, render_template
from __main__ import config, app, g
from __main__ import web_log as log
@app.errorhandler(404)
@@ -29,6 +30,7 @@ from jellyfin_accounts.web_api import checkInvite
@app.route('/invite/<path:path>')
def inviteProxy(path):
if checkInvite(path):
log.info(f'Invite {path} used to request form')
return render_template('form.html',
contactMessage=config['ui']['contact_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:
return app.send_static_file(path)
else:
log.debug('Attempted use of invalid invite')
return render_template('invalidCode.html',
contactMessage=config['ui']['contact_message'])

View File

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