From 4e178ad676fcd2a3fad6817d6753e5836ae27859 Mon Sep 17 00:00:00 2001 From: MrMeeb Date: Thu, 9 May 2024 12:00:07 +0100 Subject: [PATCH] 4.43.0 --- app/CONTRIBUTING.md | 6 + app/app/auth/views/change_email.py | 2 + app/app/auth/views/oidc.py | 32 ++- app/app/config.py | 4 +- app/app/dashboard/views/mailbox_detail.py | 15 +- app/app/dashboard/views/setting.py | 15 ++ app/app/models.py | 25 +- app/cron.py | 1 + app/email_handler.py | 1 + app/example.env | 4 +- .../versions/2024_040913_fa2f19bb4e5a_.py | 29 ++ app/templates/dashboard/setting.html | 52 +++- app/tests/auth/test_oidc.py | 248 ++++++++++++++---- app/tests/cron/test_get_alias_for_hibp.py | 26 ++ app/tests/test.env | 4 +- 15 files changed, 379 insertions(+), 85 deletions(-) create mode 100644 app/migrations/versions/2024_040913_fa2f19bb4e5a_.py diff --git a/app/CONTRIBUTING.md b/app/CONTRIBUTING.md index b6b1019..6dc29ea 100644 --- a/app/CONTRIBUTING.md +++ b/app/CONTRIBUTING.md @@ -68,6 +68,12 @@ For most tests, you will need to have ``redis`` installed and started on your ma sh scripts/run-test.sh ``` +You can also run tests using a local Postgres DB to speed things up. This can be done by + +- creating an empty test DB and running the database migration by `dropdb test && createdb test && DB_URI=postgresql://localhost:5432/test alembic upgrade head` + +- replacing the `DB_URI` in `test.env` file by `DB_URI=postgresql://localhost:5432/test` + ## Run the code locally Install npm packages diff --git a/app/app/auth/views/change_email.py b/app/app/auth/views/change_email.py index ff93c70..e5a8e47 100644 --- a/app/app/auth/views/change_email.py +++ b/app/app/auth/views/change_email.py @@ -3,11 +3,13 @@ from flask_login import login_user from app.auth.base import auth_bp from app.db import Session +from app.extensions import limiter from app.log import LOG from app.models import EmailChange, ResetPasswordCode @auth_bp.route("/change_email", methods=["GET", "POST"]) +@limiter.limit("3/hour") def change_email(): code = request.args.get("code") diff --git a/app/app/auth/views/oidc.py b/app/app/auth/views/oidc.py index 12c4e49..bbda543 100644 --- a/app/app/auth/views/oidc.py +++ b/app/app/auth/views/oidc.py @@ -1,14 +1,13 @@ from flask import request, session, redirect, flash, url_for from requests_oauthlib import OAuth2Session +import requests + from app import config from app.auth.base import auth_bp from app.auth.views.login_utils import after_login from app.config import ( URL, - OIDC_AUTHORIZATION_URL, - OIDC_USER_INFO_URL, - OIDC_TOKEN_URL, OIDC_SCOPES, OIDC_NAME_FIELD, ) @@ -16,14 +15,15 @@ from app.db import Session from app.email_utils import send_welcome_email from app.log import LOG from app.models import User, SocialAuth -from app.utils import encode_url, sanitize_email, sanitize_next_url +from app.utils import sanitize_email, sanitize_next_url # need to set explicitly redirect_uri instead of leaving the lib to pre-fill redirect_uri # when served behind nginx, the redirect_uri is localhost... and not the real url -_redirect_uri = URL + "/auth/oidc/callback" +redirect_uri = URL + "/auth/oidc/callback" SESSION_STATE_KEY = "oauth_state" +SESSION_NEXT_KEY = "oauth_redirect_next" @auth_bp.route("/oidc/login") @@ -32,18 +32,17 @@ def oidc_login(): return redirect(url_for("auth.login")) next_url = sanitize_next_url(request.args.get("next")) - if next_url: - redirect_uri = _redirect_uri + "?next=" + encode_url(next_url) - else: - redirect_uri = _redirect_uri + + auth_url = requests.get(config.OIDC_WELL_KNOWN_URL).json()["authorization_endpoint"] oidc = OAuth2Session( config.OIDC_CLIENT_ID, scope=[OIDC_SCOPES], redirect_uri=redirect_uri ) - authorization_url, state = oidc.authorization_url(OIDC_AUTHORIZATION_URL) + authorization_url, state = oidc.authorization_url(auth_url) # State is used to prevent CSRF, keep this for later. session[SESSION_STATE_KEY] = state + session[SESSION_NEXT_KEY] = next_url return redirect(authorization_url) @@ -60,19 +59,23 @@ def oidc_callback(): flash("Please use another sign in method then", "warning") return redirect("/") + oidc_configuration = requests.get(config.OIDC_WELL_KNOWN_URL).json() + user_info_url = oidc_configuration["userinfo_endpoint"] + token_url = oidc_configuration["token_endpoint"] + oidc = OAuth2Session( config.OIDC_CLIENT_ID, state=session[SESSION_STATE_KEY], scope=[OIDC_SCOPES], - redirect_uri=_redirect_uri, + redirect_uri=redirect_uri, ) oidc.fetch_token( - OIDC_TOKEN_URL, + token_url, client_secret=config.OIDC_CLIENT_SECRET, authorization_response=request.url, ) - oidc_user_data = oidc.get(OIDC_USER_INFO_URL) + oidc_user_data = oidc.get(user_info_url) if oidc_user_data.status_code != 200: LOG.e( f"cannot get oidc user data {oidc_user_data.status_code} {oidc_user_data.text}" @@ -111,7 +114,8 @@ def oidc_callback(): Session.commit() # The activation link contains the original page, for ex authorize page - next_url = sanitize_next_url(request.args.get("next")) if request.args else None + next_url = session[SESSION_NEXT_KEY] + session[SESSION_NEXT_KEY] = None return after_login(user, next_url) diff --git a/app/app/config.py b/app/app/config.py index 7a59e91..f22cdcd 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -245,9 +245,7 @@ FACEBOOK_CLIENT_ID = os.environ.get("FACEBOOK_CLIENT_ID") FACEBOOK_CLIENT_SECRET = os.environ.get("FACEBOOK_CLIENT_SECRET") CONNECT_WITH_OIDC_ICON = os.environ.get("CONNECT_WITH_OIDC_ICON") -OIDC_AUTHORIZATION_URL = os.environ.get("OIDC_AUTHORIZATION_URL") -OIDC_USER_INFO_URL = os.environ.get("OIDC_USER_INFO_URL") -OIDC_TOKEN_URL = os.environ.get("OIDC_TOKEN_URL") +OIDC_WELL_KNOWN_URL = os.environ.get("OIDC_WELL_KNOWN_URL") OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID") OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET") OIDC_SCOPES = os.environ.get("OIDC_SCOPES") diff --git a/app/app/dashboard/views/mailbox_detail.py b/app/app/dashboard/views/mailbox_detail.py index 06527b4..bbf2e95 100644 --- a/app/app/dashboard/views/mailbox_detail.py +++ b/app/app/dashboard/views/mailbox_detail.py @@ -11,9 +11,11 @@ from wtforms.fields.html5 import EmailField from app.config import ENFORCE_SPF, MAILBOX_SECRET from app.config import URL from app.dashboard.base import dashboard_bp +from app.dashboard.views.enter_sudo import sudo_required from app.db import Session from app.email_utils import email_can_be_used_as_mailbox from app.email_utils import mailbox_already_used, render, send_email +from app.extensions import limiter from app.log import LOG from app.models import Alias, AuthorizedAddress from app.models import Mailbox @@ -29,6 +31,8 @@ class ChangeEmailForm(FlaskForm): @dashboard_bp.route("/mailbox//", methods=["GET", "POST"]) @login_required +@sudo_required +@limiter.limit("20/minute", methods=["POST"]) def mailbox_detail_route(mailbox_id): mailbox: Mailbox = Mailbox.get(mailbox_id) if not mailbox or mailbox.user_id != current_user.id: @@ -179,8 +183,15 @@ def mailbox_detail_route(mailbox_id): elif request.form.get("form-name") == "toggle-pgp": if request.form.get("pgp-enabled") == "on": - mailbox.disable_pgp = False - flash(f"PGP is enabled on {mailbox.email}", "success") + if mailbox.is_proton(): + mailbox.disable_pgp = True + flash( + "Enabling PGP for a Proton Mail mailbox is redundant and does not add any security benefit", + "info", + ) + else: + mailbox.disable_pgp = False + flash(f"PGP is enabled on {mailbox.email}", "info") else: mailbox.disable_pgp = True flash(f"PGP is disabled on {mailbox.email}", "info") diff --git a/app/app/dashboard/views/setting.py b/app/app/dashboard/views/setting.py index cefbaf5..f07ebb1 100644 --- a/app/app/dashboard/views/setting.py +++ b/app/app/dashboard/views/setting.py @@ -227,6 +227,21 @@ def setting(): Session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) + elif request.form.get("form-name") == "enable_data_breach_check": + if not current_user.is_premium(): + flash("Only premium plan can enable data breach monitoring", "warning") + return redirect(url_for("dashboard.setting")) + choose = request.form.get("enable_data_breach_check") + if choose == "on": + LOG.i("User {current_user} has enabled data breach monitoring") + current_user.enable_data_breach_check = True + flash("Data breach monitoring is enabled", "success") + else: + LOG.i("User {current_user} has disabled data breach monitoring") + current_user.enable_data_breach_check = False + flash("Data breach monitoring is disabled", "info") + Session.commit() + return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "sender-in-ra": choose = request.form.get("enable") if choose == "on": diff --git a/app/app/models.py b/app/app/models.py index 472b98a..ea3defd 100644 --- a/app/app/models.py +++ b/app/app/models.py @@ -525,6 +525,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): sa.Boolean, default=True, nullable=False, server_default="1" ) + # user opted in for data breach check + enable_data_breach_check = sa.Column( + sa.Boolean, default=False, nullable=False, server_default="0" + ) + # bitwise flags. Allow for future expansion flags = sa.Column( sa.BigInteger, @@ -2644,10 +2649,15 @@ class Mailbox(Base, ModelMixin): return False def nb_alias(self): - return ( - AliasMailbox.filter_by(mailbox_id=self.id).count() - + Alias.filter_by(mailbox_id=self.id).count() + alias_ids = set( + am.alias_id + for am in AliasMailbox.filter_by(mailbox_id=self.id).values( + AliasMailbox.alias_id + ) ) + for alias in Alias.filter_by(mailbox_id=self.id).values(Alias.id): + alias_ids.add(alias.id) + return len(alias_ids) def is_proton(self) -> bool: if ( @@ -2696,12 +2706,15 @@ class Mailbox(Base, ModelMixin): @property def aliases(self) -> [Alias]: - ret = Alias.filter_by(mailbox_id=self.id).all() + ret = dict( + (alias.id, alias) for alias in Alias.filter_by(mailbox_id=self.id).all() + ) for am in AliasMailbox.filter_by(mailbox_id=self.id): - ret.append(am.alias) + if am.alias_id not in ret: + ret[am.alias_id] = am.alias - return ret + return list(ret.values()) @classmethod def create(cls, **kw): diff --git a/app/cron.py b/app/cron.py index 7466af7..2f1e6c8 100644 --- a/app/cron.py +++ b/app/cron.py @@ -1070,6 +1070,7 @@ def get_alias_to_check_hibp( Alias.id >= min_alias_id, Alias.id < max_alias_id, User.disabled == False, # noqa: E712 + User.enable_data_breach_check, or_( User.lifetime, ManualSubscription.end_at > now, diff --git a/app/email_handler.py b/app/email_handler.py index e77fcbb..303db53 100644 --- a/app/email_handler.py +++ b/app/email_handler.py @@ -1180,6 +1180,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): # References and In-Reply-To are used for keeping the email thread headers.REFERENCES, headers.IN_REPLY_TO, + headers.SL_QUEUE_ID, ] + headers.MIME_HEADERS, ) diff --git a/app/example.env b/app/example.env index 4ee0951..45716f8 100644 --- a/app/example.env +++ b/app/example.env @@ -118,9 +118,7 @@ WORDS_FILE_PATH=local_data/test_words.txt # Login with OIDC # CONNECT_WITH_OIDC_ICON=fa-github -# OIDC_AUTHORIZATION_URL=to_fill -# OIDC_USER_INFO_URL=to_fill -# OIDC_TOKEN_URL=to_fill +# OIDC_WELL_KNOWN_URL=to_fill # OIDC_SCOPES=openid email profile # OIDC_NAME_FIELD=name # OIDC_CLIENT_ID=to_fill diff --git a/app/migrations/versions/2024_040913_fa2f19bb4e5a_.py b/app/migrations/versions/2024_040913_fa2f19bb4e5a_.py new file mode 100644 index 0000000..5091988 --- /dev/null +++ b/app/migrations/versions/2024_040913_fa2f19bb4e5a_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: fa2f19bb4e5a +Revises: 52510a633d6f +Create Date: 2024-04-09 13:12:26.305340 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fa2f19bb4e5a' +down_revision = '52510a633d6f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('enable_data_breach_check', sa.Boolean(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'enable_data_breach_check') + # ### end Alembic commands ### diff --git a/app/templates/dashboard/setting.html b/app/templates/dashboard/setting.html index 0340376..ab6d865 100644 --- a/app/templates/dashboard/setting.html +++ b/app/templates/dashboard/setting.html @@ -249,6 +249,42 @@ + +
+
+
Data breach monitoring
+
+ {% if not current_user.is_premium() %} + + + {% endif %} + If enabled, we will inform you via email if one of your aliases appears in a data breach. +
+ SimpleLogin uses HaveIBeenPwned API for checking for data breaches. +
+
+ {{ csrf_form.csrf_token }} + +
+ + +
+ +
+
+
+
@@ -285,7 +321,9 @@ No Name (i.e. only reverse-alias) - +
@@ -295,7 +333,9 @@
Reverse Alias Replacement -
Experimental
+
+ Experimental +
When replying to a forwarded email, the reverse-alias can be automatically included @@ -312,9 +352,13 @@ name="replace-ra" {% if current_user.replace_reverse_alias %} checked{% endif %} class="form-check-input"> - +
- +
diff --git a/app/tests/auth/test_oidc.py b/app/tests/auth/test_oidc.py index e35bb5e..5d22e67 100644 --- a/app/tests/auth/test_oidc.py +++ b/app/tests/auth/test_oidc.py @@ -1,5 +1,5 @@ from app import config -from flask import url_for +from flask import url_for, session from urllib.parse import parse_qs from urllib3.util import parse_url from app.auth.views.oidc import create_user @@ -10,7 +10,21 @@ from app.models import User from app.config import URL, OIDC_CLIENT_ID -def test_oidc_login(flask_client): +mock_well_known_response = { + "authorization_endpoint": "http://localhost:7777/authorization-endpoint", + "userinfo_endpoint": "http://localhost:7777/userinfo-endpoint", + "token_endpoint": "http://localhost:7777/token-endpoint", +} + + +@patch("requests.get") +def test_oidc_login(mock_get, flask_client): + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + + mock_get.return_value.json.return_value = mock_well_known_response + r = flask_client.get( url_for("auth.oidc_login"), follow_redirects=False, @@ -28,8 +42,41 @@ def test_oidc_login(flask_client): assert expected_redirect_url == query["redirect_uri"][0] -def test_oidc_login_no_client_id(flask_client): +@patch("requests.get") +def test_oidc_login_next_url(mock_get, flask_client): + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + + mock_get.return_value.json.return_value = mock_well_known_response + + with flask_client: + r = flask_client.get( + url_for("auth.oidc_login", next="/dashboard/settings/"), + follow_redirects=False, + ) + location = r.headers.get("Location") + assert location is not None + + parsed = parse_url(location) + query = parse_qs(parsed.query) + + expected_redirect_url = f"{URL}/auth/oidc/callback" + + assert "code" == query["response_type"][0] + assert OIDC_CLIENT_ID == query["client_id"][0] + assert expected_redirect_url == query["redirect_uri"][0] + assert session["oauth_redirect_next"] == "/dashboard/settings/" + + +@patch("requests.get") +def test_oidc_login_no_client_id(mock_get, flask_client): config.OIDC_CLIENT_ID = None + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + + mock_get.return_value.json.return_value = mock_well_known_response r = flask_client.get( url_for("auth.oidc_login"), @@ -47,8 +94,14 @@ def test_oidc_login_no_client_id(flask_client): config.OIDC_CLIENT_ID = "to_fill" -def test_oidc_login_no_client_secret(flask_client): +@patch("requests.get") +def test_oidc_login_no_client_secret(mock_get, flask_client): config.OIDC_CLIENT_SECRET = None + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + + mock_get.return_value.json.return_value = mock_well_known_response r = flask_client.get( url_for("auth.oidc_login"), @@ -66,9 +119,14 @@ def test_oidc_login_no_client_secret(flask_client): config.OIDC_CLIENT_SECRET = "to_fill" -def test_oidc_callback_no_oauth_state(flask_client): - with flask_client.session_transaction() as session: - session["oauth_state"] = None +@patch("requests.get") +def test_oidc_callback_no_oauth_state(mock_get, flask_client): + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = None + + mock_get.return_value.json.return_value = mock_well_known_response r = flask_client.get( url_for("auth.oidc_callback"), @@ -78,11 +136,16 @@ def test_oidc_callback_no_oauth_state(flask_client): assert location is None -def test_oidc_callback_no_client_id(flask_client): - with flask_client.session_transaction() as session: - session["oauth_state"] = "state" +@patch("requests.get") +def test_oidc_callback_no_client_id(mock_get, flask_client): + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = "state" config.OIDC_CLIENT_ID = None + mock_get.return_value.json.return_value = mock_well_known_response + r = flask_client.get( url_for("auth.oidc_callback"), follow_redirects=False, @@ -97,15 +160,20 @@ def test_oidc_callback_no_client_id(flask_client): assert expected_redirect_url == parsed.path config.OIDC_CLIENT_ID = "to_fill" - with flask_client.session_transaction() as session: - session["oauth_state"] = None + with flask_client.session_transaction() as sess: + sess["oauth_state"] = None -def test_oidc_callback_no_client_secret(flask_client): - with flask_client.session_transaction() as session: - session["oauth_state"] = "state" +@patch("requests.get") +def test_oidc_callback_no_client_secret(mock_get, flask_client): + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = "state" config.OIDC_CLIENT_SECRET = None + mock_get.return_value.json.return_value = mock_well_known_response + r = flask_client.get( url_for("auth.oidc_callback"), follow_redirects=False, @@ -120,16 +188,23 @@ def test_oidc_callback_no_client_secret(flask_client): assert expected_redirect_url == parsed.path config.OIDC_CLIENT_SECRET = "to_fill" - with flask_client.session_transaction() as session: - session["oauth_state"] = None + with flask_client.session_transaction() as sess: + sess["oauth_state"] = None +@patch("requests.get") @patch("requests_oauthlib.OAuth2Session.fetch_token") @patch("requests_oauthlib.OAuth2Session.get") -def test_oidc_callback_invalid_user(mock_get, mock_fetch_token, flask_client): - mock_get.return_value = MockResponse(400, {}) - with flask_client.session_transaction() as session: - session["oauth_state"] = "state" +def test_oidc_callback_invalid_user( + mock_oauth_get, mock_fetch_token, mock_get, flask_client +): + mock_oauth_get.return_value = MockResponse(400, {}) + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = "state" + + mock_get.return_value.json.return_value = mock_well_known_response r = flask_client.get( url_for("auth.oidc_callback"), @@ -143,18 +218,25 @@ def test_oidc_callback_invalid_user(mock_get, mock_fetch_token, flask_client): expected_redirect_url = "/auth/login" assert expected_redirect_url == parsed.path - assert mock_get.called + assert mock_oauth_get.called - with flask_client.session_transaction() as session: - session["oauth_state"] = None + with flask_client.session_transaction() as sess: + sess["oauth_state"] = None +@patch("requests.get") @patch("requests_oauthlib.OAuth2Session.fetch_token") @patch("requests_oauthlib.OAuth2Session.get") -def test_oidc_callback_no_email(mock_get, mock_fetch_token, flask_client): - mock_get.return_value = MockResponse(200, {}) - with flask_client.session_transaction() as session: - session["oauth_state"] = "state" +def test_oidc_callback_no_email( + mock_oauth_get, mock_fetch_token, mock_get, flask_client +): + mock_oauth_get.return_value = MockResponse(200, {}) + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = "state" + + mock_get.return_value.json.return_value = mock_well_known_response r = flask_client.get( url_for("auth.oidc_callback"), @@ -168,20 +250,27 @@ def test_oidc_callback_no_email(mock_get, mock_fetch_token, flask_client): expected_redirect_url = "/auth/login" assert expected_redirect_url == parsed.path - assert mock_get.called + assert mock_oauth_get.called with flask_client.session_transaction() as session: session["oauth_state"] = None +@patch("requests.get") @patch("requests_oauthlib.OAuth2Session.fetch_token") @patch("requests_oauthlib.OAuth2Session.get") -def test_oidc_callback_disabled_registration(mock_get, mock_fetch_token, flask_client): +def test_oidc_callback_disabled_registration( + mock_oauth_get, mock_fetch_token, mock_get, flask_client +): config.DISABLE_REGISTRATION = True email = random_string() - mock_get.return_value = MockResponse(200, {"email": email}) - with flask_client.session_transaction() as session: - session["oauth_state"] = "state" + mock_oauth_get.return_value = MockResponse(200, {"email": email}) + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = "state" + + mock_get.return_value.json.return_value = mock_well_known_response r = flask_client.get( url_for("auth.oidc_callback"), @@ -195,26 +284,33 @@ def test_oidc_callback_disabled_registration(mock_get, mock_fetch_token, flask_c expected_redirect_url = "/auth/register" assert expected_redirect_url == parsed.path - assert mock_get.called + assert mock_oauth_get.called config.DISABLE_REGISTRATION = False - with flask_client.session_transaction() as session: - session["oauth_state"] = None + with flask_client.session_transaction() as sess: + sess["oauth_state"] = None +@patch("requests.get") @patch("requests_oauthlib.OAuth2Session.fetch_token") @patch("requests_oauthlib.OAuth2Session.get") -def test_oidc_callback_registration(mock_get, mock_fetch_token, flask_client): +def test_oidc_callback_registration( + mock_oauth_get, mock_fetch_token, mock_get, flask_client +): email = random_string() - mock_get.return_value = MockResponse( + mock_oauth_get.return_value = MockResponse( 200, { "email": email, config.OIDC_NAME_FIELD: "name", }, ) - with flask_client.session_transaction() as session: - session["oauth_state"] = "state" + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = "state" + + mock_get.return_value.json.return_value = mock_well_known_response user = User.get_by(email=email) assert user is None @@ -231,28 +327,33 @@ def test_oidc_callback_registration(mock_get, mock_fetch_token, flask_client): expected_redirect_url = "/dashboard/" assert expected_redirect_url == parsed.path - assert mock_get.called + assert mock_oauth_get.called user = User.get_by(email=email) assert user is not None assert user.email == email - with flask_client.session_transaction() as session: - session["oauth_state"] = None + with flask_client.session_transaction() as sess: + sess["oauth_state"] = None +@patch("requests.get") @patch("requests_oauthlib.OAuth2Session.fetch_token") @patch("requests_oauthlib.OAuth2Session.get") -def test_oidc_callback_login(mock_get, mock_fetch_token, flask_client): +def test_oidc_callback_login(mock_oauth_get, mock_fetch_token, mock_get, flask_client): email = random_string() - mock_get.return_value = MockResponse( + mock_oauth_get.return_value = MockResponse( 200, { "email": email, }, ) - with flask_client.session_transaction() as session: - session["oauth_state"] = "state" + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = None + sess["oauth_state"] = "state" + + mock_get.return_value.json.return_value = mock_well_known_response user = User.create( email=email, @@ -275,10 +376,57 @@ def test_oidc_callback_login(mock_get, mock_fetch_token, flask_client): expected_redirect_url = "/dashboard/" assert expected_redirect_url == parsed.path - assert mock_get.called + assert mock_oauth_get.called - with flask_client.session_transaction() as session: - session["oauth_state"] = None + with flask_client.session_transaction() as sess: + sess["oauth_state"] = None + + +@patch("requests.get") +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch("requests_oauthlib.OAuth2Session.get") +def test_oidc_callback_login_with_next_url( + mock_oauth_get, mock_fetch_token, mock_get, flask_client +): + email = random_string() + mock_oauth_get.return_value = MockResponse( + 200, + { + "email": email, + }, + ) + config.OIDC_WELL_KNOWN_URL = "http://localhost:7777/well-known-url" + with flask_client.session_transaction() as sess: + sess["oauth_redirect_next"] = "/dashboard/settings/" + sess["oauth_state"] = "state" + + mock_get.return_value.json.return_value = mock_well_known_response + + user = User.create( + email=email, + name="name", + password="", + activated=True, + ) + user = User.get_by(email=email) + assert user is not None + + r = flask_client.get( + url_for("auth.oidc_callback"), + follow_redirects=False, + ) + location = r.headers.get("Location") + assert location is not None + + parsed = parse_url(location) + + expected_redirect_url = "/dashboard/settings/" + + assert expected_redirect_url == parsed.path + assert mock_oauth_get.called + + with flask_client.session_transaction() as sess: + sess["oauth_state"] = None def test_create_user(): diff --git a/app/tests/cron/test_get_alias_for_hibp.py b/app/tests/cron/test_get_alias_for_hibp.py index ba41666..370c6ff 100644 --- a/app/tests/cron/test_get_alias_for_hibp.py +++ b/app/tests/cron/test_get_alias_for_hibp.py @@ -31,6 +31,7 @@ def test_get_alias_for_free_user_has_no_alias(): def test_get_alias_for_lifetime_with_null_hibp_date(): user = create_new_user() user.lifetime = True + user.enable_data_breach_check = True alias_id = Alias.create_new_random(user).id Session.commit() aliases = list( @@ -42,6 +43,7 @@ def test_get_alias_for_lifetime_with_null_hibp_date(): def test_get_alias_for_lifetime_with_old_hibp_date(): user = create_new_user() user.lifetime = True + user.enable_data_breach_check = True alias = Alias.create_new_random(user) alias.hibp_last_check = arrow.now().shift(days=-1) alias_id = alias.id @@ -97,6 +99,7 @@ sub_generator_list = [ @pytest.mark.parametrize("sub_generator", sub_generator_list) def test_get_alias_for_sub(sub_generator): user = create_new_user() + user.enable_data_breach_check = True sub_generator(user) alias_id = Alias.create_new_random(user).id Session.commit() @@ -140,3 +143,26 @@ def test_already_checked_is_not_checked(): cron.get_alias_to_check_hibp(arrow.now(), [user.id], alias_id, alias_id + 1) ) assert len(aliases) == 0 + + +def test_outed_in_user_is_checked(): + user = create_new_user() + user.lifetime = True + user.enable_data_breach_check = True + alias_id = Alias.create_new_random(user).id + Session.commit() + aliases = list( + cron.get_alias_to_check_hibp(arrow.now(), [], alias_id, alias_id + 1) + ) + assert len(aliases) == 1 + + +def test_outed_out_user_is_not_checked(): + user = create_new_user() + user.lifetime = True + alias_id = Alias.create_new_random(user).id + Session.commit() + aliases = list( + cron.get_alias_to_check_hibp(arrow.now(), [], alias_id, alias_id + 1) + ) + assert len(aliases) == 0 diff --git a/app/tests/test.env b/app/tests/test.env index 49941be..0c49222 100644 --- a/app/tests/test.env +++ b/app/tests/test.env @@ -51,9 +51,7 @@ FACEBOOK_CLIENT_SECRET=to_fill # Login with OIDC CONNECT_WITH_OIDC_ICON=fa-github -OIDC_AUTHORIZATION_URL=to_fill -OIDC_USER_INFO_URL=to_fill -OIDC_TOKEN_URL=to_fill +OIDC_WELL_KNOWN_URL=to_fill OIDC_SCOPES=openid email profile OIDC_NAME_FIELD=name OIDC_CLIENT_ID=to_fill