Author | zPlus <zplus@peers.community> 2018-07-18 21:42:50 |
Committer | zPlus <zplus@peers.community> 2018-07-18 21:42:50 |
Commit | ee46645 (patch) |
Tree | c8508b0 |
Parent(s) |
-rwxr-xr-x | freepost/__init__.py | 102 | ||
-rw-r--r-- | freepost/database.py | 84 | ||
-rw-r--r-- | freepost/mail.py | 15 | ||
-rw-r--r-- | freepost/templates/email/password_changed.txt | 1 | ||
-rw-r--r-- | freepost/templates/email/password_reset.txt | 6 | ||
-rwxr-xr-x | freepost/templates/login.html | 6 | ||
-rwxr-xr-x | freepost/templates/login_change_password.html | 45 | ||
-rwxr-xr-x | freepost/templates/login_reset.html | 43 | ||
-rw-r--r-- | settings.yaml | 25 |
index 14ba73b..7958ca7 | |||
old size: 19K - new size: 22K | |||
@@ -64,7 +64,7 @@ bleach.sanitizer.ALLOWED_ATTRIBUTES.update ({ | |||
64 | 64 | 'img': [ 'src' ] | |
65 | 65 | }) | |
66 | 66 | ||
67 | - | from freepost import database, session | |
67 | + | from freepost import database, mail, session | |
68 | 68 | ||
69 | 69 | # Decorator. | |
70 | 70 | # Make sure user is logged in | |
@@ -162,8 +162,13 @@ def login_check (): | |||
162 | 162 | 'login.html', | |
163 | 163 | flash = 'Bad login!') | |
164 | 164 | ||
165 | + | # Delete any existing "reset token" | |
166 | + | database.delete_password_reset_token (user['id']) | |
167 | + | ||
168 | + | # Start new session | |
165 | 169 | session.start (user['id'], remember) | |
166 | 170 | ||
171 | + | # Go to user homepage | |
167 | 172 | redirect (application.get_url ('homepage')) | |
168 | 173 | ||
169 | 174 | @get ('/register', name='register') | |
@@ -216,6 +221,101 @@ def logout (): | |||
216 | 221 | ||
217 | 222 | redirect (application.get_url ('homepage')) | |
218 | 223 | ||
224 | + | @get ('/password_reset', name='password_reset') | |
225 | + | @requires_logout | |
226 | + | def password_reset (): | |
227 | + | return template ('login_reset.html') | |
228 | + | ||
229 | + | @post ('/password_reset', name='password_reset_send_code') | |
230 | + | @requires_logout | |
231 | + | def password_reset_send_code (): | |
232 | + | username = request.forms.get ('username') | |
233 | + | email = request.forms.get ('email') | |
234 | + | ||
235 | + | if not username or not email: | |
236 | + | redirect (application.get_url ('change_password')) | |
237 | + | ||
238 | + | user = database.get_user_by_username (username) | |
239 | + | ||
240 | + | if not user: | |
241 | + | redirect (application.get_url ('change_password')) | |
242 | + | ||
243 | + | # Make sure the given email matches the one that we have in the database | |
244 | + | if user['email'] != email: | |
245 | + | redirect (application.get_url ('change_password')) | |
246 | + | ||
247 | + | # Is there another valid token already (from a previous request)? | |
248 | + | # If yes, do not send another one (to prevent multiple requests or spam) | |
249 | + | if database.is_password_reset_token_valid (user['id']): | |
250 | + | redirect (application.get_url ('change_password')) | |
251 | + | ||
252 | + | # Generate secret token to send via email | |
253 | + | secret_token = random.ascii_string (32) | |
254 | + | ||
255 | + | # Add token to database | |
256 | + | database.set_password_reset_token (user['id'], secret_token) | |
257 | + | ||
258 | + | # Send token via email | |
259 | + | client_ip = request.environ.get ('HTTP_X_FORWARDED_FOR') or \ | |
260 | + | request.environ.get ('REMOTE_ADDR') | |
261 | + | email_from = 'freepost <noreply@freepost.peers.community>' | |
262 | + | email_to = user['email'] | |
263 | + | email_subject = 'freepost password reset' | |
264 | + | email_body = template ( | |
265 | + | 'email/password_reset.txt', | |
266 | + | ip=client_ip, | |
267 | + | secret_token=secret_token) | |
268 | + | ||
269 | + | mail.send (email_from, email_to, email_subject, email_body) | |
270 | + | ||
271 | + | redirect (application.get_url ('change_password')) | |
272 | + | ||
273 | + | @get ('/change_password', name='change_password') | |
274 | + | @requires_logout | |
275 | + | def change_password (): | |
276 | + | return template ('login_change_password.html') | |
277 | + | ||
278 | + | @post ('/change_password', name='validate_new_password') | |
279 | + | @requires_logout | |
280 | + | def validate_new_password (): | |
281 | + | username = request.forms.get ('username') | |
282 | + | email = request.forms.get ('email') | |
283 | + | password = request.forms.get ('password') | |
284 | + | secret_token = request.forms.get ('token') | |
285 | + | ||
286 | + | # We must have all fields | |
287 | + | if not username or not email or not password or not secret_token: | |
288 | + | redirect (application.get_url ('login')) | |
289 | + | ||
290 | + | # Password too short? | |
291 | + | if len (password) < 8: | |
292 | + | return template ( | |
293 | + | 'login_change_password.html', | |
294 | + | flash = 'Password must be at least 8 characters long') | |
295 | + | ||
296 | + | # OK, everything should be fine now. Reset user password. | |
297 | + | database.reset_password (username, email, password, secret_token) | |
298 | + | ||
299 | + | # Check if the password was successfully reset | |
300 | + | user = database.check_user_credentials (username, password) | |
301 | + | ||
302 | + | # Username/Password not working | |
303 | + | if not user: | |
304 | + | redirect (application.get_url ('login')) | |
305 | + | ||
306 | + | # Everything matched! | |
307 | + | # Notify user of password change. | |
308 | + | email_from = 'freepost <noreply@freepost.peers.community>' | |
309 | + | email_to = user['email'] | |
310 | + | email_subject = 'freepost password changed' | |
311 | + | email_body = template ('email/password_changed.txt') | |
312 | + | ||
313 | + | mail.send (email_from, email_to, email_subject, email_body) | |
314 | + | ||
315 | + | # Start new session and redirect user | |
316 | + | session.start (user['id']) | |
317 | + | redirect (application.get_url ('user')) | |
318 | + | ||
219 | 319 | @get ('/user', name='user') | |
220 | 320 | @requires_login | |
221 | 321 | def user_private_homepage (): |
index 747b490..0923433 | |||
old size: 16K - new size: 18K | |||
@@ -117,6 +117,9 @@ def count_unread_messages (user_id): | |||
117 | 117 | ||
118 | 118 | # Retrieve a user | |
119 | 119 | def get_user_by_username (username): | |
120 | + | if not username: | |
121 | + | return None | |
122 | + | ||
120 | 123 | cursor = db.cursor (MySQLdb.cursors.DictCursor) | |
121 | 124 | ||
122 | 125 | cursor.execute ( | |
@@ -621,12 +624,87 @@ def search (query, page = 0, order = 'newest'): | |||
621 | 624 | ||
622 | 625 | return cursor.fetchall () | |
623 | 626 | ||
627 | + | # Set reset token for user email | |
628 | + | def set_password_reset_token (user_id = None, token = None): | |
629 | + | if not user_id or not token: | |
630 | + | return | |
631 | + | ||
632 | + | cursor = db.cursor (MySQLdb.cursors.DictCursor) | |
633 | + | ||
634 | + | cursor.execute ( | |
635 | + | """ | |
636 | + | UPDATE user | |
637 | + | SET passwordResetToken = SHA2(%(token)s, 512), | |
638 | + | passwordResetTokenExpire = NOW() + INTERVAL 1 HOUR | |
639 | + | WHERE id = %(user)s | |
640 | + | """, | |
641 | + | { | |
642 | + | 'user': user_id, | |
643 | + | 'token': token | |
644 | + | } | |
645 | + | ) | |
624 | 646 | ||
647 | + | # Delete the password reset token for a user | |
648 | + | def delete_password_reset_token (user_id = None): | |
649 | + | cursor = db.cursor (MySQLdb.cursors.DictCursor) | |
650 | + | ||
651 | + | cursor.execute ( | |
652 | + | """ | |
653 | + | UPDATE user | |
654 | + | SET passwordResetToken = NULL, | |
655 | + | passwordResetTokenExpire = NULL | |
656 | + | WHERE id = %(user)s | |
657 | + | """, | |
658 | + | { | |
659 | + | 'user': user_id | |
660 | + | } | |
661 | + | ) | |
625 | 662 | ||
663 | + | # Check if a reset token has expired. | |
664 | + | def is_password_reset_token_valid (user_id = None): | |
665 | + | cursor = db.cursor (MySQLdb.cursors.DictCursor) | |
666 | + | ||
667 | + | cursor.execute ( | |
668 | + | """ | |
669 | + | SELECT COUNT(1) AS valid | |
670 | + | FROM user | |
671 | + | WHERE id = %(user)s AND | |
672 | + | passwordResetToken IS NOT NULL AND | |
673 | + | passwordResetTokenExpire IS NOT NULL AND | |
674 | + | passwordResetTokenExpire > NOW() | |
675 | + | """, | |
676 | + | { | |
677 | + | 'user': user_id | |
678 | + | } | |
679 | + | ) | |
680 | + | ||
681 | + | return cursor.fetchone ()['valid'] == 1 | |
626 | 682 | ||
627 | - | ||
628 | - | ||
629 | - | ||
683 | + | # Reset user password | |
684 | + | def reset_password (username = None, email = None, new_password = None, secret_token = None): | |
685 | + | if not new_password: | |
686 | + | return | |
687 | + | ||
688 | + | cursor = db.cursor (MySQLdb.cursors.DictCursor) | |
689 | + | ||
690 | + | cursor.execute ( | |
691 | + | """ | |
692 | + | UPDATE user | |
693 | + | SET password = SHA2(CONCAT(%(password)s, `salt`), 512), | |
694 | + | passwordResetToken = NULL, | |
695 | + | passwordResetTokenExpire = NULL | |
696 | + | WHERE username = %(user)s AND | |
697 | + | email = %(email)s AND | |
698 | + | passwordResetToken = SHA2(%(token)s, 512) AND | |
699 | + | passwordResetTokenExpire > NOW() | |
700 | + | """, | |
701 | + | { | |
702 | + | 'password': new_password, | |
703 | + | 'user': username, | |
704 | + | 'email': email, | |
705 | + | 'token': secret_token | |
706 | + | } | |
707 | + | ) | |
630 | 708 | ||
631 | 709 | ||
632 | 710 |
index 0000000..e40e46f | |||
old size: 0B - new size: 575B | |||
new file mode: -rw-r--r-- |
@@ -0,0 +1,15 @@ | |||
1 | + | from email.mime.multipart import MIMEMultipart | |
2 | + | from email.mime.text import MIMEText | |
3 | + | from freepost import settings | |
4 | + | from subprocess import Popen, PIPE | |
5 | + | ||
6 | + | def send (from_address, to_address, subject, body): | |
7 | + | email_message = MIMEMultipart () | |
8 | + | email_message['From'] = from_address | |
9 | + | email_message['To'] = to_address | |
10 | + | email_message['Subject'] = subject | |
11 | + | email_message.attach (MIMEText (body, 'plain')) | |
12 | + | ||
13 | + | # Open pipe to sendmail | |
14 | + | Popen ([ settings['sendmail']['path'] , "-t" ], stdin=PIPE) \ | |
15 | + | .communicate (email_message.as_bytes ()) |
index 0000000..ef26307 | |||
old size: 0B - new size: 32B | |||
new file mode: -rw-r--r-- |
@@ -0,0 +1 @@ | |||
1 | + | Your password has been changed. |
index 0000000..2aa4f15 | |||
old size: 0B - new size: 313B | |||
new file mode: -rw-r--r-- |
@@ -0,0 +1,6 @@ | |||
1 | + | Somebody from IP:{{ ip }} has requested to reset your freepost password. | |
2 | + | The secret code to reset your password is {{ secret_token|safe }} | |
3 | + | This code can only be used one time, and will automatically expire in 1 hour. | |
4 | + | ||
5 | + | If you did not request to change your password, please ignore this message | |
6 | + | or contact support. |
index 5bf5db2..19fcb2d | |||
old size: 1K - new size: 1K | |||
@@ -19,14 +19,14 @@ | |||
19 | 19 | Screen name | |
20 | 20 | </div> | |
21 | 21 | <div> | |
22 | - | <input type="text" name="username" class="form-control" /> | |
22 | + | <input type="text" name="username" class="form-control" required /> | |
23 | 23 | </div> | |
24 | 24 | ||
25 | 25 | <div class="title"> | |
26 | 26 | Password | |
27 | 27 | </div> | |
28 | 28 | <div> | |
29 | - | <input type="password" name="password" class="form-control" /> | |
29 | + | <input type="password" name="password" class="form-control" required /> | |
30 | 30 | </div> | |
31 | 31 | ||
32 | 32 | <div> | |
@@ -39,7 +39,7 @@ | |||
39 | 39 | </form> | |
40 | 40 | ||
41 | 41 | <div> | |
42 | - | <a href="login_reset">Reset password</a> | |
42 | + | <a href="{{ url ('password_reset') }}">Reset password</a> | |
43 | 43 | </div> | |
44 | 44 | <div> | |
45 | 45 | <a href="{{ url ('register') }}">Create new account</a> |
index 0000000..272e7a4 | |||
old size: 0B - new size: 1K | |||
new file mode: -rwxr-xr-x |
@@ -0,0 +1,45 @@ | |||
1 | + | {% extends 'layout.html' %} | |
2 | + | ||
3 | + | {% block content %} | |
4 | + | ||
5 | + | <div class="login"> | |
6 | + | {% if flash %} | |
7 | + | <div class="alert bg-red"> | |
8 | + | {{ flash }} | |
9 | + | </div> | |
10 | + | {% endif %} | |
11 | + | ||
12 | + | <h3>Change password</h3> | |
13 | + | ||
14 | + | <p style="margin: 1em 0 2em 0;"> | |
15 | + | If the username and email are valid, you should receive a new message soon. | |
16 | + | Insert the secret code and your new password below. | |
17 | + | </p> | |
18 | + | ||
19 | + | <form action="{{ url ('validate_new_password') }}" method="post"> | |
20 | + | <input type="hidden" name="token" value="" /> | |
21 | + | <p> | |
22 | + | <input type="text" name="username" placeholder="Username" class="form-control" required /> | |
23 | + | </p> | |
24 | + | <p> | |
25 | + | <input type="text" name="email" placeholder="Email" class="form-control" required /> | |
26 | + | </p> | |
27 | + | <p> | |
28 | + | <input type="password" name="password" placeholder="New password" class="form-control" required /> | |
29 | + | </p> | |
30 | + | <p> | |
31 | + | <input type="password" name="token" placeholder="Secret code" class="form-control" required /> | |
32 | + | </p> | |
33 | + | ||
34 | + | <div> | |
35 | + | <input type="submit" class="button button_info" value="Change password" /> | |
36 | + | </div> | |
37 | + | </form> | |
38 | + | ||
39 | + | </div> | |
40 | + | ||
41 | + | {% endblock %} | |
42 | + | ||
43 | + | ||
44 | + | ||
45 | + |
index 0000000..250331f | |||
old size: 0B - new size: 996B | |||
new file mode: -rwxr-xr-x |
@@ -0,0 +1,43 @@ | |||
1 | + | {% extends 'layout.html' %} | |
2 | + | ||
3 | + | {% block content %} | |
4 | + | ||
5 | + | <div class="login"> | |
6 | + | {% if flash %} | |
7 | + | <div class="alert bg-red"> | |
8 | + | {{ flash }} | |
9 | + | </div> | |
10 | + | {% endif %} | |
11 | + | ||
12 | + | <h3>Reset password</h3> | |
13 | + | ||
14 | + | <p> | |
15 | + | Please insert your username and email. You will receive a secret | |
16 | + | code for resetting your password. | |
17 | + | </p> | |
18 | + | <p style="margin: 1em 0 2em 0;"> | |
19 | + | You can only reset your password once per hour. | |
20 | + | </p> | |
21 | + | ||
22 | + | <form action="{{ url ('password_reset_send_code') }}" method="post"> | |
23 | + | <div> | |
24 | + | <p> | |
25 | + | <input type="text" name="username" placeholder="Username" class="form-control" required /> | |
26 | + | </p> | |
27 | + | <p> | |
28 | + | <input type="text" name="email" placeholder="Email" class="form-control" required /> | |
29 | + | </p> | |
30 | + | </div> | |
31 | + | ||
32 | + | <div> | |
33 | + | <input type="submit" value="Send email" class="button button_info" /> | |
34 | + | </div> | |
35 | + | </form> | |
36 | + | ||
37 | + | </div> | |
38 | + | ||
39 | + | {% endblock %} | |
40 | + | ||
41 | + | ||
42 | + | ||
43 | + |
index 9115290..b46a8ab | |||
old size: 815B - new size: 941B | |||
@@ -1,21 +1,12 @@ | |||
1 | 1 | # This is a bunch of settings useful for the app | |
2 | 2 | ||
3 | 3 | defaults: | |
4 | + | # How many posts to show per page | |
4 | 5 | items_per_page: 50 | |
5 | 6 | search_results_per_page: 50 | |
6 | 7 | # How many topics to allow for a post | |
7 | 8 | topics_per_post: 10 | |
8 | 9 | ||
9 | - | session: | |
10 | - | # Name to use for the session cookie | |
11 | - | name: freepost | |
12 | - | ||
13 | - | # Timeout in seconds for the "remember me" option. | |
14 | - | # By default, if the user doesn't click "remember me" during login the | |
15 | - | # session will end when the browser is closed. | |
16 | - | # 2592000 = 30 days | |
17 | - | remember_me: 2592000 | |
18 | - | ||
19 | 10 | cookies: | |
20 | 11 | # A secret key for signing cookies. Must be kept private. | |
21 | 12 | # Used to verify that cookies haven't been tampered with. | |
@@ -29,3 +20,17 @@ mysql: | |||
29 | 20 | charset: utf8 | |
30 | 21 | username: freepost | |
31 | 22 | password: freepost | |
23 | + | ||
24 | + | # Emails are sent using the local sendmail MTA. | |
25 | + | sendmail: | |
26 | + | path: /usr/sbin/sendmail | |
27 | + | ||
28 | + | session: | |
29 | + | # Name to use for the session cookie | |
30 | + | name: freepost | |
31 | + | ||
32 | + | # Timeout in seconds for the "remember me" option. | |
33 | + | # By default, if the user doesn't click "remember me" during login the | |
34 | + | # session will end when the browser is closed. | |
35 | + | # 2592000 = 30 days | |
36 | + | remember_me: 2592000 |