added password validation; changed create account layout

Password validation added, configurable under the [password_validation]
section in config.ini. Each criterion is displayed next to the form, in
red or green depending on whether the password passes it. form.html now
looks different because of it, whether it is enabled or disabled. An
error message is now displayed if the user already exists.
This commit is contained in:
Harvey Tindall 2020-04-14 21:31:44 +01:00
parent 0ab93990e8
commit 690de58e9f
7 changed files with 261 additions and 56 deletions

View File

@ -102,6 +102,18 @@ help_message = Enter your details to create an account.
; Displayed when an account is created.
success_message = Your account has been created. Click below to continue to Jellyfin.
[password_validation]
; Enables password validation.
enabled = true
; Min. password length
min_length = 8
; Min. number of uppercase characters
upper = 1
; Min. number of numbers
number = 1
; Min. number of special characters
special = 0
[email]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true

View File

@ -23,6 +23,18 @@ help_message = Enter your details to create an account.
; Displayed when an account is created.
success_message = Your account has been created. Click below to continue to Jellyfin.
[password_validation]
; Enables password validation.
enabled = true
; Min. password length
min_length = 8
; Min. number of uppercase characters
upper = 1
; Min. number of numbers
number = 1
; Min. number of special characters
special = 0
[email]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true

View File

@ -10,20 +10,16 @@
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<style>
.pageTitle {
.pageContainer {
margin: 20%;
margin-bottom: 5%;
}
.accountForm {
margin: 20%;
margin-bottom: 5%;
margin-top: 5%;
}
.contactBox {
margin: 20%;
margin-top: 5%;
color: grey;
}
#container {
margin-top: 5%;
margin-bottom: 5%;
}
</style>
<title>Create Jellyfin Account</title>
</head>
@ -43,13 +39,18 @@
</div>
</div>
</div>
<div class="pageTitle">
<div class="pageContainer">
<h1>
Create Account
</h1>
<p>{{ helpMessage }}</p>
</div>
<div class="accountForm" id="accountForm">
<p class="contactBox">{{ contactMessage }}</p>
<div class="container" id="container">
<div class="row" id="cardContainer">
<div class="col-sm" id="accountForm">
<div class="card bg-light mb-3">
<div class="card-header">Details</div>
<div class="card-body">
<form action="#" method="POST">
<div class="form-group">
<label for="inputEmail">Email</label>
@ -63,24 +64,101 @@
<label for="inputPassword">Password</label>
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="Password" required>
</div>
<input type="submit" class="btn btn-primary" value="Create Account">
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox">
<input type="submit" class="btn btn-outline-primary" value="Create Account">
</div>
</form>
</div>
<div class="contactBox">
<p>{{ contactMessage }}</p>
</div>
</div>
</div>
</div>
</div>
<script>
var code = window.location.href.split('/').pop();
$.ajax('/getRequirements', {
data : JSON.stringify({ 'code': code }),
contentType : 'application/json',
type : 'POST',
crossDomain : true,
complete : function(response){
var data = response['responseJSON'];
if (Object.keys(data).length > 1) {
var col = document.createElement('div');
col.classList.add('col-sm');
col.id = 'requirementBox';
var card = document.createElement('div');
card.classList.add('card', 'bg-light', 'mb-3', 'requirementBox');
// card.setAttribute('style', 'max-width: 18rem;');
var header = document.createElement('div');
header.classList.add('card-header');
header.appendChild(document.createTextNode('Password Requirements'));
card.appendChild(header);
var body = document.createElement('div');
body.classList.add('card-body');
var listGroup = document.createElement('ul');
listGroup.classList.add('list-group');
listGroup.id = 'requirements';
for (var key in data) {
if (data.hasOwnProperty(key)) {
var criterion = document.createElement('li');
criterion.id = key;
criterion.classList.add('list-group-item', 'list-group-item-danger');
var text = document.createElement('div');
text.appendChild(document.createTextNode(' ' + data[key]));
criterion.appendChild(text);
listGroup.appendChild(criterion);
};
};
body.appendChild(listGroup);
card.appendChild(body);
col.appendChild(card);
document.getElementById('cardContainer').appendChild(col);
};
}
});
$("form").submit(function() {
var send = $("form").serializeObject();
send['code'] = window.location.href.split('/').pop();
send['code'] = code;
send = JSON.stringify(send);
$.ajax('/newUser', {
data : send,
contentType : 'application/json',
type : 'POST',
crossDomain: true,
success : function(){
crossDomain : true,
complete : function(response){
var data = response['responseJSON'];
if ('error' in data) {
var text = document.createTextNode(data['error']);
// <div class="alert alert-danger" id="errorBox"></div>
var error = document.createElement('button');
error.classList.add('btn', 'btn-outline-danger');
error.setAttribute('disabled', '');
error.appendChild(text);
document.getElementById('errorBox').appendChild(error);
} else {
var valid = true
for (var key in data) {
if (data.hasOwnProperty(key)) {
var criterion = document.getElementById(key);
if (data[key] == false) {
valid = false;
if (criterion.classList.contains('list-group-item-success')) {
criterion.classList.remove('list-group-item-success');
criterion.classList.add('list-group-item-danger');
};
} else {
if (criterion.classList.contains('list-group-item-danger')) {
criterion.classList.remove('list-group-item-danger');
criterion.classList.add('list-group-item-success');
};
};
};
};
if (valid == true) {
$('#successBox').modal('show');
};
}
}
});
return false;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -0,0 +1,45 @@
specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')',
'<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']']
class PasswordValidator:
def __init__(self, min_length, upper, number, special):
self.criteria = {'characters': int(min_length),
'uppercase characters': int(upper),
'numbers': int(number),
'special characters': int(special)}
def validate(self, password):
count = {'characters': 0,
'uppercase characters': 0,
'numbers': 0,
'special characters': 0}
for c in password:
count['characters'] += 1
if c.isupper():
count['uppercase characters'] += 1
elif c.isnumeric():
count['numbers'] += 1
elif c in specials:
count['special characters'] += 1
for criterion in count:
if count[criterion] < self.criteria[criterion]:
count[criterion] = False
else:
count[criterion] = True
return count
def getCriteria(self):
lines = {}
for criterion in self.criteria:
min = self.criteria[criterion]
if min > 0:
text = f"Must have at least {min} "
if min == 1:
text += criterion[:-1]
else:
text += criterion
lines[criterion] = text
return lines

View File

@ -1,12 +1,14 @@
from flask import request, jsonify
from configparser import RawConfigParser
from jellyfin_accounts.jf_api import Jellyfin
import json
import datetime
import secrets
import time
from __main__ import config, app, g
from __main__ import config, config_path, app, g
from __main__ import web_log as log
from jellyfin_accounts.login import auth
from jellyfin_accounts.validate_password import PasswordValidator
def resp(success=True, code=500):
if success:
@ -57,20 +59,52 @@ while attempts != 3:
log.info(('Successfully authenticated with ' +
config['jellyfin']['server']))
break
except jellyfin_accounts.jf_api.AuthenticationError:
except Jellyfin.AuthenticationError:
attempts += 1
log.error(('Failed to authenticate with ' +
config['jellyfin']['server'] +
'. Retrying...'))
time.sleep(5)
if config.getboolean('password_validation', 'enabled'):
validator = PasswordValidator(config['password_validation']['min_length'],
config['password_validation']['upper'],
config['password_validation']['number'],
config['password_validation']['special'])
else:
validator = PasswordValidator(0, 0, 0, 0)
@app.route('/getRequirements', methods=['GET', 'POST'])
def getRequirements():
data = request.get_json()
log.debug('Password Requirements requested')
if checkInvite(data['code']):
return jsonify(validator.getCriteria())
@app.route('/newUser', methods=['GET', 'POST'])
def newUser():
data = request.get_json()
log.debug('Attempted newUser')
if checkInvite(data['code'], delete=True):
if checkInvite(data['code']):
validation = validator.validate(data['password'])
valid = True
for criterion in validation:
if validation[criterion] == False:
valid = False
if valid:
log.debug('User password valid')
try:
user = jf.newUser(data['username'], data['password'])
except Jellyfin.UserExistsError:
error = 'User already exists with name '
error += data['username']
log.debug(error)
return jsonify({'error': error})
except:
return jsonify({'error': 'Unknown error'})
checkInvite(data['code'], delete=True)
if user.status_code == 200:
try:
with open(config['files']['user_template'], 'r') as f:
@ -78,7 +112,6 @@ def newUser():
jf.setPolicy(user.json()['Id'], default_policy)
except:
log.debug('setPolicy failed')
pass
if config.getboolean('email', 'enabled'):
try:
with open(config['files']['emails'], 'r') as f:
@ -90,10 +123,12 @@ def newUser():
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('User password invalid')
return jsonify(validation)
else:
log.debug('Attempted newUser unauthorized')
return resp(False, code=401)
@ -176,4 +211,27 @@ def get_token():
return jsonify({'token': token.decode('ascii')})
@app.route('/modifyConfig', methods=['POST'])
@auth.login_required
def modifyConfig():
log.info('Config modification requested')
data = request.get_json()
temp_config = RawConfigParser(comment_prefixes='/',
allow_no_value=True)
temp_config.read(config_path)
for section in data:
if section in temp_config:
for item in data[section]:
if item in temp_config[section]:
temp_config[section][item] = data[section][item]
data[section][item] = True
log.debug(f'{section}/{item} modified')
else:
data[section][item] = False
log.debug(f'{section}/{item} does not exist in config')
with open(config_path, 'w') as config_file:
temp_config.write(config_file)
log.debug('Config written')
return jsonify(data)