From ee46645dfbf1eae4bb84d34c4c90657f232cfbdf Mon Sep 17 00:00:00 2001 From: zPlus Date: Wed, 18 Jul 2018 23:42:50 +0200 Subject: [PATCH] Add "account password reset". modified: freepost/__init__.py modified: freepost/database.py new file: freepost/mail.py new file: freepost/templates/email/password_changed.txt new file: freepost/templates/email/password_reset.txt modified: freepost/templates/login.html new file: freepost/templates/login_change_password.html new file: freepost/templates/login_reset.html modified: settings.yaml --- freepost/__init__.py | 102 +++++++++++++++++- freepost/database.py | 84 ++++++++++++++- freepost/mail.py | 15 +++ freepost/templates/email/password_changed.txt | 1 + freepost/templates/email/password_reset.txt | 6 ++ freepost/templates/login.html | 6 +- freepost/templates/login_change_password.html | 45 ++++++++ freepost/templates/login_reset.html | 43 ++++++++ settings.yaml | 25 +++-- 9 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 freepost/mail.py create mode 100644 freepost/templates/email/password_changed.txt create mode 100644 freepost/templates/email/password_reset.txt create mode 100755 freepost/templates/login_change_password.html create mode 100755 freepost/templates/login_reset.html diff --git a/freepost/__init__.py b/freepost/__init__.py index 14ba73b8..7958ca7a 100755 --- a/freepost/__init__.py +++ b/freepost/__init__.py @@ -64,7 +64,7 @@ bleach.sanitizer.ALLOWED_ATTRIBUTES.update ({ 'img': [ 'src' ] }) -from freepost import database, session +from freepost import database, mail, session # Decorator. # Make sure user is logged in @@ -162,8 +162,13 @@ def login_check (): 'login.html', flash = 'Bad login!') + # Delete any existing "reset token" + database.delete_password_reset_token (user['id']) + + # Start new session session.start (user['id'], remember) + # Go to user homepage redirect (application.get_url ('homepage')) @get ('/register', name='register') @@ -216,6 +221,101 @@ def logout (): redirect (application.get_url ('homepage')) +@get ('/password_reset', name='password_reset') +@requires_logout +def password_reset (): + return template ('login_reset.html') + +@post ('/password_reset', name='password_reset_send_code') +@requires_logout +def password_reset_send_code (): + username = request.forms.get ('username') + email = request.forms.get ('email') + + if not username or not email: + redirect (application.get_url ('change_password')) + + user = database.get_user_by_username (username) + + if not user: + redirect (application.get_url ('change_password')) + + # Make sure the given email matches the one that we have in the database + if user['email'] != email: + redirect (application.get_url ('change_password')) + + # Is there another valid token already (from a previous request)? + # If yes, do not send another one (to prevent multiple requests or spam) + if database.is_password_reset_token_valid (user['id']): + redirect (application.get_url ('change_password')) + + # Generate secret token to send via email + secret_token = random.ascii_string (32) + + # Add token to database + database.set_password_reset_token (user['id'], secret_token) + + # Send token via email + client_ip = request.environ.get ('HTTP_X_FORWARDED_FOR') or \ + request.environ.get ('REMOTE_ADDR') + email_from = 'freepost ' + email_to = user['email'] + email_subject = 'freepost password reset' + email_body = template ( + 'email/password_reset.txt', + ip=client_ip, + secret_token=secret_token) + + mail.send (email_from, email_to, email_subject, email_body) + + redirect (application.get_url ('change_password')) + +@get ('/change_password', name='change_password') +@requires_logout +def change_password (): + return template ('login_change_password.html') + +@post ('/change_password', name='validate_new_password') +@requires_logout +def validate_new_password (): + username = request.forms.get ('username') + email = request.forms.get ('email') + password = request.forms.get ('password') + secret_token = request.forms.get ('token') + + # We must have all fields + if not username or not email or not password or not secret_token: + redirect (application.get_url ('login')) + + # Password too short? + if len (password) < 8: + return template ( + 'login_change_password.html', + flash = 'Password must be at least 8 characters long') + + # OK, everything should be fine now. Reset user password. + database.reset_password (username, email, password, secret_token) + + # Check if the password was successfully reset + user = database.check_user_credentials (username, password) + + # Username/Password not working + if not user: + redirect (application.get_url ('login')) + + # Everything matched! + # Notify user of password change. + email_from = 'freepost ' + email_to = user['email'] + email_subject = 'freepost password changed' + email_body = template ('email/password_changed.txt') + + mail.send (email_from, email_to, email_subject, email_body) + + # Start new session and redirect user + session.start (user['id']) + redirect (application.get_url ('user')) + @get ('/user', name='user') @requires_login def user_private_homepage (): diff --git a/freepost/database.py b/freepost/database.py index 747b4909..09234336 100644 --- a/freepost/database.py +++ b/freepost/database.py @@ -117,6 +117,9 @@ def count_unread_messages (user_id): # Retrieve a user def get_user_by_username (username): + if not username: + return None + cursor = db.cursor (MySQLdb.cursors.DictCursor) cursor.execute ( @@ -621,12 +624,87 @@ def search (query, page = 0, order = 'newest'): return cursor.fetchall () +# Set reset token for user email +def set_password_reset_token (user_id = None, token = None): + if not user_id or not token: + return + + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE user + SET passwordResetToken = SHA2(%(token)s, 512), + passwordResetTokenExpire = NOW() + INTERVAL 1 HOUR + WHERE id = %(user)s + """, + { + 'user': user_id, + 'token': token + } + ) +# Delete the password reset token for a user +def delete_password_reset_token (user_id = None): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE user + SET passwordResetToken = NULL, + passwordResetTokenExpire = NULL + WHERE id = %(user)s + """, + { + 'user': user_id + } + ) +# Check if a reset token has expired. +def is_password_reset_token_valid (user_id = None): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT COUNT(1) AS valid + FROM user + WHERE id = %(user)s AND + passwordResetToken IS NOT NULL AND + passwordResetTokenExpire IS NOT NULL AND + passwordResetTokenExpire > NOW() + """, + { + 'user': user_id + } + ) + + return cursor.fetchone ()['valid'] == 1 - - - +# Reset user password +def reset_password (username = None, email = None, new_password = None, secret_token = None): + if not new_password: + return + + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE user + SET password = SHA2(CONCAT(%(password)s, `salt`), 512), + passwordResetToken = NULL, + passwordResetTokenExpire = NULL + WHERE username = %(user)s AND + email = %(email)s AND + passwordResetToken = SHA2(%(token)s, 512) AND + passwordResetTokenExpire > NOW() + """, + { + 'password': new_password, + 'user': username, + 'email': email, + 'token': secret_token + } + ) diff --git a/freepost/mail.py b/freepost/mail.py new file mode 100644 index 00000000..e40e46fd --- /dev/null +++ b/freepost/mail.py @@ -0,0 +1,15 @@ +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from freepost import settings +from subprocess import Popen, PIPE + +def send (from_address, to_address, subject, body): + email_message = MIMEMultipart () + email_message['From'] = from_address + email_message['To'] = to_address + email_message['Subject'] = subject + email_message.attach (MIMEText (body, 'plain')) + + # Open pipe to sendmail + Popen ([ settings['sendmail']['path'] , "-t" ], stdin=PIPE) \ + .communicate (email_message.as_bytes ()) diff --git a/freepost/templates/email/password_changed.txt b/freepost/templates/email/password_changed.txt new file mode 100644 index 00000000..ef263072 --- /dev/null +++ b/freepost/templates/email/password_changed.txt @@ -0,0 +1 @@ +Your password has been changed. diff --git a/freepost/templates/email/password_reset.txt b/freepost/templates/email/password_reset.txt new file mode 100644 index 00000000..2aa4f157 --- /dev/null +++ b/freepost/templates/email/password_reset.txt @@ -0,0 +1,6 @@ +Somebody from IP:{{ ip }} has requested to reset your freepost password. +The secret code to reset your password is {{ secret_token|safe }} +This code can only be used one time, and will automatically expire in 1 hour. + +If you did not request to change your password, please ignore this message +or contact support. diff --git a/freepost/templates/login.html b/freepost/templates/login.html index 5bf5db21..19fcb2d8 100755 --- a/freepost/templates/login.html +++ b/freepost/templates/login.html @@ -19,14 +19,14 @@ Screen name
- +
Password
- +
@@ -39,7 +39,7 @@
- Reset password + Reset password
Create new account diff --git a/freepost/templates/login_change_password.html b/freepost/templates/login_change_password.html new file mode 100755 index 00000000..272e7a4b --- /dev/null +++ b/freepost/templates/login_change_password.html @@ -0,0 +1,45 @@ +{% extends 'layout.html' %} + +{% block content %} + + + +{% endblock %} + + + + diff --git a/freepost/templates/login_reset.html b/freepost/templates/login_reset.html new file mode 100755 index 00000000..250331f5 --- /dev/null +++ b/freepost/templates/login_reset.html @@ -0,0 +1,43 @@ +{% extends 'layout.html' %} + +{% block content %} + + + +{% endblock %} + + + + diff --git a/settings.yaml b/settings.yaml index 91152900..b46a8ab1 100644 --- a/settings.yaml +++ b/settings.yaml @@ -1,21 +1,12 @@ # This is a bunch of settings useful for the app defaults: + # How many posts to show per page items_per_page: 50 search_results_per_page: 50 # How many topics to allow for a post topics_per_post: 10 -session: - # Name to use for the session cookie - name: freepost - - # Timeout in seconds for the "remember me" option. - # By default, if the user doesn't click "remember me" during login the - # session will end when the browser is closed. - # 2592000 = 30 days - remember_me: 2592000 - cookies: # A secret key for signing cookies. Must be kept private. # Used to verify that cookies haven't been tampered with. @@ -29,3 +20,17 @@ mysql: charset: utf8 username: freepost password: freepost + +# Emails are sent using the local sendmail MTA. +sendmail: + path: /usr/sbin/sendmail + +session: + # Name to use for the session cookie + name: freepost + + # Timeout in seconds for the "remember me" option. + # By default, if the user doesn't click "remember me" during login the + # session will end when the browser is closed. + # 2592000 = 30 days + remember_me: 2592000