From 80acb7b8759c6a7167f837a337466f519867a080 Mon Sep 17 00:00:00 2001 From: Pavel Eis Date: Wed, 15 Sep 2021 09:08:03 +0200 Subject: [PATCH 1/7] NERDweb: Reworks user managament Adds registration of local user, email verification for locally registered user. User passwords are now stored directly in the database. --- NERDweb/nerd_main.py | 71 ++---- NERDweb/templates/layout.html | 22 +- NERDweb/templates/login.html | 33 +++ NERDweb/templates/not_verified.html | 15 ++ NERDweb/templates/password_reset.html | 28 ++ NERDweb/templates/password_reset_request.html | 21 ++ .../templates/password_strength_check.html | 69 +++++ NERDweb/templates/user_registration.html | 64 +++++ NERDweb/user_management.py | 240 ++++++++++++++++++ NERDweb/userdb.py | 101 +++++++- install/create_user_db.sql | 6 +- install/pip_requirements_nerdweb.txt | 2 + 12 files changed, 608 insertions(+), 64 deletions(-) create mode 100644 NERDweb/templates/login.html create mode 100644 NERDweb/templates/not_verified.html create mode 100644 NERDweb/templates/password_reset.html create mode 100644 NERDweb/templates/password_reset_request.html create mode 100644 NERDweb/templates/password_strength_check.html create mode 100644 NERDweb/templates/user_registration.html create mode 100644 NERDweb/user_management.py diff --git a/NERDweb/nerd_main.py b/NERDweb/nerd_main.py index a238bf85..d2fa7358 100644 --- a/NERDweb/nerd_main.py +++ b/NERDweb/nerd_main.py @@ -8,19 +8,21 @@ import ipaddress import struct import hashlib +from ipaddress import IPv4Address, AddressValueError + import requests import flask -from flask import Flask, request, make_response, g, jsonify, json, flash, redirect, session, Response +from flask import Flask, request, make_response, g, jsonify, json, flash, redirect, session, Response, Markup, url_for from flask_pymongo import pymongo, PyMongo import pymongo.errors from flask_wtf import FlaskForm -from flask_mail import Mail, Message +from flask_mail import Mail from wtforms import validators, TextField, TextAreaField, FloatField, IntegerField, BooleanField, HiddenField, SelectField, SelectMultipleField, PasswordField import dateutil.parser import pymisp from pymisp import ExpandedPyMISP -from ipaddress import IPv4Address, AddressValueError -from event_count_logger import EventCountLogger, EventGroup, DummyEventGroup + +from event_count_logger import EventCountLogger, DummyEventGroup # Add to path the "one directory above the current file location" sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))) @@ -119,6 +121,11 @@ # **** Create and initialize Flask application ***** +# Override render_template to always include some variables +def render_template(template, **kwargs): + return flask.render_template(template, config=config, config_tags=config_tags['tags'], userdb=userdb, user=g.user, + ac=g.ac, verified=g.verified, **kwargs) + app = Flask(__name__) app.secret_key = config.get('secret_key') @@ -159,7 +166,6 @@ app.config['WTF_CSRF_ENABLED'] = False #app.config['WTF_CSRF_CHECK_DEFAULT'] = False - # ***** Jinja2 filters ***** # Datetime filters @@ -415,6 +421,7 @@ def logout(): ) +# TODO user - move this to user management source file: https://stackoverflow.com/questions/15446276/before-request-for-multiple-blueprints @app.before_request def store_user_info(): """Store user info to 'g' (request-wide global variable)""" @@ -447,7 +454,8 @@ def store_user_info(): else: # Normal authentication using session cookie - g.user, g.ac = get_user_info(session) + g.user, g.ac, verified = get_user_info(session) + g.verified = False if g.user is not None and not verified else True def exceeded_rate_limit(user_id): @@ -516,21 +524,11 @@ def add_user_header(resp): return resp -# ***** Override render_template to always include some variables ***** - -def render_template(template, **kwargs): - return flask.render_template(template, config=config, config_tags=config_tags['tags'], userdb=userdb, user=g.user, ac=g.ac, **kwargs) - - # ***** Main page ***** # TODO: rewrite as before_request (to check for this situation at any URL) @app.route('/') def main(): log_ep.log('/') - # User is authenticated but has no account - if g.user and g.ac('notregistered'): - return redirect(BASE_URL+'/noaccount') - return redirect(BASE_URL+'/ips/') @@ -540,41 +538,6 @@ class AccountRequestForm(FlaskForm): message = TextAreaField("", [validators.Optional()]) action = HiddenField('action') -@app.route('/noaccount', methods=['GET','POST']) -def noaccount(): - log_ep.log('/noaccount') - if not g.user: - return make_response("ERROR: no user is authenticated") - if not g.ac('notregistered'): - return redirect(BASE_URL+'/ips/') - if g.user['login_type'] != 'shibboleth': - return make_response("ERROR: You've successfully authenticated to web server but there is no matching user account. This is probably a configuration error. Contact NERD administrator.") - - form = AccountRequestForm(request.values) - # Prefill user's default email from his/her account info (we expect a list of emails separated by ';') - if not form.email.data and 'email' in g.user: - form.email.data = g.user['email'].split(';')[0] - - request_sent = False - if form.validate() and form.action.data == 'request_account': - # Check presence of config login.request-email - if not config.get('login.request-email', None): - return make_response("ERROR: No destination email address configured. This is a server configuration error. Please, report this to NERD administrator if possible.") - # Send email - name = g.user.get('name', '[name not available]') - id = g.user['id'] - email = form.email.data - message = form.message.data - msg = Message(subject="[NERD] New account request from {} ({})".format(name,id), - recipients=[config.get('login.request-email')], - reply_to=email, - body="A user with the following ID has requested creation of a new account in NERD.\n\nid: {}\nname: {}\nemails: {}\nselected email: {}\n\nMessage:\n{}".format(id,name,g.user.get('email',''),email,message), - ) - mailer.send(msg) - request_sent = True - - return render_template('noaccount.html', **locals()) - # ***** Account info & password change ***** @@ -1953,6 +1916,12 @@ def get_shodan_response(ipaddr=None): # ********** +# register blueprints - generally definitions of routes in separate files +from user_management import user_management + +app.register_blueprint(user_management, url_prefix="/user") + + if __name__ == "__main__": # Set global testing flag config.testing = True diff --git a/NERDweb/templates/layout.html b/NERDweb/templates/layout.html index b724a5a3..13896e12 100644 --- a/NERDweb/templates/layout.html +++ b/NERDweb/templates/layout.html @@ -131,13 +131,14 @@
{% if not user %} - Log in using: - {%- for method_name, params in config.login.methods.items() -%} - {{ params.display }} - {%- endfor -%} - {%- if config.testing %} - = devel autologin = - {%- endif %} + Log in using: + {%- for method_name, params in config.login.methods.items() -%} + Local account + {%- endfor -%} + {%- if config.testing %} + = devel autologin = + {%- endif %} + Register {% else %} Logged in:{{ user.id }}{% if user.name %} ({{ user.name }}){% endif %}Log out {% endif %} @@ -150,6 +151,13 @@ {% endblock %}
+{% if not verified %} +
Your email address is still not verified. Please check your email inbox and follow the + instructions to verify your email address! Cannot find the verification email in your inbox? + Resend verification email + +
+{% endif %} {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} diff --git a/NERDweb/templates/login.html b/NERDweb/templates/login.html new file mode 100644 index 00000000..5d28dbac --- /dev/null +++ b/NERDweb/templates/login.html @@ -0,0 +1,33 @@ +{% extends "layout.html" %} +{% block body %} + +
+

User login

+
+
+
+ {{ form.email.label(class="form-control-label") }}
+ {{ form.email(class="form-control form-control-lg") }} + {% if form.email.errors %} + {{ ', '.join(form.email.errors) }}
+ {% endif %} +
+
+ {{ form.password.label(class="form-control-label") }}
+ {% if form.password.errors %} + {{ ', '.join(form.password.errors) }}
+ {% endif %} + {{ form.password(class="form-control form-control-lg") }} +
+
+
+ {{ form.submit(class="btn btn-outline-info") }} +
+
+
+
+ + Forgot you password? You can reset password. + +
+{% endblock %} diff --git a/NERDweb/templates/not_verified.html b/NERDweb/templates/not_verified.html new file mode 100644 index 00000000..2f3a5bd8 --- /dev/null +++ b/NERDweb/templates/not_verified.html @@ -0,0 +1,15 @@ +{% extends "layout.html" %} +{% block body %} + +
+

Not a verified user

+

Your email address is still not verified. In order to access to NERD, please check your email inbox and follows the + instructions to verify your email address or log out.

+
+ +
+ + Cannot find the verification email in your inbox? Resend verification email + +
+{% endblock %} \ No newline at end of file diff --git a/NERDweb/templates/password_reset.html b/NERDweb/templates/password_reset.html new file mode 100644 index 00000000..1084ba7d --- /dev/null +++ b/NERDweb/templates/password_reset.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} +{% block body %} + +
+

Password reset - fill in your new password

+
+
+
+ {{ form.password.label(class="form-control-label") }}
+ {% if form.password.errors %} + {{ ', '.join(form.password.errors) }}
+ {% endif %} + {{ form.password(class="form-control form-control-lg") }} +
+
+ {{ form.confirm_password.label(class="form-control-label") }}
+ {% if form.confirm_password.errors %} + {{ ', '.join(form.confirm_password.errors) }}
+ {% endif %} + {{ form.confirm_password(class="form-control form-control-lg") }} +
+
+
+ {{ form.submit(class="btn btn-outline-info") }} +
+
+
+{% endblock %} diff --git a/NERDweb/templates/password_reset_request.html b/NERDweb/templates/password_reset_request.html new file mode 100644 index 00000000..7ca38844 --- /dev/null +++ b/NERDweb/templates/password_reset_request.html @@ -0,0 +1,21 @@ +{% extends "layout.html" %} +{% block body %} + +
+

Password reset request

+
+
+
+ {{ form.email.label(class="form-control-label") }}
+ {{ form.email(class="form-control form-control-lg") }} + {% if form.email.errors %} + {{ ', '.join(form.email.errors) }}
+ {% endif %} +
+
+
+ {{ form.submit(class="btn btn-outline-info") }} +
+
+
+{% endblock %} diff --git a/NERDweb/templates/password_strength_check.html b/NERDweb/templates/password_strength_check.html new file mode 100644 index 00000000..18ab7b66 --- /dev/null +++ b/NERDweb/templates/password_strength_check.html @@ -0,0 +1,69 @@ +{% macro password_check(field, level) %} +{# Source: https://github.com/dropbox/zxcvbn and http://hack4impact.github.io/flask-base/templates/#macros-password-strength-check_passwordhtml #} + + + + +{% endmacro %} \ No newline at end of file diff --git a/NERDweb/templates/user_registration.html b/NERDweb/templates/user_registration.html new file mode 100644 index 00000000..4a1c2ac3 --- /dev/null +++ b/NERDweb/templates/user_registration.html @@ -0,0 +1,64 @@ +{% extends "layout.html" %} +{% import "password_strength_check.html" as check %} +{% block body %} + +
+

User registration

+
+
+
+ {{ form.name.label(class="form-control-label") }}
+ {{ form.name(class="form-control form-control-lg") }} + {% if form.name.errors %} + {{ ', '.join(form.name.errors) }}
+ {% endif %} +
+
+ {{ form.surname.label(class="form-control-label") }}
+ {{ form.surname(class="form-control form-control-lg") }} + {% if form.surname.errors %} + {{ ', '.join(form.surname.errors) }}
+ {% endif %} +
+
+ {{ form.email.label(class="form-control-label") }}
+ {{ form.email(class="form-control form-control-lg") }} + {% if form.email.errors %} + {{ ', '.join(form.email.errors) }}
+ {% endif %} +
+
+ {{ form.organization.label(class="form-control-label") }}
+ {{ form.organization(class="form-control form-control-lg") }} + {% if form.organization.errors %} + {{ ', '.join(form.organization.errors) }}
+ {% endif %} +
+
+ {{ form.password.label(class="form-control-label") }}
+ {% if form.password.errors %} + {{ ', '.join(form.password.errors) }}
+ {% endif %} + {{ form.password(class="form-control form-control-lg") }} +
+
+ {{ form.confirm_password.label(class="form-control-label") }}
+ {% if form.confirm_password.errors %} + {{ ', '.join(form.confirm_password.errors) }}
+ {% endif %} + {{ form.confirm_password(class="form-control form-control-lg") }} +
+
+
+ {{ form.submit(class="btn btn-outline-info") }} +
+
+
+
+ + Already have an account? Sign in + +
+{{ check.password_check('password', 0) }} + +{% endblock %} diff --git a/NERDweb/user_management.py b/NERDweb/user_management.py new file mode 100644 index 00000000..27bf4818 --- /dev/null +++ b/NERDweb/user_management.py @@ -0,0 +1,240 @@ +from datetime import datetime, timedelta + +from flask import Blueprint, flash, redirect, session, g, make_response, current_app, url_for +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Email, EqualTo, Length, Regexp +from bcrypt import hashpw, checkpw, gensalt +from flask_mail import Message +from itsdangerous import URLSafeTimedSerializer + +# needed overridden render_template method because it passes some needed attributes to Jinja templates +from nerd_main import render_template, BASE_URL, config, mailer +from userdb import create_user, get_user_data_for_login, get_user_by_email, verify_user, get_verification_email_sent, \ + set_verification_email_sent, set_last_login, get_user_name, set_new_password + + +# variable name and name of blueprint is recommended to be same as filename +user_management = Blueprint("user_management", __name__, static_folder="static", template_folder="templates") + +ERROR_MSG_MISSING_MAIL_CONFIG = "ERROR: No destination email address configured. This is a server configuration " \ + "error. Please, report this to NERD administrator if possible." + + +# ***** Util functions ***** +def generate_token(user_email): + """ Generates random token for email verification or password reset. """ + serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY']) + return serializer.dumps(user_email) + + +def confirm_token(token, expiration=3600): + serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY']) + try: + email = serializer.loads( + token, + max_age=expiration + ) + except: + return False + return email + + +def get_current_datetime(): + return datetime.now() + + +def send_verification_email(user_email, name): + if not config.get('login.request-email', None): + return make_response(ERROR_MSG_MISSING_MAIL_CONFIG) + token = generate_token(user_email) + confirm_url = url_for('user_management.verify_email', token=token, _external=True) + msg = Message(subject="[NERD] Verify your account email address", + recipients=[user_email], + reply_to=current_app.config.get('MAIL_DEFAULT_SENDER'), + body=f"Dear {name},\n\nplease verify your email address to complete your registration process by" + f" clicking on this link:\n\n{confirm_url}\n\nThank you,\nNERD administrator", + ) + mailer.send(msg) + + +def send_password_reset_email(user_email, name): + if not config.get('login.request-email', None): + return make_response(ERROR_MSG_MISSING_MAIL_CONFIG) + token = generate_token(user_email) + password_reset_url = url_for('user_management.verify_email', token=token, _external=True) + msg = Message(subject="[NERD] Password reset request", + recipients=[user_email], + reply_to=current_app.config.get('MAIL_DEFAULT_SENDER'), + body=f"Dear {name},\n\nyou can reset your user password clicking on this link:" + f"\n\n{password_reset_url}\n\nThank you,\nNERD administrator", + ) + mailer.send(msg) + + +def get_hashed_password(password): + return hashpw(password.encode('utf-8'), gensalt()).decode('utf-8') + + +def verify_email_token(token): + """ Verifies email token and returns user's email, to which the token was crafted and also user info. """ + try: + email = confirm_token(token) + except Exception as e: + flash('The link is invalid or has expired.', 'error') + return None + if not email: + flash('The link is invalid. Please check the link and if the problem persists, ' + 'contact NERD administrator.', 'error') + return None + user = get_user_by_email(email) + if user is None: + flash('Such user account does not exist. Please check your link and if the problem persists, ' + 'contact NERD administrator.', 'error') + return user + + +# ***** Forms ***** +class UserRegistrationForm(FlaskForm): + email = StringField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired()]) + confirm_password = PasswordField("Confirm password", validators=[DataRequired(), EqualTo('password')]) + name = StringField("Name", validators=[Length(max=20), Regexp(r'^[\w]+$', + message="Invalid input, you can use alphanumeric characters only!")]) + surname = StringField("Surname", validators=[Length(max=20), Regexp(r'^[\w]+$', + message="Invalid input, you can use alphanumeric characters only!")]) + organization = StringField("Organization", validators=[Length(max=50)]) + submit = SubmitField("Register") + + +class LoginForm(FlaskForm): + email = StringField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Login") + + +class PasswordResetRequest(FlaskForm): + email = StringField("Enter your email address:", validators=[DataRequired(), Email()]) + submit = SubmitField("Submit") + + +class PasswordReset(FlaskForm): + password = PasswordField("Password", validators=[DataRequired()]) + confirm_password = PasswordField("Confirm password", validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField("Submit") + + +# ***** Routes ***** +@user_management.route("/register", methods=['POST', 'GET']) +def register_user(): + reg_form = UserRegistrationForm() + if reg_form.validate_on_submit(): + # store user in database + hashed_password = get_hashed_password(reg_form.password.data) + res = create_user(reg_form.email.data, hashed_password, "local", reg_form.name.data, + reg_form.surname.data, reg_form.organization.data) + if isinstance(res, Exception): + if res.args[0].startswith("duplicate key") and "Key (id)" in res.args[0]: + flash(f"User with email address {reg_form.email.data} already exists! You can either log in or try to " + f"reset your password in log in section.", "error") + else: + flash(f"Something has failed during registration process, please contact administrator. {res.args}", + "error") + else: + flash(f"Account created for {reg_form.email.data}!", "success") + # immediately log in user + session['user'] = { + 'login_type': 'local', + 'id': reg_form.email.data, + } + send_verification_email(reg_form.email.data, reg_form.name.data) + set_verification_email_sent(get_current_datetime(), reg_form.email.data) + set_last_login(get_current_datetime(), reg_form.email.data) + return redirect(BASE_URL+'/ips/') + return render_template('user_registration.html', title="Registration", form=reg_form) + + +@user_management.route("/login/local", methods=['POST', 'GET']) +def login_local(): + login_form = LoginForm() + if login_form.validate_on_submit(): + user_data = get_user_data_for_login("local:" + login_form.email.data) + if user_data is None: + flash(f"User with email address {login_form.email.data} does not exist!", "error") + elif not checkpw(login_form.password.data.encode('utf-8'), user_data['password'].encode('utf-8')): + flash("Wrong password!", "error") + else: + flash(f"User {user_data['name']} successfully logged in!") + session['user'] = { + 'login_type': 'local', + 'id': login_form.email.data, + } + return redirect(BASE_URL + '/') + return render_template('login.html', title="Local login", form=login_form) + + +@user_management.route("/password_reset_request", methods=['POST', 'GET']) +def password_reset_request(): + pw_reset_form = PasswordResetRequest() + if pw_reset_form.validate_on_submit(): + user_name = get_user_name(pw_reset_form.email.data).split(' ') + if user_name is None: + flash("User with such email address does not exist", "error") + else: + send_verification_email(pw_reset_form.email.data, user_name) + flash(f"Email with password reset link was sent!") + return redirect(BASE_URL + '/') + return render_template('password_reset_request.html', title="Password reset request", form=pw_reset_form) + + +@user_management.route("/password_reset_request/", methods=['POST', 'GET']) +def password_reset(token): + pw_reset_form = PasswordReset() + if pw_reset_form.validate_on_submit(): + email = verify_email_token(token)['email'] + if email is None: + return redirect(BASE_URL + '/') + + result = set_new_password(get_hashed_password(pw_reset_form.password.data), email) + if result is not None: + # exception occurred + flash("Password reset failed. Please, try again and if the problem persists, contact NERD administrator.", + "error") + else: + flash(f"Password has been successfully changed!", "success") + return redirect(BASE_URL + '/') + return render_template('password_reset_request.html', title="Password reset request", form=pw_reset_form) + + +@user_management.route("/verify/") +def verify_email(token): + user = verify_email_token(token) + email = user['email'] + if email is None: + return redirect(BASE_URL + '/') + + if user['verified']: + flash('Account already confirmed. Please login.', 'success') + else: + result = verify_user(user['id']) + if result is not None: + # exception occurred + flash("Verification failed. Please, try again and if the problem persists, contact NERD administrator.", + "error") + else: + flash(f"Email address {email} successfully verified!", "success") + return redirect(BASE_URL + '/') + + +@user_management.route("/resend_verification") +def resend_verification_mail(): + user_email = g.user['fullid'].split(':')[1] + email_sent_at = get_verification_email_sent(user_email) + if email_sent_at > (get_current_datetime() - timedelta(hours=1)): + flash("Verification email was already sent in last hour. Check your inbox!", "error") + else: + user_name = get_user_name(user_email).split(' ') + send_verification_email(user_email, user_name) + set_verification_email_sent(get_current_datetime(), user_email) + flash("New verification email was sent, check your inbox!", "success") + return redirect(BASE_URL + '/') diff --git a/NERDweb/userdb.py b/NERDweb/userdb.py index 1af598d2..9d13a32c 100644 --- a/NERDweb/userdb.py +++ b/NERDweb/userdb.py @@ -52,6 +52,19 @@ def get_all_groups(): return sorted(list(groups)) +# ***** User management functions ***** +def create_user(email, password, provider, name=None, surname=None, organization=None): + try: + cur = db.cursor() + cur.execute("""INSERT INTO users (id, groups, name, email, org, password) + VALUES (%s, %s, %s, %s, %s, %s)""", + (provider + ":" + email, ["registered"], name + " " + surname, email, organization, password)) + db.commit() + cur.close() + except (Exception, psycopg2.DatabaseError) as e: + return e + + # ***** Access control functions ***** def get_user_groups(full_id): @@ -78,15 +91,92 @@ def ac(resource): return ac +def get_user_by_email(email): + cur = db.cursor() + cur.execute("SELECT * FROM users WHERE email=%s", (email,)) + row = cur.fetchone() + if not row: + return None + col_names = [col.name for col in cur.description] + return dict(zip(col_names, row)) + + +def get_user_data_for_login(user_id): + cur = db.cursor() + cur.execute("SELECT id, password, name FROM users WHERE id = %s", (user_id,)) + row = cur.fetchone() + if not row: + return None + return {'id': row[0], 'password': row[1], 'name': row[2]} + + +def verify_user(user_id): + try: + cur = db.cursor() + cur.execute("""UPDATE users SET groups=%s, verified=TRUE WHERE id = %s""", (["registered"], user_id,)) + db.commit() + cur.close() + except (Exception, psycopg2.DatabaseError) as e: + return e + + +def set_verification_email_sent(date_time, email): + try: + cur = db.cursor() + cur.execute("""UPDATE users SET verification_email_sent=%s WHERE email = %s""", (date_time, email)) + db.commit() + cur.close() + except (Exception, psycopg2.DatabaseError) as e: + return e + + +def set_last_login(date_time, email): + try: + cur = db.cursor() + cur.execute("""UPDATE users SET last_login=%s WHERE email = %s""", (date_time, email)) + db.commit() + cur.close() + except (Exception, psycopg2.DatabaseError) as e: + return e + + +def get_verification_email_sent(user_email): + cur = db.cursor() + cur.execute("SELECT verification_email_sent FROM users WHERE email = %s", (user_email,)) + row = cur.fetchone() + if not row: + return None + return row[0] + + +def get_user_name(user_email): + cur = db.cursor() + cur.execute("SELECT name FROM users WHERE email = %s", (user_email,)) + row = cur.fetchone() + if not row: + return None + return row[0] + + +def set_new_password(new_password, email): + try: + cur = db.cursor() + cur.execute("""UPDATE users SET password=%s WHERE email = %s""", (new_password, email)) + db.commit() + cur.close() + except (Exception, psycopg2.DatabaseError) as e: + return e + + # TODO - split authentication and authorization/get_user_information def get_user_info(session): """ - Returun info about current user (or None if noone is logged in) and + Return info about current user (or None if no one is logged in) and the access control function. To be called by all page handlers as: - user, ac = get_user_info(session) + user, ac, verified = get_user_info(session) 'user' contains: login_type, id, fullid, groups, name, email, org, api_token, rl-bs, rl-tps @@ -99,7 +189,7 @@ def get_user_info(session): user['fullid'] = user['login_type'] + ':' + user['id'] else: # No user logged in - return None, get_ac_func(set()) + return None, get_ac_func(set()), None # Get user info from DB # TODO: get only what is normally needed (id, groups, name (to show in web header), rl-*) @@ -110,7 +200,7 @@ def get_user_info(session): if not row: # User not found in DB = user is authenticated (e.g. via shibboleth) but has no account yet user['groups'] = set() - return user, get_ac_func(user['groups']) + return user, get_ac_func(user['groups']), False # Put all fields from DB into 'user' dict col_names[0] = 'fullid' # rename column 'id' to 'fullid', other columns can be mapped directly as they are in DB @@ -130,7 +220,8 @@ def get_user_info(session): ac = get_ac_func(set(user['selected_groups'])) else: ac = get_ac_func(user['groups']) - return user, ac + session['user']['verified'] = user['verified'] + return user, ac, user['verified'] def authenticate_with_token(token): diff --git a/install/create_user_db.sql b/install/create_user_db.sql index 30b825b5..ce0990f6 100644 --- a/install/create_user_db.sql +++ b/install/create_user_db.sql @@ -7,7 +7,11 @@ CREATE TABLE IF NOT EXISTS users ( org VARCHAR, api_token VARCHAR, rl_bs REAL, - rl_tps REAL + rl_tps REAL, + verified BOOLEAN, + password VARCHAR, + verification_email_sent TIMESTAMP, + last_login TIMESTAMP ); -- testing users --INSERT INTO users (id,groups,name,email) VALUES ('devel:devel_admin','{"admin","registered"}','Mr. Developer','test@example.org') ON CONFLICT DO NOTHING; diff --git a/install/pip_requirements_nerdweb.txt b/install/pip_requirements_nerdweb.txt index ec7e0a96..1974312c 100644 --- a/install/pip_requirements_nerdweb.txt +++ b/install/pip_requirements_nerdweb.txt @@ -11,3 +11,5 @@ redis hiredis cachetools event_count_logger +bcrypt +email_validator From d088581a2abbec06fb2d2f1fc4556db0eceb53c6 Mon Sep 17 00:00:00 2001 From: Pavel Eis Date: Wed, 29 Sep 2021 09:59:49 +0200 Subject: [PATCH 2/7] NERDweb: Middle of Google OAuth --- NERDweb/nerd_main.py | 4 +++- NERDweb/user_management.py | 17 +++++++++++++++++ install/pip_requirements_nerdweb.txt | 1 + 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/NERDweb/nerd_main.py b/NERDweb/nerd_main.py index d2fa7358..858f9155 100644 --- a/NERDweb/nerd_main.py +++ b/NERDweb/nerd_main.py @@ -38,6 +38,7 @@ from userdb import get_user_info, authenticate_with_token, generate_unique_token # ***** Load configuration ***** +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" DEFAULT_CONFIG_FILE = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "/etc/nerd/nerdweb.yml")) @@ -1917,9 +1918,10 @@ def get_shodan_response(ipaddr=None): # ********** # register blueprints - generally definitions of routes in separate files -from user_management import user_management +from user_management import user_management, google_blueprint app.register_blueprint(user_management, url_prefix="/user") +app.register_blueprint(google_blueprint) if __name__ == "__main__": diff --git a/NERDweb/user_management.py b/NERDweb/user_management.py index 27bf4818..63ee9bb0 100644 --- a/NERDweb/user_management.py +++ b/NERDweb/user_management.py @@ -7,12 +7,17 @@ from bcrypt import hashpw, checkpw, gensalt from flask_mail import Message from itsdangerous import URLSafeTimedSerializer +from flask_dance.contrib.google import make_google_blueprint, google # needed overridden render_template method because it passes some needed attributes to Jinja templates from nerd_main import render_template, BASE_URL, config, mailer from userdb import create_user, get_user_data_for_login, get_user_by_email, verify_user, get_verification_email_sent, \ set_verification_email_sent, set_last_login, get_user_name, set_new_password +google_blueprint = make_google_blueprint(client_id=config.get('oauth.google.client_id'), + client_secret=config.get('oauth.google.client_secret'), + scope=["profile", "email"], + redirect_url=BASE_URL + '/') # variable name and name of blueprint is recommended to be same as filename user_management = Blueprint("user_management", __name__, static_folder="static", template_folder="templates") @@ -238,3 +243,15 @@ def resend_verification_mail(): set_verification_email_sent(get_current_datetime(), user_email) flash("New verification email was sent, check your inbox!", "success") return redirect(BASE_URL + '/') + + +@user_management.route("/login/google") +def google_login(): + if not google.authorized: + return redirect(url_for("google.login")) + # https://www.googleapis.com/auth/userinfo.email + # account_info = google.get("https://www.googleapis.com/auth/userinfo.email") + # account_info = google.get("https://www.googleapis.com/oauth2/v2/userinfo?fields=id,email,name,picture") + account_info = google.get("/oauth2/v1/userinfo") + flash(f"User info is test: {account_info.json()}") + return redirect(BASE_URL + '/') diff --git a/install/pip_requirements_nerdweb.txt b/install/pip_requirements_nerdweb.txt index 1974312c..95ec5b91 100644 --- a/install/pip_requirements_nerdweb.txt +++ b/install/pip_requirements_nerdweb.txt @@ -13,3 +13,4 @@ cachetools event_count_logger bcrypt email_validator +flask_dance From 622376009b5899ca07fa4631b880832f93f605b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Barto=C5=A1?= Date: Thu, 14 Oct 2021 09:04:28 +0200 Subject: [PATCH 3/7] new user mgmt: various fixes and todo notes --- NERDweb/nerd_main.py | 4 +- NERDweb/templates/account_info.html | 1 + NERDweb/templates/login.html | 2 +- NERDweb/templates/not_verified.html | 2 +- NERDweb/templates/password_reset.html | 1 + NERDweb/templates/user_registration.html | 7 -- NERDweb/user_management.py | 91 ++++++++++++------------ NERDweb/userdb.py | 29 +++++--- install/pip_requirements_nerdweb.txt | 3 +- 9 files changed, 71 insertions(+), 69 deletions(-) diff --git a/NERDweb/nerd_main.py b/NERDweb/nerd_main.py index d2fa7358..b28729d5 100644 --- a/NERDweb/nerd_main.py +++ b/NERDweb/nerd_main.py @@ -154,7 +154,8 @@ def render_template(template, **kwargs): app.config['MAIL_USE_SSL'] = config.get('mail.ssl', False) app.config['MAIL_USERNAME'] = config.get('mail.username', None) app.config['MAIL_PASSWORD'] = config.get('mail.password', None) -app.config['MAIL_DEFAULT_SENDER'] = config.get('mail.sender', 'NERD ') +app.config['MAIL_DEFAULT_SENDER'] = config.get('mail.sender', 'NERD ') #TODO: this should be determined automatically, or it must be present in config, so it's crear it should be redefined +app.config['MAIL_SUPPRESS_SEND '] = False # default is True if app.testing is True mailer = Mail(app) @@ -541,6 +542,7 @@ class AccountRequestForm(FlaskForm): # ***** Account info & password change ***** +# TODO? toto a vše související je potřeba nahradit novým formulářem na změnu hesla class PasswordChangeForm(FlaskForm): old_passwd = PasswordField('Old password', [validators.InputRequired()]) new_passwd = PasswordField('New password', [validators.InputRequired(), validators.length(8, -1, 'Password must have at least 8 characters')]) diff --git a/NERDweb/templates/account_info.html b/NERDweb/templates/account_info.html index 6435f0d2..201c01d4 100644 --- a/NERDweb/templates/account_info.html +++ b/NERDweb/templates/account_info.html @@ -7,6 +7,7 @@

Account information

Name: {{ user.get('name', '[unknown]') }}

User ID: {{ user.id }}

Login type: {{ user.login_type }}

+

Email: {{ user.email }}{{ " (not verified yet!)" if not user.verified else "" }}

Groups: {{ user.groups|join(', ') }}


diff --git a/NERDweb/templates/login.html b/NERDweb/templates/login.html index 5d28dbac..6c52d705 100644 --- a/NERDweb/templates/login.html +++ b/NERDweb/templates/login.html @@ -27,7 +27,7 @@

User login

- Forgot you password? You can reset password. + Forgot your password? You can reset password.
{% endblock %} diff --git a/NERDweb/templates/not_verified.html b/NERDweb/templates/not_verified.html index 2f3a5bd8..f2651fde 100644 --- a/NERDweb/templates/not_verified.html +++ b/NERDweb/templates/not_verified.html @@ -3,7 +3,7 @@

Not a verified user

-

Your email address is still not verified. In order to access to NERD, please check your email inbox and follows the +

Your email address is still not verified. In order to access to NERD, please check your email inbox and follow the instructions to verify your email address or log out.

diff --git a/NERDweb/templates/password_reset.html b/NERDweb/templates/password_reset.html index 1084ba7d..7ee867d7 100644 --- a/NERDweb/templates/password_reset.html +++ b/NERDweb/templates/password_reset.html @@ -3,6 +3,7 @@

Password reset - fill in your new password

+

User id: {{ userid }}

diff --git a/NERDweb/templates/user_registration.html b/NERDweb/templates/user_registration.html index 4a1c2ac3..9ec717ad 100644 --- a/NERDweb/templates/user_registration.html +++ b/NERDweb/templates/user_registration.html @@ -13,13 +13,6 @@

User registration

{{ ', '.join(form.name.errors) }}
{% endif %}
-
- {{ form.surname.label(class="form-control-label") }}
- {{ form.surname(class="form-control form-control-lg") }} - {% if form.surname.errors %} - {{ ', '.join(form.surname.errors) }}
- {% endif %} -
{{ form.email.label(class="form-control-label") }}
{{ form.email(class="form-control form-control-lg") }} diff --git a/NERDweb/user_management.py b/NERDweb/user_management.py index 27bf4818..31be2c0f 100644 --- a/NERDweb/user_management.py +++ b/NERDweb/user_management.py @@ -6,7 +6,7 @@ from wtforms.validators import DataRequired, Email, EqualTo, Length, Regexp from bcrypt import hashpw, checkpw, gensalt from flask_mail import Message -from itsdangerous import URLSafeTimedSerializer +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired # needed overridden render_template method because it passes some needed attributes to Jinja templates from nerd_main import render_template, BASE_URL, config, mailer @@ -35,18 +35,17 @@ def confirm_token(token, expiration=3600): token, max_age=expiration ) - except: + except (BadSignature, SignatureExpired): return False return email def get_current_datetime(): - return datetime.now() + return datetime.now() # TODO? shouldn't it be utcnow()? ukladádá se do DB def send_verification_email(user_email, name): - if not config.get('login.request-email', None): - return make_response(ERROR_MSG_MISSING_MAIL_CONFIG) + # TODO: udelat nekde kontrolu, ze je nakonfigurovane mailovani token = generate_token(user_email) confirm_url = url_for('user_management.verify_email', token=token, _external=True) msg = Message(subject="[NERD] Verify your account email address", @@ -62,11 +61,11 @@ def send_password_reset_email(user_email, name): if not config.get('login.request-email', None): return make_response(ERROR_MSG_MISSING_MAIL_CONFIG) token = generate_token(user_email) - password_reset_url = url_for('user_management.verify_email', token=token, _external=True) + password_reset_url = url_for('user_management.password_reset', token=token, _external=True) msg = Message(subject="[NERD] Password reset request", recipients=[user_email], reply_to=current_app.config.get('MAIL_DEFAULT_SENDER'), - body=f"Dear {name},\n\nyou can reset your user password clicking on this link:" + body=f"Dear {name},\n\nyou can reset your password by clicking on this link:" f"\n\n{password_reset_url}\n\nThank you,\nNERD administrator", ) mailer.send(msg) @@ -78,14 +77,9 @@ def get_hashed_password(password): def verify_email_token(token): """ Verifies email token and returns user's email, to which the token was crafted and also user info. """ - try: - email = confirm_token(token) - except Exception as e: - flash('The link is invalid or has expired.', 'error') - return None + email = confirm_token(token) if not email: - flash('The link is invalid. Please check the link and if the problem persists, ' - 'contact NERD administrator.', 'error') + flash('The link is invalid or has expired.', 'error') return None user = get_user_by_email(email) if user is None: @@ -99,11 +93,8 @@ class UserRegistrationForm(FlaskForm): email = StringField("Email", validators=[DataRequired(), Email()]) password = PasswordField("Password", validators=[DataRequired()]) confirm_password = PasswordField("Confirm password", validators=[DataRequired(), EqualTo('password')]) - name = StringField("Name", validators=[Length(max=20), Regexp(r'^[\w]+$', - message="Invalid input, you can use alphanumeric characters only!")]) - surname = StringField("Surname", validators=[Length(max=20), Regexp(r'^[\w]+$', - message="Invalid input, you can use alphanumeric characters only!")]) - organization = StringField("Organization", validators=[Length(max=50)]) + name = StringField("Full name", validators=[Length(max=80)]) + organization = StringField("Organization", validators=[Length(max=80)]) submit = SubmitField("Register") @@ -119,8 +110,8 @@ class PasswordResetRequest(FlaskForm): class PasswordReset(FlaskForm): - password = PasswordField("Password", validators=[DataRequired()]) - confirm_password = PasswordField("Confirm password", validators=[DataRequired(), EqualTo('password')]) + password = PasswordField("New password", validators=[DataRequired()]) + confirm_password = PasswordField("Confirm new password", validators=[DataRequired(), EqualTo('password')]) submit = SubmitField("Submit") @@ -131,15 +122,15 @@ def register_user(): if reg_form.validate_on_submit(): # store user in database hashed_password = get_hashed_password(reg_form.password.data) - res = create_user(reg_form.email.data, hashed_password, "local", reg_form.name.data, - reg_form.surname.data, reg_form.organization.data) + res = create_user(reg_form.email.data, hashed_password, "local", reg_form.name.data, reg_form.organization.data) if isinstance(res, Exception): - if res.args[0].startswith("duplicate key") and "Key (id)" in res.args[0]: + if res.args[0].startswith("duplicate key") and "Key (id)" in res.args[0]: #TODO? neexistuje lepší způsob kontroly typu cyhby? Prý na to speciální typ Exc asi není flash(f"User with email address {reg_form.email.data} already exists! You can either log in or try to " - f"reset your password in log in section.", "error") + f"reset your password in log-in section.", "error") else: - flash(f"Something has failed during registration process, please contact administrator. {res.args}", - "error") + print(f"ERROR in register_user(): Something has failed during registration process: {res.args}") + # TODO implement some notification mechanism for such errors + flash(f"Something has failed during registration process, please contact administrator.", "error") else: flash(f"Account created for {reg_form.email.data}!", "success") # immediately log in user @@ -159,10 +150,9 @@ def login_local(): login_form = LoginForm() if login_form.validate_on_submit(): user_data = get_user_data_for_login("local:" + login_form.email.data) - if user_data is None: - flash(f"User with email address {login_form.email.data} does not exist!", "error") - elif not checkpw(login_form.password.data.encode('utf-8'), user_data['password'].encode('utf-8')): - flash("Wrong password!", "error") + if (user_data is None or + not checkpw(login_form.password.data.encode('utf-8'), user_data['password'].encode('utf-8'))): + flash(f"Wrong email or password!", "error") else: flash(f"User {user_data['name']} successfully logged in!") session['user'] = { @@ -177,25 +167,30 @@ def login_local(): def password_reset_request(): pw_reset_form = PasswordResetRequest() if pw_reset_form.validate_on_submit(): - user_name = get_user_name(pw_reset_form.email.data).split(' ') + user_name = get_user_name(pw_reset_form.email.data) if user_name is None: - flash("User with such email address does not exist", "error") + flash("User with such email address does not exist", "error") # TODO? is it OK to disclose information about account (non)existence? else: - send_verification_email(pw_reset_form.email.data, user_name) + send_password_reset_email(pw_reset_form.email.data, user_name.split(' ')) flash(f"Email with password reset link was sent!") return redirect(BASE_URL + '/') return render_template('password_reset_request.html', title="Password reset request", form=pw_reset_form) -@user_management.route("/password_reset_request/", methods=['POST', 'GET']) +@user_management.route("/password_reset_request/", methods=['POST', 'GET']) #TODO? only one method should be allowed, there's no reason to allow the one we don't use (in all endpoints) def password_reset(token): + # Check token validity and load user info + # TODO? shouldn't it be user ID instead? + user = verify_email_token(token) + if user is None or user['email'] is None: + return redirect(BASE_URL + '/') + + assert user['id'].startswith("local:") # password reset is only possible for local accounts + userid = user['id'][6:] # remove leading "local:" + pw_reset_form = PasswordReset() if pw_reset_form.validate_on_submit(): - email = verify_email_token(token)['email'] - if email is None: - return redirect(BASE_URL + '/') - - result = set_new_password(get_hashed_password(pw_reset_form.password.data), email) + result = set_new_password(get_hashed_password(pw_reset_form.password.data), user['email']) if result is not None: # exception occurred flash("Password reset failed. Please, try again and if the problem persists, contact NERD administrator.", @@ -203,15 +198,17 @@ def password_reset(token): else: flash(f"Password has been successfully changed!", "success") return redirect(BASE_URL + '/') - return render_template('password_reset_request.html', title="Password reset request", form=pw_reset_form) + + return render_template('password_reset.html', title="Password reset", form=pw_reset_form, userid=userid) +# TODO? i tady by mohl byt "password_strength_check" @user_management.route("/verify/") def verify_email(token): + # Check token validity and load user info user = verify_email_token(token) - email = user['email'] - if email is None: - return redirect(BASE_URL + '/') + if user is None or user['email'] is None: + return redirect(BASE_URL + '/') # error message was issued via flash(), just show main page if user['verified']: flash('Account already confirmed. Please login.', 'success') @@ -222,7 +219,7 @@ def verify_email(token): flash("Verification failed. Please, try again and if the problem persists, contact NERD administrator.", "error") else: - flash(f"Email address {email} successfully verified!", "success") + flash(f"Email address {user['email']} successfully verified!", "success") return redirect(BASE_URL + '/') @@ -230,7 +227,7 @@ def verify_email(token): def resend_verification_mail(): user_email = g.user['fullid'].split(':')[1] email_sent_at = get_verification_email_sent(user_email) - if email_sent_at > (get_current_datetime() - timedelta(hours=1)): + if email_sent_at and email_sent_at > (get_current_datetime() - timedelta(hours=1)): flash("Verification email was already sent in last hour. Check your inbox!", "error") else: user_name = get_user_name(user_email).split(' ') @@ -238,3 +235,5 @@ def resend_verification_mail(): set_verification_email_sent(get_current_datetime(), user_email) flash("New verification email was sent, check your inbox!", "success") return redirect(BASE_URL + '/') + +# TODO: create some simpler html template/layout for these login/verify/reset pages (?) diff --git a/NERDweb/userdb.py b/NERDweb/userdb.py index 9d13a32c..d849fc5f 100644 --- a/NERDweb/userdb.py +++ b/NERDweb/userdb.py @@ -53,12 +53,12 @@ def get_all_groups(): # ***** User management functions ***** -def create_user(email, password, provider, name=None, surname=None, organization=None): +def create_user(email, password, provider, name=None, organization=None): try: cur = db.cursor() cur.execute("""INSERT INTO users (id, groups, name, email, org, password) VALUES (%s, %s, %s, %s, %s, %s)""", - (provider + ":" + email, ["registered"], name + " " + surname, email, organization, password)) + (provider + ":" + email, [], name, email, organization, password)) db.commit() cur.close() except (Exception, psycopg2.DatabaseError) as e: @@ -99,7 +99,7 @@ def get_user_by_email(email): return None col_names = [col.name for col in cur.description] return dict(zip(col_names, row)) - +# TODO? je opravdu potreba vracet vsechny sloupce? Podívat se, kde se to používá a případně upravit. def get_user_data_for_login(user_id): cur = db.cursor() @@ -108,7 +108,7 @@ def get_user_data_for_login(user_id): if not row: return None return {'id': row[0], 'password': row[1], 'name': row[2]} - +# TODO? Proc se vraci "name"? def verify_user(user_id): try: @@ -116,18 +116,20 @@ def verify_user(user_id): cur.execute("""UPDATE users SET groups=%s, verified=TRUE WHERE id = %s""", (["registered"], user_id,)) db.commit() cur.close() - except (Exception, psycopg2.DatabaseError) as e: + except psycopg2.Error as e: + print(f"verify_user() failed: {e.pgerror}") return e - def set_verification_email_sent(date_time, email): try: cur = db.cursor() cur.execute("""UPDATE users SET verification_email_sent=%s WHERE email = %s""", (date_time, email)) db.commit() cur.close() - except (Exception, psycopg2.DatabaseError) as e: + except psycopg2.Error as e: + print(f"set_verification_email_sent() failed: {e.pgerror}") return e +# TODO? nejsem si jisty, jestli je idealni ukladat toho do databaze. Jestli to slouzi jen k tomu, aby se nedalo odeslat prilis moc mailu, mozna by to slo vyresit spis rate-limiterem? def set_last_login(date_time, email): @@ -136,8 +138,10 @@ def set_last_login(date_time, email): cur.execute("""UPDATE users SET last_login=%s WHERE email = %s""", (date_time, email)) db.commit() cur.close() - except (Exception, psycopg2.DatabaseError) as e: + except psycopg2.Error as e: + print(f"set_last_login() failed: {e.pgerror}") return e +# TODO? Nebylo by lepsi last_login ukládat podle "id" a ne podle mailu? def get_verification_email_sent(user_email): @@ -156,7 +160,7 @@ def get_user_name(user_email): if not row: return None return row[0] - +# TODO? proc je to podle emailu a ne id? def set_new_password(new_password, email): try: @@ -164,8 +168,10 @@ def set_new_password(new_password, email): cur.execute("""UPDATE users SET password=%s WHERE email = %s""", (new_password, email)) db.commit() cur.close() - except (Exception, psycopg2.DatabaseError) as e: + except psycopg2.Error as e: + print(f"set_new_password() failed: {e.pgerror}") return e +# TODO? proc je to podle emailu a ne id? # TODO - split authentication and authorization/get_user_information @@ -222,7 +228,8 @@ def get_user_info(session): ac = get_ac_func(user['groups']) session['user']['verified'] = user['verified'] return user, ac, user['verified'] - +# TODO? Proc se vraci "verified" samostante, kdyz je to soucast "user"? +# Není k tomu důvod může to být v "user" def authenticate_with_token(token): """Like get_user_info, but authentication uses API token""" diff --git a/install/pip_requirements_nerdweb.txt b/install/pip_requirements_nerdweb.txt index 1974312c..0352d161 100644 --- a/install/pip_requirements_nerdweb.txt +++ b/install/pip_requirements_nerdweb.txt @@ -11,5 +11,4 @@ redis hiredis cachetools event_count_logger -bcrypt -email_validator +bcrypt \ No newline at end of file From ff265d63185128d513736f8907724562c1cb9918 Mon Sep 17 00:00:00 2001 From: xoltma00 Date: Thu, 21 Jul 2022 12:09:15 +0200 Subject: [PATCH 4/7] Default Google Credentials temp placeholder New pip dependencies: email-validator, flask-dance, bcrypt --- common/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/config.py b/common/config.py index aff7d800..bf43f655 100644 --- a/common/config.py +++ b/common/config.py @@ -9,7 +9,7 @@ class NoDefault: class MissingConfigError(Exception): pass -def hierarchical_get(self, key, default=NoDefault): +def hierarchical_get(self, key, default="1111"): """ Return self[key] or "default" if key is not found. Allow hierarchical keys. From 6924ca29d2d60984c659802e9c0274dfe357348d Mon Sep 17 00:00:00 2001 From: xoltma00 Date: Fri, 22 Jul 2022 15:10:32 +0200 Subject: [PATCH 5/7] Google OAuth Several user related pages are redesigned, Google OAuth works in testing phase --- NERDweb/static/style.css | 58 +++++++++++++++ NERDweb/templates/layout.html | 9 +-- NERDweb/templates/login.html | 34 +++++++-- NERDweb/templates/oauth_account.html | 29 ++++++++ .../templates/password_strength_check.html | 2 +- NERDweb/templates/user_registration.html | 29 ++++---- NERDweb/user_management.py | 72 ++++++++++++++++--- NERDweb/userdb.py | 8 +++ etc/nerdweb.yml | 4 ++ 9 files changed, 211 insertions(+), 34 deletions(-) create mode 100644 NERDweb/templates/oauth_account.html diff --git a/NERDweb/static/style.css b/NERDweb/static/style.css index 1bf6d22b..99ba90a1 100755 --- a/NERDweb/static/style.css +++ b/NERDweb/static/style.css @@ -1061,4 +1061,62 @@ ul.data-list li ul li { height: 200px; flex-direction: column; justify-content: space-between; +} + +/* LOG IN */ +#login-form +{ + width: 400px; + margin: auto; + text-align: center; +} + + + +#login-form fieldset +{ + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + text-align: left; +} + +#login-form fieldset input +{ + margin-bottom: 15px; +} + +#login-form fieldset input#password +{ + margin-bottom: 0px; +} + +#login-form progress +{ + padding-top: 0; +} + +#password-strength-text +{ + height: 10px; +} + +#login-options +{ + width: 100%; + text-align: center; +} + +.center +{ + width: 400px; + margin: auto; + text-align: center; + padding-top: 10%; +} + +.center h3 +{ + text-align: left; } \ No newline at end of file diff --git a/NERDweb/templates/layout.html b/NERDweb/templates/layout.html index 4b04d122..5be08f94 100644 --- a/NERDweb/templates/layout.html +++ b/NERDweb/templates/layout.html @@ -29,6 +29,8 @@ + +