diff --git a/NERDweb/nerd_main.py b/NERDweb/nerd_main.py index bea93fa5..fbcc64e7 100644 --- a/NERDweb/nerd_main.py +++ b/NERDweb/nerd_main.py @@ -8,9 +8,11 @@ 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 @@ -19,8 +21,8 @@ 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__)), '..'))) @@ -36,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")) @@ -151,6 +154,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') @@ -179,7 +187,8 @@ 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) @@ -191,7 +200,6 @@ app.config['WTF_CSRF_ENABLED'] = False #app.config['WTF_CSRF_CHECK_DEFAULT'] = False - # ***** Jinja2 filters ***** # Datetime filters @@ -447,6 +455,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)""" @@ -479,7 +488,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): @@ -548,21 +558,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/') @@ -572,44 +572,10 @@ 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 ***** +# 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')]) @@ -2051,6 +2017,15 @@ def get_shodan_response(ipaddr=None): # ********** +# register blueprints - generally definitions of routes in separate files +from user_management import user_management, google_blueprint, twitter_blueprint, github_blueprint + +app.register_blueprint(user_management, url_prefix="/user") +app.register_blueprint(google_blueprint) +app.register_blueprint(twitter_blueprint, url_prefix="/user/login") +app.register_blueprint(github_blueprint, url_prefix="/user/login") + + if __name__ == "__main__": # Set global testing flag config.testing = True diff --git a/NERDweb/static/style.css b/NERDweb/static/style.css index 1bf6d22b..586f77d3 100755 --- a/NERDweb/static/style.css +++ b/NERDweb/static/style.css @@ -1061,4 +1061,67 @@ 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; +} + +#login-options a:visited +{ + color: black; +} + +.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/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/layout.html b/NERDweb/templates/layout.html index a96f21a6..5be08f94 100644 --- a/NERDweb/templates/layout.html +++ b/NERDweb/templates/layout.html @@ -29,6 +29,8 @@ + + + + + +{% 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..de967b23 --- /dev/null +++ b/NERDweb/templates/user_registration.html @@ -0,0 +1,71 @@ +{% 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.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") }} +
+ +
or log in using:
+
+ + + +
+ EduGain +
+ +
+
+
+

+ 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..cc8ca01d --- /dev/null +++ b/NERDweb/user_management.py @@ -0,0 +1,434 @@ +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, BadSignature, SignatureExpired +from flask_dance.contrib.google import make_google_blueprint, google +from flask_dance.contrib.twitter import make_twitter_blueprint, twitter +from flask_dance.contrib.github import make_github_blueprint, github + + +# 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, check_if_user_exists + +google_blueprint = make_google_blueprint(client_id="", + client_secret="", + scope=["openid", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"], + redirect_url=BASE_URL + '/user/login/google/account') + +twitter_blueprint = make_twitter_blueprint( + api_key="", + api_secret="", + redirect_url=BASE_URL + '/user/login/use-twitter') + + +github_blueprint = make_github_blueprint( + client_id="", + client_secret="", + scope=["read:user,user:email"], + redirect_url=BASE_URL + '/user/login/use-github') + +# 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 (BadSignature, SignatureExpired): + return False + return email + + +def get_current_datetime(): + return datetime.now() # TODO? shouldn't it be utcnow()? uklad�d� se do DB + + +def send_verification_email(user_email, name): + # 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", + 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.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 password by 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. """ + email = confirm_token(token) + if not email: + flash('The link is invalid or has expired.', '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("Full name", validators=[Length(max=80)]) + organization = StringField("Organization", validators=[Length(max=80)]) + 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("New password", validators=[DataRequired()]) + confirm_password = PasswordField("Confirm new 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.organization.data) + if isinstance(res, Exception): + 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") + else: + 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. {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 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'] = { + '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) + if user_name is None: + flash("User with such email address does not exist", "error") # TODO? is it OK to disclose information about account (non)existence? + else: + 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']) #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(): + 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.", + "error") + else: + flash(f"Password has been successfully changed!", "success") + return redirect(BASE_URL + '/') + + 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) + 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') + 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 {user['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 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(' ') + 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 + '/') + +# TODO: create some simpler html template/layout for these login/verify/reset pages (?) + + +""" + G O O G L E +""" + +@user_management.route("/login/google") +def google_login(): + if not google.authorized: + return redirect(url_for("google.login")) + else: + account_info = google.get("/oauth2/v1/userinfo") + # log user in + flash("User " + account_info.json()["name"] + " successfully logged in!") + session['user'] = { + 'login_type': 'google', + 'id': account_info.json()["email"], + } + return redirect(BASE_URL + '/') + +@user_management.route("/login/google/account") +def google_login_account(): + account_info = google.get("/oauth2/v1/userinfo") + if check_if_user_exists("google:" + account_info.json()["email"]): + # log user in + flash("User " + account_info.json()["name"] + " successfully logged in!") + session['user'] = { + 'login_type': 'google', + 'id': account_info.json()["email"], + } + return redirect(BASE_URL + '/') + else: + return render_template('oauth_account.html', email=account_info.json()["email"], name=account_info.json()["name"], provider="google") + return redirect(BASE_URL + '/') + +@user_management.route("/login/google/account/create") +def google_login_create_account(): + account_info = google.get("/oauth2/v1/userinfo") + if check_if_user_exists("google:" + account_info.json()["email"]) is False: + # create the profile + create_user(account_info.json()["email"], "", "google", name=account_info.json()["name"], organization=None) + # set email as verified + verify_user("google:" + account_info.json()["email"]) + # log user in + flash("User " + account_info.json()["name"] + " successfully logged in!") + session['user'] = { + 'login_type': 'google', + 'id': account_info.json()["email"], + } + return redirect(BASE_URL + '/') + + return redirect(BASE_URL + '/') + +@user_management.route("/google/logout") +def google_logout(): + if google_blueprint.token is not None: + token = google_blueprint.token["access_token"] + resp = google.post( + "https://accounts.google.com/o/oauth2/revoke", + params={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + assert resp.ok, resp.text + del google_blueprint.token # Delete OAuth token from storage + logout_user() + return redirect(BASE_URL + '/') + + +""" + T W I T T E R +""" + +@user_management.route("/login/use-twitter") +def twitter_login(): + if not twitter.authorized: + return redirect(url_for("twitter.login")) + resp = twitter.get("/2/users/me") + + if check_if_user_exists("twitter:" + resp.json()["data"]["id"]): + #log user in + flash("User " + resp.json()["data"]["name"] + " successfully logged in!") + session['user'] = { + 'login_type': 'twitter', + 'id': resp.json()["data"]["id"], + } + else: + return render_template('oauth_account.html', email=resp.json()["data"]["username"], name=resp.json()["data"]["name"], provider="twitter") + + return redirect(BASE_URL + '/') + +@user_management.route("/login/twitter/account/create") +def twitter_login_create_account(): + if not twitter.authorized: + return redirect(url_for("twitter.login")) + resp = twitter.get("/2/users/me") + if check_if_user_exists("twitter:" + resp.json()["data"]["id"]) is False: + # create the profile + create_user(resp.json()["data"]["id"], "", "twitter", name=resp.json()["data"]["name"], organization=None) + # set email as verified + verify_user("twitter:" + resp.json()["data"]["id"]) + # log user in + flash("User " + resp.json()["data"]["name"] + " successfully logged in!") + session['user'] = { + 'login_type': 'twitter', + 'id': resp.json()["data"]["id"], + } + return redirect(BASE_URL + '/') + + return redirect(BASE_URL + '/') + +@user_management.route("/twitter/logout") +def twitter_logout(): + del twitter_blueprint.token + return redirect(BASE_URL + '/') + +""" + G I T H U B +""" + + +@user_management.route("/login/use-github") +def github_login(): + if not github.authorized: + return redirect(url_for("github.login")) + resp = github.get("/user") + # if user has a private email address it will be shown as null in resp + mail = github.get("/user/emails") # gets email address every time + m = 0 + while m < len(mail.json()) and mail.json()[m]["primary"] != True: + m += 1 + + if resp.json()["name"] is not None: + name = resp.json()["name"] + else: + name = resp.json()["login"] + + if check_if_user_exists("github:" + mail.json()[m]["email"]): + #log user in + flash("User " + name + " successfully logged in!") + session['user'] = { + 'login_type': 'github', + 'id': mail.json()[m]["email"], + } + + return redirect(BASE_URL + '/') + else: + session['user-info'] = { + 'name': name, + 'mail': mail.json()[m]["email"], + } + return render_template('oauth_account.html', email=mail.json()[m]["email"], name=name, provider="github") + return redirect(BASE_URL + '/') + +@user_management.route("/login/github/account/create") +def github_login_create_account(): + if check_if_user_exists("github:" + session['user-info']["mail"]) is False: + # create the profile + create_user(session['user-info']["mail"], "", "github", name=session['user-info']["name"], organization=None) + # set email as verified + verify_user("github:" + session['user-info']["mail"]) + # log user in + flash("User " + session['user-info']["name"] + " successfully logged in!") + session['user'] = { + 'login_type': 'github', + 'id': session['user-info']["mail"], + } + return redirect(BASE_URL + '/') + + return redirect(BASE_URL + '/') + +@user_management.route("/github/logout") +def github_logout(): + del github_blueprint.token + return redirect(BASE_URL + '/') \ No newline at end of file diff --git a/NERDweb/userdb.py b/NERDweb/userdb.py index 1af598d2..1b16c371 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, 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, [], name, 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,106 @@ 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)) +# 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() + 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]} +# TODO? Proc se vraci "name"? + +def check_if_user_exists(user_id): + cur = db.cursor() + cur.execute("SELECT id, name FROM users WHERE id = %s", (user_id,)) + row = cur.fetchone() + if not row: + return False + return True + +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 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 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): + try: + cur = db.cursor() + cur.execute("""UPDATE users SET last_login=%s WHERE email = %s""", (date_time, email)) + db.commit() + cur.close() + 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): + 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] +# TODO? proc je to podle emailu a ne id? + +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 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 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 +203,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 +214,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,8 +234,10 @@ 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'] +# 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/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. diff --git a/etc/nerdweb.yml b/etc/nerdweb.yml index b51b870a..f0dc9bd2 100644 --- a/etc/nerdweb.yml +++ b/etc/nerdweb.yml @@ -44,7 +44,18 @@ login: # Path to .htpasswd file with usernames and passwords (relative to NERD's "etc" dir) # Note that the file must be accessible by web server for both read and write (to allow password change) htpasswd_file: "htpasswd" - + + google: + loc: "/login/google" + logout_path: "/nerd/user/google/logout" + + github: + loc: "/login/github" + logout_path: "/nerd/user/github/logout" + + twitter: + loc: "/login/twitter" + logout_path: "/nerd/user/twitter/logout" # shibboleth: # display: EduGAIN # display_order: 1 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..a1518a85 100644 --- a/install/pip_requirements_nerdweb.txt +++ b/install/pip_requirements_nerdweb.txt @@ -11,3 +11,5 @@ redis hiredis cachetools event_count_logger +bcrypt +flask_dance