From 01f158c6c0dfeb5b3a552ff410d60a447d83c3f3 Mon Sep 17 00:00:00 2001 From: Ehsan Asdar Date: Wed, 15 Aug 2018 01:45:37 -0500 Subject: [PATCH] Basic local registration support This implements "local" registration where usernames and hashed passwords are stored in the Postgres database. This also requires email verification via an email sent through a configured SMTP server. This is copied from HackGT's prior work and doesn't currently include "coexistance" of both Quill and local auth options. That will require a bit of further refactoring. --- hardwarecheckout/config.py | 6 ++ hardwarecheckout/controllers/login.py | 84 ++++++++++++++----- hardwarecheckout/forms/register_form.py | 6 ++ hardwarecheckout/models/user.py | 8 +- hardwarecheckout/static/css/app.css | 8 ++ hardwarecheckout/templates/includes/nav.html | 1 + hardwarecheckout/templates/pages/login.html | 8 ++ .../templates/pages/register.html | 32 +++++++ hardwarecheckout/utils.py | 20 +++++ requirements.txt | 1 + 10 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 hardwarecheckout/forms/register_form.py create mode 100644 hardwarecheckout/templates/pages/register.html diff --git a/hardwarecheckout/config.py b/hardwarecheckout/config.py index ff8a679..1f19d57 100644 --- a/hardwarecheckout/config.py +++ b/hardwarecheckout/config.py @@ -12,6 +12,12 @@ # URL of Quill instance (for auth integration) QUILL_URL = os.environ['QUILL'] +# Required for email verification +DOMAIN = os.environ['EMAIL_DOMAIN'] +SMTP_HOST = os.environ['SMTP_HOST'] +SMTP_USER = os.environ['SMTP_USER'] +SMTP_PASSWORD = os.environ['SMTP_PASSWORD'] + # Random Secret for JWTs - must match Quill secret SECRET = os.environ['SECRET'] diff --git a/hardwarecheckout/controllers/login.py b/hardwarecheckout/controllers/login.py index 51e22a9..27626ad 100644 --- a/hardwarecheckout/controllers/login.py +++ b/hardwarecheckout/controllers/login.py @@ -1,18 +1,65 @@ from hardwarecheckout import app from hardwarecheckout import config from hardwarecheckout.models.user import * -from hardwarecheckout.utils import verify_token +from hardwarecheckout.utils import verify_token, gen_uuid, send_verification_email, gen_token import requests import datetime import json +import uuid from urlparse import urljoin from hardwarecheckout.forms.login_form import LoginForm +from hardwarecheckout.forms.register_form import RegisterForm from flask import ( redirect, render_template, request, url_for ) +from werkzeug.security import generate_password_hash, \ + check_password_hash + +@app.route('/verify') +def verify_page(): + if request.args.get('token'): + user = User.query.filter_by(verification_token=request.args.get('token')).first() + if user: + user.verified_email = True + db.session.commit() + response = app.make_response(redirect('/login?v=1')) + return response + + return "Token not found", 400 + +@app.route('/register') +def register_page(): + # Check if already logged in + if 'jwt' in request.cookies: + try: + decode_token(request.cookies['jwt']) + return redirect('/inventory') + except Exception as e: + pass + + return render_template('pages/register.html') + +@app.route('/register', methods=['POST']) +def register_handler(): + form = RegisterForm(request.form) + if form.validate(): + if User.query.filter_by(email=request.form['email']).first(): + return render_template('pages/register.html', error=["Email address already in use"]) + verification_token = uuid.uuid4().hex + user = User(gen_uuid(), request.form['email'], generate_password_hash(request.form['password']), verification_token, False) + db.session.add(user) + db.session.commit() + send_verification_email(request.form['email'], verification_token) + response = app.make_response(redirect('/login?r=1')) + return response + errors = [] + for field, error in form.errors.items(): + errors.append(field + ": " + "\n".join(error) + "\n") + + return render_template('pages/register.html', error=errors) @app.route('/login') def login_page(): @@ -23,35 +70,30 @@ def login_page(): return redirect('/inventory') except Exception as e: pass - - return render_template('pages/login.html') + + success = None + if request.args.get('r'): + success = ["Account created! Check your email to verify your account."] + elif request.args.get('v'): + success = ["Account verified! Login to continue."] + + return render_template('pages/login.html', success=success) @app.route('/login', methods=['POST']) def login_handler(): """Log user in""" form = LoginForm(request.form) if form.validate(): - url = urljoin(config.QUILL_URL, '/auth/login') - r = requests.post(url, data={'email':request.form['email'], 'password':request.form['password']}) - try: - r = json.loads(r.text) - except ValueError as e: - return render_template('pages/login.html', error=[str(e)]) - - if 'message' in r: - return render_template('pages/login.html', error=[r['message']]) + user = User.query.filter_by(email=request.form['email']).first() - quill_id = verify_token(r['token']) - if not quill_id: - return render_template('pages/login.html', error=['Invalid token returned by registration']) - - if User.query.filter_by(quill_id=quill_id).count() == 0: - user = User(quill_id, request.form['email'], r['user']['admin']) - db.session.add(user) - db.session.commit() + if not user or not check_password_hash(user.password_hash, request.form['password']): + return render_template('pages/login.html', error=["Invalid username or password"]) + + if not user.verified_email: + return render_template('pages/login.html', error=["Please verify your email to login"]) response = app.make_response(redirect('/inventory')) - response.set_cookie('jwt', r['token']) + response.set_cookie('jwt', gen_token(user.quill_id)) return response errors = [] diff --git a/hardwarecheckout/forms/register_form.py b/hardwarecheckout/forms/register_form.py new file mode 100644 index 0000000..e2310d9 --- /dev/null +++ b/hardwarecheckout/forms/register_form.py @@ -0,0 +1,6 @@ +from wtforms import Form, StringField, PasswordField, FileField, validators + +class RegisterForm(Form): + email = StringField('email_address', [validators.input_required(), validators.email()]) + password = PasswordField('password', [validators.input_required(), validators.length(min=6), validators.equal_to('confirm', message='Passwords must match')]) + confirm = PasswordField('confirm', [validators.input_required(), validators.length(min=6)]) \ No newline at end of file diff --git a/hardwarecheckout/models/user.py b/hardwarecheckout/models/user.py index fb8bcb8..b2c5f7e 100644 --- a/hardwarecheckout/models/user.py +++ b/hardwarecheckout/models/user.py @@ -3,11 +3,14 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) quill_id = db.Column(db.String(), unique=True) + verified_email = db.Column(db.Boolean) + verification_token = db.Column(db.String(), unique=True) is_admin = db.Column(db.Boolean) location = db.Column(db.String(120)) name = db.Column(db.String()) phone = db.Column(db.String(255)) email = db.Column(db.String()) + password_hash = db.Column(db.String()) notifications = db.Column(db.Boolean) have_their_id = db.Column(db.Boolean) requests = db.relationship('Request', back_populates='user') @@ -15,15 +18,18 @@ class User(db.Model): items = db.relationship('Item', backref='user') - def __init__(self, quill_id, email, is_admin): + def __init__(self, quill_id, email, password_hash, verification_token, is_admin): self.quill_id = quill_id self.email = email + self.password_hash = password_hash self.is_admin = is_admin self.name = '' self.location = '' self.phone = '' self.notifications = False self.have_their_id = False + self.verified_email = False + self.verification_token = verification_token def requires_id(self): for item in self.items: diff --git a/hardwarecheckout/static/css/app.css b/hardwarecheckout/static/css/app.css index 98e1bbf..cceaed2 100644 --- a/hardwarecheckout/static/css/app.css +++ b/hardwarecheckout/static/css/app.css @@ -6,6 +6,14 @@ text-align: left; vertical-align: middle; } +#register-wrapper { + max-width: 450px; + margin-top: 35px; } + +#register-form { + text-align: left; + vertical-align: middle; } + .footer { color: #777; padding-bottom: 1em; diff --git a/hardwarecheckout/templates/includes/nav.html b/hardwarecheckout/templates/includes/nav.html index 8abb484..1512160 100644 --- a/hardwarecheckout/templates/includes/nav.html +++ b/hardwarecheckout/templates/includes/nav.html @@ -11,6 +11,7 @@ {{ user.email }} Logout {% else %} + Register Login {% endif %} diff --git a/hardwarecheckout/templates/pages/login.html b/hardwarecheckout/templates/pages/login.html index f3bc072..08551e4 100644 --- a/hardwarecheckout/templates/pages/login.html +++ b/hardwarecheckout/templates/pages/login.html @@ -16,6 +16,7 @@

{{ config["HACKATHON_NAME"] }} Hardware

+ Register {% if error %}
Login Errors: @@ -24,6 +25,13 @@

{{ config["HACKATHON_NAME"] }} Hardware

{% endfor %}
{% endif %} + {% if success %} +
+ {% for item in success %} +
{{item}}
+ {% endfor %} +
+ {% endif %} {% endblock %} diff --git a/hardwarecheckout/templates/pages/register.html b/hardwarecheckout/templates/pages/register.html new file mode 100644 index 0000000..22f7408 --- /dev/null +++ b/hardwarecheckout/templates/pages/register.html @@ -0,0 +1,32 @@ +{% extends "includes/wrapper.html" %} +{% block title %} Register {% endblock %} + +{% block content %} +
+
+

{{ config["HACKATHON_NAME"] }} Hardware

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ {% if error %} +
Registration Errors: + {% for item in error %} +
{{item}}
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/hardwarecheckout/utils.py b/hardwarecheckout/utils.py index 38f13e9..97c8ae6 100644 --- a/hardwarecheckout/utils.py +++ b/hardwarecheckout/utils.py @@ -2,6 +2,7 @@ from jose import jws from jose.exceptions import JWSError import os +import time from hardwarecheckout.config import * from hardwarecheckout.constants import * from hardwarecheckout.models.user import * @@ -9,10 +10,18 @@ from functools import wraps from datetime import datetime from babel import dates +import yagmail def gen_uuid(): return str(uuid.uuid4()).replace('-', '') +def gen_token(id, duration=604800): + try: + expiry = int(time.time()) + duration + return jws.sign(id, SECRET) + except JWSError: + return None + def verify_token(token): try: return jws.verify(token, SECRET, algorithms=['HS256']) @@ -28,6 +37,17 @@ def safe_redirect(endpoint, request): ), 401 return redirect(url_for(endpoint)) +def send_verification_email(to, token): + yag = yagmail.SMTP(user=SMTP_USER, password=SMTP_PASSWORD, host=SMTP_HOST) + subject = "[%s] Verify your email" % (HACKATHON_NAME) + content = """ + Thanks for registering for Hardware Checkout at %s! + + Click here to verify your email. + + The %s Team""" % (HACKATHON_NAME, DOMAIN + "/verify?token=" + token, HACKATHON_NAME) + yag.send(to, subject, content) + def requires_auth(): def decorator(f): @wraps(f) diff --git a/requirements.txt b/requirements.txt index 16fb224..a7a98b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,3 +61,4 @@ wrapt==1.10.11 WTForms==2.0 WTForms-Alchemy==0.15.0 WTForms-Components==0.10.3 +yagmail==0.11.214 \ No newline at end of file