user login and registration - unfinished

This commit is contained in:
rimu 2023-08-05 21:25:18 +12:00
parent 3b1c087a61
commit 3b0a4a4388
8 changed files with 390 additions and 0 deletions

22
app/auth/email.py Normal file
View file

@ -0,0 +1,22 @@
from flask import render_template, current_app
from flask_babel import _
from app.email import send_email
def send_password_reset_email(user):
token = user.get_reset_password_token()
send_email(_('[PyFedi] Reset Your Password'),
sender='PyFedi <rimu@chorebuster.net>',
recipients=[user.email],
text_body=render_template('email/reset_password.txt',
user=user, token=token),
html_body=render_template('email/reset_password.html',
user=user, token=token))
def send_welcome_email(user):
send_email(_('Welcome to PyFedi'),
sender='PyFedi <rimu@chorebuster.net>',
recipients=[user.email],
text_body=render_template('email/welcome.txt', user=user),
html_body=render_template('email/welcome.html', user=user))

43
app/auth/forms.py Normal file
View file

@ -0,0 +1,43 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, HiddenField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length
from flask_babel import _, lazy_gettext as _l
from app.models import User
from app.auth.recaptcha3 import Recaptcha3Field
class LoginForm(FlaskForm):
user_name = StringField(_l('User name'), validators=[DataRequired()])
password = PasswordField(_l('Password'), validators=[DataRequired()])
submit = SubmitField(_l('Log In'))
class RegistrationForm(FlaskForm):
user_name = StringField(_l('User name'), validators=[DataRequired()])
email = HiddenField(_l('Email'))
real_email = StringField(_l('Email'), validators=[DataRequired(), Email(), Length(min=5, max=255)])
password = PasswordField(_l('Password'), validators=[DataRequired(), Length(min=5, max=50)])
password2 = PasswordField(
_l('Repeat password'), validators=[DataRequired(),
EqualTo('password')])
recaptcha = Recaptcha3Field(action="TestAction", execute_on_load=True)
submit = SubmitField(_l('Sign up'))
def validate_real_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError(_('An account with this email address already exists.'))
class ResetPasswordRequestForm(FlaskForm):
email = StringField(_l('Email'), validators=[DataRequired(), Email()])
submit = SubmitField(_l('Request password reset'))
class ResetPasswordForm(FlaskForm):
password = PasswordField(_l('Password'), validators=[DataRequired()])
password2 = PasswordField(
_l('Repeat password'), validators=[DataRequired(),
EqualTo('password')])
submit = SubmitField(_l('Request password reset'))

139
app/auth/recaptcha3.py Normal file
View file

@ -0,0 +1,139 @@
import logging
import requests
from flask import Markup, current_app, json, request
from wtforms import ValidationError
from wtforms.fields import HiddenField
from wtforms.widgets import HiddenInput
logger = logging.getLogger(__name__)
JSONEncoder = json.JSONEncoder
RECAPTCHA_TEMPLATE = '''
<script src='https://www.google.com/recaptcha/api.js?render={public_key}&onload=executeRecaptcha{action}' async defer></script>
<script>
var executeRecaptcha{action} = function() {{
console.log("grecaptcha is ready!");
grecaptcha.execute('{public_key}', {{action: '{action}'}}).then(function(token) {{
console.log(token);
document.getElementById("{field_name}").value = token;
}});
}};
</script>
<input type="hidden" id="{field_name}" name="{field_name}">
'''
RECAPTCHA_TEMPLATE_MANUAL = '''
<script src='https://www.google.com/recaptcha/api.js?render={public_key}' async defer></script>
<script>
var executeRecaptcha{action} = function() {{
console.log("executeRecaptcha{action}() is called!");
grecaptcha.ready(function() {{
console.log("grecaptcha is ready!");
grecaptcha.execute('{public_key}', {{action: '{action}'}}).then(function(token) {{
console.log(token);
document.getElementById("{field_name}").value = token;
}});
}});
}};
</script>
<input type="hidden" id="{field_name}" name="{field_name}">
'''
RECAPTCHA_VERIFY_SERVER = 'https://www.google.com/recaptcha/api/siteverify'
RECAPTCHA_ERROR_CODES = {
'missing-input-secret': 'The secret parameter is missing.',
'invalid-input-secret': 'The secret parameter is invalid or malformed.',
'missing-input-response': 'The response parameter is missing.',
'invalid-input-response': 'The response parameter is invalid or malformed.'
}
class Recaptcha3Validator(object):
"""Validates a ReCaptcha."""
def __init__(self, message=None):
if message is None:
message = "Please verify that you are not a robot."
self.message = message
def __call__(self, form, field):
if current_app.testing:
return True
token = field.data
if not token:
logger.warning("Token is not ready or incorrect configuration (check JavaScript error log).")
raise ValidationError(field.gettext(self.message))
remote_ip = request.remote_addr
if not Recaptcha3Validator._validate_recaptcha(field, token, remote_ip):
field.recaptcha_error = 'incorrect-captcha-sol'
raise ValidationError(field.gettext(self.message))
@staticmethod
def _validate_recaptcha(field, response, remote_addr):
"""Performs the actual validation."""
try:
private_key = current_app.config['RECAPTCHA3_PRIVATE_KEY']
except KeyError:
raise RuntimeError("RECAPTCHA3_PRIVATE_KEY is not set in app config.")
data = {
'secret': private_key,
'remoteip': remote_addr,
'response': response
}
http_response = requests.post(RECAPTCHA_VERIFY_SERVER, data)
if http_response.status_code != 200:
return False
json_resp = http_response.json()
if json_resp["success"] and json_resp["action"] == field.action and json_resp["score"] > field.score_threshold:
logger.info(json_resp)
return True
else:
logger.warning(json_resp)
for error in json_resp.get("error-codes", []):
if error in RECAPTCHA_ERROR_CODES:
raise ValidationError(RECAPTCHA_ERROR_CODES[error])
return False
class Recaptcha3Widget(HiddenInput):
def __call__(self, field, **kwargs):
"""Returns the recaptcha input HTML."""
public_key_name = 'RECAPTCHA3_PUBLIC_KEY'
try:
public_key = current_app.config[public_key_name]
except KeyError:
raise RuntimeError(f"{public_key_name} is not set in app config.")
return Markup(
(RECAPTCHA_TEMPLATE if field.execute_on_load else RECAPTCHA_TEMPLATE_MANUAL).format(
public_key=public_key, action=field.action, field_name=field.name))
class Recaptcha3Field(HiddenField):
widget = Recaptcha3Widget()
# error message if recaptcha validation fails
recaptcha_error = None
def __init__(self, action, score_threshold=0.5, execute_on_load=True, validators=None, **kwargs):
'''If execute_on_load is False, recaptcha.execute needs to be manually bound to an event to obtain token,
the JavaScript function to call is executeRecaptcha{action}, e.g. onsubmit="executeRecaptchaSignIn" '''
if not action:
# TODO: more validation on action, see https://developers.google.com/recaptcha/docs/v3#actions
# "actions may only contain alphanumeric characters and slashes, and must not be user-specific"
raise RuntimeError("action must not be none or empty.")
self.action = action
self.execute_on_load = execute_on_load
self.score_threshold = score_threshold
validators = validators or [Recaptcha3Validator()]
super(Recaptcha3Field, self).__init__(validators=validators, **kwargs)

View file

@ -0,0 +1,117 @@
from datetime import date, datetime, timedelta
from flask import render_template, redirect, url_for, flash, request, make_response, session, Markup
from werkzeug.urls import url_parse
from flask_login import login_user, logout_user, current_user
from flask_babel import _
from app import db
from app.auth import bp
from app.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm
from app.models import User
from app.auth.email import send_password_reset_email, send_welcome_email
from sqlalchemy import text
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('main.index')
return redirect(next_page)
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(user_name=form.user_name.data).first()
if user is None:
flash(_('No account exists with that user name address'), 'error')
return redirect(url_for('auth.login'))
if not user.check_password(form.password.data):
if user.password_hash is None:
if "@gmail.com" in user.email:
message = Markup(_('Invalid password. Please click the "Login using Google" button or <a href="/auth/reset_password_request">reset your password</a>.'))
flash(message, 'warning')
else:
message = Markup(_('Invalid password. Please <a href="/auth/reset_password_request">reset your password</a>.'))
flash(message, 'error')
return redirect(url_for('auth.login'))
flash(_('Invalid password'))
return redirect(url_for('auth.login'))
login_user(user, remember=True)
current_user.last_seen = datetime.utcnow()
db.session.commit()
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('main.index')
return redirect(next_page)
return render_template('auth/login.html', title=_('Login'), form=form)
@bp.route('/logout')
def logout():
logout_user()
return redirect(url_for('main.index'))
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
if form.email.data == '': # ignore any registration where the email field is filled out. spam prevention
if form.real_email.data.lower().startswith('postmaster@') or form.real_email.data.lower().startswith('abuse@') or \
form.real_email.data.lower().startswith('noc@'):
flash(_('Sorry, you cannot use that email address'), 'error')
else:
user = User(username=form.user_name.data, email=form.real_email.data, last_seen=datetime.utcnow())
user.set_password(form.password.data)
db.session.add_all([user])
db.session.commit()
login_user(user, remember=True)
send_welcome_email(user)
flash(_('Great, you are now a registered user!'))
# set a cookie so the login button is emphasised on the public site, for future visits
resp = make_response(redirect(url_for('main.index')))
resp.set_cookie('logged_in_before', value='1', expires=datetime.now() + timedelta(weeks=300),
domain='.chorebuster.net')
return resp
return render_template('auth/register.html', title=_('Register'),
form=form)
@bp.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = ResetPasswordRequestForm()
if form.validate_on_submit():
if form.email.data.lower().startswith('postmaster@') or form.email.data.lower().startswith('abuse@') or \
form.email.data.lower().startswith('noc@'):
flash(_('Sorry, you cannot use that email address'), 'error')
else:
user = User.query.filter_by(email=form.email.data).first()
if user:
send_password_reset_email(user)
flash(_('Check your email for the instructions to reset your password'))
return redirect(url_for('auth.login'))
else:
flash(_('No account with that email address exists'), 'warning')
return render_template('auth/reset_password_request.html',
title=_('Reset Password'), form=form)
@bp.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
user = User.verify_reset_password_token(token)
if not user:
return redirect(url_for('main.index'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash(_('Your password has been reset.'))
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)

View file

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
{{ render_form(form) }}
<br>
<hr />
<br>
<p>{{ _('New User?') }} <a href="{{ url_for('auth.register') }}">{{ _('Register new account') }}</a></p>
<p class="mb-0">
{{ _('Forgot Your Password?') }}
<a href="{{ url_for('auth.reset_password_request') }}">{{ _('Reset it') }}</a>
</p>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6" id="registration_form">
<div class="card-title text-center">{{ _('Create new account') }}</div>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Reset your password') }}</div>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Reset your password') }}</div>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% endblock %}