Merge branch 'rb-verification-challenge-refactor' into 'main'

Verification challenge code refactor

See merge request engineering/remarkbox/remarkbox!1
This commit is contained in:
Russell Ballestrini 2022-06-19 23:06:30 +00:00
commit cf89a62a66
15 changed files with 246 additions and 100 deletions

View file

@ -20,6 +20,7 @@ clean:
test: dev
env/bin/py.test
#env/bin/py.test --lf
serve: dev
# In the first shell, run a copy of remarkbox using:

View file

@ -94,17 +94,27 @@ Now browse to http://127.0.0.1:8000 and index.html will load and will have an em
If you attempt to log in, your third shell will capture the email and you can copy / paste the verification link to log in!
SQL Migrations
===============
New Environments
================
If your deployment is brand new, you don't need to run any migrations.
To create all the schemas & tables in your database, run:
.. code-block:: bash
env/bin/remarkbox_init_db development.ini
You should however run this to stamp the database as ready:
.. code-block:: bash
alembic -c development.ini stamp head
SQL Migrations
===============
Otherwise, it should be safe to run this at anytime to catch your database up:
.. code-block:: bash

View file

@ -52,7 +52,7 @@ Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem
</div>
<script src="http://127.0.0.1:6543/static/js/iframe-resizer/iframeResizer.min.js"></script>
<script>
var rb_owner_key = "f7ea7fb0-cd90-11e7-a69d-9c4e369c7158";
var rb_owner_key = "3484c7d4-f012-11ec-b5f9-843a4b34def8";
var thread_uri = window.location.href;
var thread_title = window.document.title;
var thread_fragment = window.location.hash;

View file

@ -25,41 +25,30 @@ jinja2_env = Environment(
)
def send_otp_email(request, to_email, raw_otp, return_to):
def send_verification_digits_to_email(request, to_email, raw_digits):
"""
Send email with OTP (one time password) link.
Send email with raw_digits a user may pass to verify & authenticate.
request
the request (of the successful log in attempt)
to_email
the email address to send the OTP link
the email address to send the raw_digits
raw_otp
the raw (unencrypted) one time password
return_to
the URI to return the user on successful authentication
raw_digits:
the raw (unencrypted) digits the user may use to verify & authenticate.
"""
subject = "Verification Code - {}".format(raw_digits)
query_params = [
"email={}".format(quote_plus(to_email)),
"raw-otp={}".format(raw_otp),
]
if return_to:
query_params.append("return-to={}".format(return_to))
link = "{0}/join-or-log-in?{1}".format(request.host_url, "&".join(query_params))
subject = "Magic sign-in link for comments"
message_text = WELCOME_1_TEXT.format(raw_digits)
message_html = WELCOME_1_HTML.format(subject, raw_digits)
if not request.user.verified:
message_text = WELCOME_1_TEXT.format(link)
message_html = WELCOME_1_HTML.format(subject, link)
message_text = WELCOME_1_TEXT.format(raw_digits)
message_html = WELCOME_1_HTML.format(subject, raw_digits)
else:
message_text = WELCOME_2_TEXT.format(link)
message_html = WELCOME_2_HTML.format(subject, link)
message_text = WELCOME_2_TEXT.format(raw_digits)
message_html = WELCOME_2_HTML.format(subject, raw_digits)
send_pyramid_email(request, to_email, subject, message_text, message_html)

View file

@ -3,11 +3,13 @@ Hello!
Thanks for joining the discussion.
To get reply notifications, you should paste this link into your browser:
Hey there!
Here is the verification code you requested:
\n{0}\n
This will verify your email and log you in.
Type code into the challenge input field.
What's next?
@ -33,23 +35,10 @@ WELCOME_1_HTML = """
</p>
<p>
To get reply notifcations, you should click this link:
Here is the verification code you requested:
</p>
<p>
<a href="{1}" style="font-weight: bold;" target="_blank">Click here to verify and log in!</a>
</p>
<p style="font-size: .8em;">
<span style="color: #aaaaaa;">
You may paste this link into your browser:
</span>
<br>
<br>
<a href="{1}" style="color: #439fe0; font-weight: normal; text-decoration: none; word-break: break-word;" target="_blank">
{1}
</a>
</p>
<p><b>{1}</b></p>
<p>
This will verify your email and log you in.
@ -76,10 +65,12 @@ WELCOME_1_HTML = """
WELCOME_2_TEXT = """
Hello again!
To log in please paste this link into your browser:
Here is the verification code you requested:
\n{0}\n
Type code into the challenge input field.
Don't forget to check out your notification settings.
Talk to you soon!
@ -95,23 +86,10 @@ WELCOME_2_HTML = """
<h2>Hello again!</h2>
<p>
To log in please click the link below.
Here is the verification code you requested:
</p>
<p>
<a href="{1}" style="font-weight: bold;" target="_blank">Click here to log in!</a>
</p>
<p style="font-size: .8em;">
<span style="color: #aaaaaa;">
You may paste this link into your browser:
</span>
<br>
<br>
<a href="{1}" style="color: #439fe0; font-weight: normal; text-decoration: none; word-break: break-word;" target="_blank">
{1}
</a>
</p>
<p><b>{1}</b></p>
<h3>What's next?</h3>

View file

@ -150,3 +150,7 @@ class RBase(object):
@property
def dbsession(self):
return object_session(self)
@property
def id_without_dashes(self):
return self.id.__str__().replace("-","")

View file

@ -1,4 +1,4 @@
from sqlalchemy import BigInteger, Boolean, Column, Unicode, Enum, func, or_
from sqlalchemy import BigInteger, Boolean, Integer, Column, Unicode, Enum, func, or_
from sqlalchemy.orm import relationship, backref
@ -91,6 +91,7 @@ class User(RBase, Base):
# we only share this string with the owner of an email box. not-in-use...
email_id = Column(Unicode(8))
password = Column(Unicode(64))
password_attempts = Column(Integer, default=0)
password_timestamp = Column(BigInteger)
# TODO: someday this should be renamed to created_timestamp
created = Column(BigInteger, nullable=False)
@ -261,15 +262,20 @@ class User(RBase, Base):
self.created = now_timestamp()
self.id = uuid.uuid1()
self.email = unicode(email)
self.email_id = unicode(self._generate_raw_password(8))
# TODO: this field was never used & should be sunset.
self.email_id = unicode(generate_password(size=8))
self.new_password()
# don't password throttle new User objects.
self.password_timestamp = 0
def _generate_raw_password(self, size=32):
def _generate_raw_password(self):
"""Return a system generated password"""
return generate_password(size)
#return generate_password(size)
from random import choice
numbers = '0123456789'
return "".join([choice(numbers) for i in range(0,6)])
def new_password(self):
"""Generate and return raw password, store password hash into DB."""
@ -281,19 +287,32 @@ class User(RBase, Base):
bcrypt.gensalt()
).decode("utf-8")
self.password_timestamp = now_timestamp()
self.password_attempts = 0
return raw_password
def check_password(self, password):
"""Accept plain-text raw password, create hash, compare with DB."""
stored_hash = self.password
# increment password attempts.
self.password_attempts += 1
# expire password after 15 minutes.
# 900000 milliseconds == 15 minutes
if self.password_timestamp_delta >= 900000:
return False
# prevent brute force, allow 10 invalid verification code attempts.
if self.password_attempts >= 10:
return False
# bcrypt works with bytes so we encode to utf-8.
new_hash = bcrypt.hashpw(
password.encode("utf-8"),
stored_hash.encode("utf-8"),
).decode("utf-8")
log.info("new_hash={} stored_hash={}".format(new_hash, stored_hash))
#log.info("new_hash={} stored_hash={}".format(new_hash, stored_hash))
if new_hash == stored_hash:
return True

View file

@ -96,6 +96,8 @@ def includeme(config):
# basic routes:
config.add_route("basic-join-or-log-in", "/join-or-log-in")
config.add_route("verification-challenge", "/verification-challenge")
config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes")
config.add_route("basic-namespace-settings", "/ns/{namespace}/settings")
config.add_route("basic-namespace-stats-json", "/ns/{namespace}/stats.json")

View file

@ -22,9 +22,6 @@ input.rb-submit {
{% if return_to %}
<input type="hidden" name="return-to" value="{{ return_to }}" >
{% endif %}
{% if thread_uri %}
<input type="hidden" name="thread_uri" value="{{ thread_uri }}" >
{% endif %}
</form>
<span class="whats-next">
<b>No account?</b>

View file

@ -26,7 +26,7 @@
{{ snippets.user_avatar_link(request.user, 35, class="avatar-nav") }}
</span>
{% else %}
<a href="{{ domain_app }}{% if request.mode == "embed" %}{{ request.link_prefix }}{% endif %}/join-or-log-in{% if request.mode == "basic" %}?return-to={{ request.path_url }}{% elif request.node and request.mode == "embed" %}?thread_uri={{ request.node.root.uri.data }}{% endif %}" style="display: none;">join-or-log-in</a>
<a href="{{ domain_app }}{% if request.mode == "embed" %}{{ request.link_prefix }}{% endif %}/join-or-log-in{% if request.mode == "basic" %}?return-to={{ request.path_url }}{% endif %}" style="display: none;">join-or-log-in</a>
{%- endif %}

View file

@ -0,0 +1,39 @@
{% extends request.base_funnel_template -%}
{% block title %}verify code | {{ request.domain }}{% endblock -%}
{% block content %}
<section class="one-column">
<section class="log-in-form well">
<h2>Verification Code</h2>
<b>Please enter code to log in.<b>
<br>
<br>
<form method="post" action="/verification-challenge">
<input
name = "email"
type = "hidden"
id = "email_input"
class = "common-text-input"
value = "{{ email }}"
placeholder = "Your Email Address" />
<input
name = "raw-otp"
type = "number"
id = "raw-otp"
class = "common-text-input"
tabindex = "1"
value = "{% if raw_otp %}{{ raw_otp }}{% endif %}"
placeholder = "6 digit challenge verification code"
required
autofocus />
<br/>
<br/>
<input type="submit" name="submit" id="submit" value="verify code" required />
</form>
<br/>
</section>
</section>
{%- endblock -%}

View file

@ -38,7 +38,7 @@ class TestUser(unittest.TestCase):
def test_new_password(self):
raw_password = self.user.new_password()
self.assertGreater(len(raw_password), 30)
self.assertEqual(len(raw_password), 6)
def test_check_password_success(self):
raw_password = self.user.new_password()

View file

@ -102,8 +102,8 @@ class UnauthenticatedFunctionalTests(FunctionalTests):
self.assertIn(b"The resource was found at", redirect_res2.body)
res = redirect_res2.follow()
self.assertIn(b"test title", res.body)
self.assertIn(b"test data", res.body)
self.assertIn(b"Your post was successful!", res.body)
self.assertIn(b"We just sent a link to test@example.com. Check email to log in.", res.body)
def test_new_thread_without_email(self):
redirect_res = self.testapp.post(
@ -210,8 +210,8 @@ class AuthenticatedFunctionalTests(FunctionalTests):
def _log_in_test_user(self, test_creds):
# log in user.
res_login = self.testapp.get(
"/join-or-log-in?email={}&raw-otp={}".format(*test_creds)
res_login = self.testapp.post(
"/verification-challenge?email={}&raw-otp={}&submit".format(*test_creds)
)
# attach csrf to class.
res_csrf = self.testapp.get("/")
@ -219,10 +219,8 @@ class AuthenticatedFunctionalTests(FunctionalTests):
return res_login
def test_log_in(self):
redirect_res1 = self._log_in_test_user(self.test_creds1)
redirect_res2 = redirect_res1.follow()
redirect_res3 = redirect_res2.follow()
self.assertIn(self.test_user1.name.encode("utf-8"), redirect_res3.body)
res = self._log_in_test_user(self.test_creds1)
self.assertIn(self.test_user1.name.encode("utf-8"), res.body)
def test_log_in_or_join_creates_default_watcher(self):
"""This tests makes sure a default watcher is created for new users."""

View file

@ -105,12 +105,15 @@ def get_join_or_log_in_route_uri(request, return_to=""):
)
nodes_pending_verify = lambda request: request.session.get("nodes_pending_verify", [])
def nodes_pending_verify(request):
if "nodes_pending_verify" not in request.session:
request.session["nodes_pending_verify"] = []
return request.session["nodes_pending_verify"]
def set_node_to_pending_in_session(request, node):
"""Update session to make node go into pending_verify state."""
nodes_pending_verify(request).append(str(node.id))
nodes_pending_verify(request).append(str(node.id_without_dashes))
def verify_pending_nodes_in_session(request, user):
@ -123,6 +126,6 @@ def verify_pending_nodes_in_session(request, user):
if user == node.user:
node.verified = True
request.dbsession.add(node)
still_pending_verify.remove(str(node.id))
request.dbsession.flush()
still_pending_verify.remove(str(node.id_without_dashes))
request.session["nodes_pending_verify"] = still_pending_verify
request.dbsession.flush()

View file

@ -1,14 +1,19 @@
import re
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from remarkbox.models import get_node_by_uri, get_node_by_id
from remarkbox.lib.mail import send_otp_email
from remarkbox.models.user import get_or_create_user_by_email
from remarkbox.lib.mail import send_verification_digits_to_email
from . import get_referer_or_home, get_embed_route_uri, verify_pending_nodes_in_session
@view_config(route_name="log-out")
@view_config(route_name="embed-log-out")
def log_out(request):
@ -17,6 +22,7 @@ def log_out(request):
request.session["authenticated_user_id"] = None
return HTTPFound(uri)
'''
# disable CSRF checking for iframe embedded version of this view.
# If a client has 3rd party cookies disabled this security feature causes
@ -31,11 +37,7 @@ def join_or_log_in(request):
It uses "password-less" authentication by sending OTP (one-time-password)
links to the user's email address.
"""
# get the return_to uri from posted parameters.
return_to = request.params.get("return-to", "")
# get the return_to uri from posted parameters.
thread_uri = request.params.get("thread_uri", "")
_email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$")
# get the raw OTP (one-time-password) from posted parameters.
raw_otp = request.params.get("raw-otp", "")
@ -43,6 +45,11 @@ def join_or_log_in(request):
# get the email_id from posted parameters.
email_id = request.params.get("email-id", "")
if email_id and _email_regex.match(email_id) is None:
# posted email does not pass regex, set it to None.
email_id = ""
request.session.flash(("That email address is invalid.", "error"))
if request.spam:
return request.spam
@ -50,8 +57,6 @@ def join_or_log_in(request):
if request.user.authenticated:
# user already authenticated, return early.
if return_to:
return HTTPFound(return_to)
return HTTPFound(get_referer_or_home(request))
user = request.user
@ -72,7 +77,7 @@ def join_or_log_in(request):
request.dbsession.add(user)
request.dbsession.flush()
return HTTPFound(return_to)
return HTTPFound("/")
if user.throttle_password():
msg = (
@ -86,15 +91,8 @@ def join_or_log_in(request):
request.dbsession.add(user)
request.dbsession.flush()
email_return_to = return_to
if thread_uri:
if "#" not in thread_uri:
thread_uri = thread_uri + "#remarkbox-div"
email_return_to = thread_uri
# email user the one-time-password and flash message.
send_otp_email(request, user.email, raw_otp, email_return_to)
send_otp_email(request, user.email, raw_otp)
msg = (
"We just sent a link to {}. Click it to log in.".format(user.email),
@ -126,5 +124,113 @@ def join_or_log_in(request):
# 'the_title' : 'join or log in',
"title": "join or log in",
"return_to": return_to,
"thread_uri": thread_uri,
}
'''
#@view_config(route_name="join-or-log-in", renderer="join-or-log-in.j2")
@view_config(route_name="basic-join-or-log-in", renderer="join-or-log-in.j2", require_csrf=False)
@view_config(route_name="embed-join-or-log-in", renderer="join-or-log-in.j2", require_csrf=False)
def join_or_log_in(request):
"""
This view handles user registration, verification, and log in.
It uses "password-less" authentication by sending 6 digit
OTP (one-time-password) tokens to email addresses to verify both the email
address & to authenticate the device displaying the challenge input field.
"""
_email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$")
# get the raw OTP (one-time-password) from posted parameters.
raw_otp = request.params.get("raw-otp", "")
# get the email from posted parameters.
email = request.params.get("email", "")
if email and _email_regex.match(email) is None:
# posted email does not pass regex, set it to None.
email = ""
request.session.flash(("That email address is invalid.", "error"))
if request.spam:
return request.spam
if request.user and request.user.authenticated:
return HTTPFound(get_referer_or_home(request))
elif email:
# get or create a User object from the posted email.
user = get_or_create_user_by_email(request.dbsession, email)
if user.throttle_password():
msg = (
"We already sent a link to {}. Check email to log in.".format(user.email),
"info",
)
else:
# generate a new one-time-password and save to database
raw_otp = user.new_password()
request.dbsession.add(user)
request.dbsession.flush()
# email user the one-time-password and flash message.
send_verification_digits_to_email(request, user.email, raw_otp)
msg = (
"We just sent a link to {}. Check email to log in.".format(user.email),
"info",
)
request.session.flash(msg)
return HTTPFound("/verification-challenge?email={}".format(email))
return {
"title": "join or log in",
}
@view_config(route_name="verification-challenge", renderer="verification-challenge.j2", require_csrf=False)
def verification_challenge(request):
# get the raw OTP (one-time-password) from posted parameters.
raw_otp = request.params.get("raw-otp", "")
# get the email from posted parameters.
email = request.params.get("email", "")
user = None
if email:
# get or create a User object from the posted email.
user = get_or_create_user_by_email(request.dbsession, email)
if "submit" in request.params:
if raw_otp and user.check_password(raw_otp):
# success: the user was verified.
user.verified = True
msg = ("Welcome {}".format(user.name), "success")
request.session["authenticated_user_id"] = str(user.id)
request.session.flash(msg)
# attempt to verify all nodes_pending_verify in user's session.
verify_pending_nodes_in_session(request, user)
# Idempotent operation. Make certain a user has at least one reply_watcher.
user.create_default_reply_watcher()
request.dbsession.add(user)
request.dbsession.flush()
#return HTTPFound("/")
else:
msg = ("Invalid Verification Code", "error")
request.session.flash(msg)
return {
"title": "Please Enter Verification Code",
"email": email,
"raw_otp": raw_otp,
}