From 3b0a4a43882ebb7657568b36ca6ca614fd6e8af7 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sat, 5 Aug 2023 21:25:18 +1200 Subject: [PATCH] user login and registration - unfinished --- app/auth/email.py | 22 +++ app/auth/forms.py | 43 ++++++ app/auth/recaptcha3.py | 139 ++++++++++++++++++ app/auth/routes.py | 117 +++++++++++++++ app/templates/auth/login.html | 23 +++ app/templates/auth/register.html | 16 ++ app/templates/auth/reset_password.html | 15 ++ .../auth/reset_password_request.html | 15 ++ 8 files changed, 390 insertions(+) create mode 100644 app/auth/email.py create mode 100644 app/auth/forms.py create mode 100644 app/auth/recaptcha3.py create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/auth/reset_password.html create mode 100644 app/templates/auth/reset_password_request.html diff --git a/app/auth/email.py b/app/auth/email.py new file mode 100644 index 00000000..ea1fadfe --- /dev/null +++ b/app/auth/email.py @@ -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 ', + 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 ', + recipients=[user.email], + text_body=render_template('email/welcome.txt', user=user), + html_body=render_template('email/welcome.html', user=user)) diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 00000000..6e2e3437 --- /dev/null +++ b/app/auth/forms.py @@ -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')) diff --git a/app/auth/recaptcha3.py b/app/auth/recaptcha3.py new file mode 100644 index 00000000..fbd5cd02 --- /dev/null +++ b/app/auth/recaptcha3.py @@ -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 = ''' + + + +''' + +RECAPTCHA_TEMPLATE_MANUAL = ''' + + + +''' + +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) diff --git a/app/auth/routes.py b/app/auth/routes.py index e69de29b..b5659e93 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -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 reset your password.')) + flash(message, 'warning') + else: + message = Markup(_('Invalid password. Please reset your password.')) + 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/', 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) \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 00000000..f8a29aaa --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+ +
+ +{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 00000000..0c1976e7 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} + +
+ +
+{% endblock %} diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html new file mode 100644 index 00000000..2b83b4b8 --- /dev/null +++ b/app/templates/auth/reset_password.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+ +
+{% endblock %} diff --git a/app/templates/auth/reset_password_request.html b/app/templates/auth/reset_password_request.html new file mode 100644 index 00000000..2b83b4b8 --- /dev/null +++ b/app/templates/auth/reset_password_request.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+ +
+{% endblock %}