mirror of
https://github.com/hrfee/jellyfin-accounts.git
synced 2025-12-08 11:39:33 +00:00
Add pw reset support; add logging
This commit is contained in:
@@ -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
194
jellyfin_accounts/pw_reset.py
Executable 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()
|
||||
@@ -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'])
|
||||
|
||||
@@ -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')})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user