mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 19:36:56 -08:00
user login and registration - unfinished
This commit is contained in:
parent
3b1c087a61
commit
3b0a4a4388
8 changed files with 390 additions and 0 deletions
22
app/auth/email.py
Normal file
22
app/auth/email.py
Normal 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
43
app/auth/forms.py
Normal 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
139
app/auth/recaptcha3.py
Normal 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)
|
|
@ -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)
|
23
app/templates/auth/login.html
Normal file
23
app/templates/auth/login.html
Normal 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 %}
|
16
app/templates/auth/register.html
Normal file
16
app/templates/auth/register.html
Normal 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 %}
|
15
app/templates/auth/reset_password.html
Normal file
15
app/templates/auth/reset_password.html
Normal 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 %}
|
15
app/templates/auth/reset_password_request.html
Normal file
15
app/templates/auth/reset_password_request.html
Normal 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 %}
|
Loading…
Reference in a new issue