From af351fa5a5b6545ee2ddeb8477d9de4a12b96ec1 Mon Sep 17 00:00:00 2001 From: Tim Tomes Date: Fri, 24 May 2024 13:04:46 -0400 Subject: [PATCH] Rebuilt the PwnedHub out-of-band password reset system. --- database/cs/02-pwnedhub.sql | 29 ++++++++++++ database/ctf/02-pwnedhub.sql | 29 ++++++++++++ database/init/02-pwnedhub.sql | 29 ++++++++++++ pwnedhub/models.py | 31 +++++++++++++ pwnedhub/templates/login.html | 1 + pwnedhub/templates/register.html | 1 + pwnedhub/templates/reset_init.html | 1 + pwnedhub/templates/reset_password.html | 7 ++- pwnedhub/templates/reset_question.html | 1 + pwnedhub/views/auth.py | 62 +++++++++++++++++--------- 10 files changed, 168 insertions(+), 23 deletions(-) diff --git a/database/cs/02-pwnedhub.sql b/database/cs/02-pwnedhub.sql index 4ccd107..6b24f77 100644 --- a/database/cs/02-pwnedhub.sql +++ b/database/cs/02-pwnedhub.sql @@ -111,6 +111,35 @@ INSERT INTO `notes` VALUES (1,'2023-05-31 14:39:06','2023-05-31 15:09:26','Notes /*!40000 ALTER TABLE `notes` ENABLE KEYS */; UNLOCK TABLES; +-- +-- Table structure for table `tokens` +-- + +DROP TABLE IF EXISTS `tokens`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tokens` ( + `id` int NOT NULL AUTO_INCREMENT, + `created` datetime NOT NULL, + `modified` datetime NOT NULL, + `value` varchar(255) NOT NULL, + `ttl` int NOT NULL, + `user_id` int NOT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tokens` +-- + +LOCK TABLES `tokens` WRITE; +/*!40000 ALTER TABLE `tokens` DISABLE KEYS */; +/*!40000 ALTER TABLE `tokens` ENABLE KEYS */; +UNLOCK TABLES; + -- -- Table structure for table `tools` -- diff --git a/database/ctf/02-pwnedhub.sql b/database/ctf/02-pwnedhub.sql index 3e64d68..aa6d9f9 100644 --- a/database/ctf/02-pwnedhub.sql +++ b/database/ctf/02-pwnedhub.sql @@ -110,6 +110,35 @@ LOCK TABLES `notes` WRITE; /*!40000 ALTER TABLE `notes` ENABLE KEYS */; UNLOCK TABLES; +-- +-- Table structure for table `tokens` +-- + +DROP TABLE IF EXISTS `tokens`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tokens` ( + `id` int NOT NULL AUTO_INCREMENT, + `created` datetime NOT NULL, + `modified` datetime NOT NULL, + `value` varchar(255) NOT NULL, + `ttl` int NOT NULL, + `user_id` int NOT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tokens` +-- + +LOCK TABLES `tokens` WRITE; +/*!40000 ALTER TABLE `tokens` DISABLE KEYS */; +/*!40000 ALTER TABLE `tokens` ENABLE KEYS */; +UNLOCK TABLES; + -- -- Table structure for table `tools` -- diff --git a/database/init/02-pwnedhub.sql b/database/init/02-pwnedhub.sql index e668de2..ec48456 100644 --- a/database/init/02-pwnedhub.sql +++ b/database/init/02-pwnedhub.sql @@ -110,6 +110,35 @@ LOCK TABLES `notes` WRITE; /*!40000 ALTER TABLE `notes` ENABLE KEYS */; UNLOCK TABLES; +-- +-- Table structure for table `tokens` +-- + +DROP TABLE IF EXISTS `tokens`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tokens` ( + `id` int NOT NULL AUTO_INCREMENT, + `created` datetime NOT NULL, + `modified` datetime NOT NULL, + `value` varchar(255) NOT NULL, + `ttl` int NOT NULL, + `user_id` int NOT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tokens` +-- + +LOCK TABLES `tokens` WRITE; +/*!40000 ALTER TABLE `tokens` DISABLE KEYS */; +/*!40000 ALTER TABLE `tokens` ENABLE KEYS */; +UNLOCK TABLES; + -- -- Table structure for table `tools` -- diff --git a/pwnedhub/models.py b/pwnedhub/models.py index bab2464..7e1bab0 100644 --- a/pwnedhub/models.py +++ b/pwnedhub/models.py @@ -122,6 +122,7 @@ class User(BaseModel): status = db.Column(db.Integer, nullable=False, default=1) notes = db.relationship('Note', back_populates='owner', lazy='dynamic') messages = db.relationship('Message', back_populates='author', lazy='dynamic') + tokens = db.relationship('Token', back_populates='owner', lazy='dynamic') sent_mail = db.relationship('Mail', foreign_keys=[Mail.sender_id], back_populates='sender', lazy='dynamic') received_mail = db.relationship('Mail', foreign_keys=[Mail.receiver_id], back_populates='receiver', lazy='dynamic') @@ -187,3 +188,33 @@ def get_by_email(email): def __repr__(self): return "".format(self.username) + + +class Token(BaseModel): + __tablename__ = 'tokens' + value = db.Column(db.String(255), nullable=False) + ttl = db.Column(db.Integer, nullable=False, default=600) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + owner = db.relationship('User', back_populates='tokens') + + @property + def is_valid(self): + current = get_current_utc_time().replace(tzinfo=None) + diff = current - self.created + if diff.total_seconds() > self.ttl: + return False + return True + + @staticmethod + def get_by_value(value): + return Token.query.filter_by(value=value).first() + + @staticmethod + def purge(): + invalid_tokens = [t for t in Token.query.all() if not t.is_valid] + for invalid_token in invalid_tokens: + db.session.delete(invalid_token) + db.session.commit() + + def __repr__(self): + return "".format(self.value) diff --git a/pwnedhub/templates/login.html b/pwnedhub/templates/login.html index 47a41a3..f82b5e5 100644 --- a/pwnedhub/templates/login.html +++ b/pwnedhub/templates/login.html @@ -17,6 +17,7 @@

Login with your
Google Account

{% else %}
{% endif %} +

Please log in.

diff --git a/pwnedhub/templates/register.html b/pwnedhub/templates/register.html index 9bc1938..d22c54e 100644 --- a/pwnedhub/templates/register.html +++ b/pwnedhub/templates/register.html @@ -2,6 +2,7 @@ {% block body %}
+

Create an account.

diff --git a/pwnedhub/templates/reset_init.html b/pwnedhub/templates/reset_init.html index b426cdb..7fbb3e5 100644 --- a/pwnedhub/templates/reset_init.html +++ b/pwnedhub/templates/reset_init.html @@ -2,6 +2,7 @@ {% block body %}
+

Password trouble?

diff --git a/pwnedhub/templates/reset_password.html b/pwnedhub/templates/reset_password.html index 4bb6b44..ec8c454 100644 --- a/pwnedhub/templates/reset_password.html +++ b/pwnedhub/templates/reset_password.html @@ -1,8 +1,13 @@ {% extends "layout.html" %} {% block body %}
+{% if app_config('OOB_RESET_ENABLE') %} + +{% else %} - +{% endif %} +

Welcome back!

+
diff --git a/pwnedhub/templates/reset_question.html b/pwnedhub/templates/reset_question.html index b410e62..6c3e664 100644 --- a/pwnedhub/templates/reset_question.html +++ b/pwnedhub/templates/reset_question.html @@ -2,6 +2,7 @@ {% block body %}
+

Verify your identity.

diff --git a/pwnedhub/views/auth.py b/pwnedhub/views/auth.py index cc3c448..6483994 100644 --- a/pwnedhub/views/auth.py +++ b/pwnedhub/views/auth.py @@ -1,8 +1,8 @@ -from flask import Blueprint, current_app, request, g, session, redirect, url_for, render_template, flash +from flask import Blueprint, current_app, request, g, session, redirect, url_for, render_template, flash, abort from pwnedhub import db from pwnedhub.constants import QUESTIONS from pwnedhub.decorators import validate -from pwnedhub.models import Config, Email, Mail, User +from pwnedhub.models import Config, Email, Mail, User, Token from pwnedhub.oauth import OAuthSignIn, OAuthCallbackError from pwnedhub.utils import xor_encrypt, generate_timestamp_token from pwnedhub.validators import is_valid_password @@ -178,12 +178,15 @@ def reset_init(): except: user = None if user: - session['reset_id'] = user.id if Config.get_value('OOB_RESET_ENABLE'): - # begin the out-of-band reset flow - reset_token = generate_timestamp_token() - session['reset_token'] = reset_token - link = url_for('auth.reset_verify', code=reset_token, _external=True) + # initialize the out-of-band reset flow + reset_token = Token( + value=generate_timestamp_token(), + owner=user + ) + db.session.add(reset_token) + db.session.commit() + link = url_for('auth.reset_password_oob', token=reset_token.value, _external=True) email = Email( sender = 'no-reply@pwnedhub.com', receiver = user.email, @@ -194,7 +197,8 @@ def reset_init(): db.session.commit() flash('Check your email to reset your password.') return redirect(url_for('auth.reset_init')) - # begin the in-band reset flow + # initialize the in-band reset flow + session['reset_id'] = user.id return redirect(url_for('auth.reset_question')) else: flash('User not recognized.') @@ -203,6 +207,8 @@ def reset_init(): @blp.route('/reset/question', methods=['GET', 'POST']) @validate(['answer']) def reset_question(): + if Config.get_value('OOB_RESET_ENABLE'): + abort(404) # validate flow control if not session.get('reset_id'): return reset_flow('Reset improperly initialized.') @@ -214,20 +220,11 @@ def reset_question(): return reset_flow('Incorrect answer.') return render_template('reset_question.html', question=user.question_as_string) -@blp.route('/reset/verify') -@validate(['code'], method='GET') -def reset_verify(): - # validate flow control - if not session.get('reset_id'): - return reset_flow('Reset improperly initialized.') - code = request.args.get('code') - if code == session.pop('reset_token', None): - return redirect(url_for('auth.reset_password')) - return reset_flow('Invalid reset token.') - @blp.route('/reset/password', methods=['GET', 'POST']) @validate(['password']) def reset_password(): + if Config.get_value('OOB_RESET_ENABLE'): + abort(404) # validate flow control if not session.get('reset_id'): return reset_flow('Reset improperly initialized.') @@ -237,10 +234,31 @@ def reset_password(): if is_valid_password(password): session.pop('reset_id', None) user.password = password - db.session.add(user) db.session.commit() flash('Password reset. Please log in.') return redirect(url_for('auth.login')) - else: - flash('Password does not meet complexity requirements.') + flash('Password does not meet complexity requirements.') return render_template('reset_password.html', user=user) + +@blp.route('/reset/password/', methods=['GET', 'POST']) +@validate(['password']) +def reset_password_oob(token): + if not Config.get_value('OOB_RESET_ENABLE'): + abort(404) + # validate the reset token + reset_token = Token.get_by_value(token) + if not reset_token: + return reset_flow('Invalid reset token.') + if not reset_token.is_valid: + Token.purge() + return reset_flow('Invalid reset token.') + if request.method == 'POST': + password = request.form['password'] + if is_valid_password(password): + reset_token.owner.password = password + db.session.delete(reset_token) + db.session.commit() + flash('Password reset. Please log in.') + return redirect(url_for('auth.login')) + flash('Password does not meet complexity requirements.') + return render_template('reset_password.html', token=reset_token.value, user=reset_token.owner)