diff --git a/app/app/auth/views/forgot_password.py b/app/app/auth/views/forgot_password.py index 684fd38..42246a1 100644 --- a/app/app/auth/views/forgot_password.py +++ b/app/app/auth/views/forgot_password.py @@ -1,4 +1,4 @@ -from flask import request, render_template, redirect, url_for, flash, g +from flask import request, render_template, flash, g from flask_wtf import FlaskForm from wtforms import StringField, validators @@ -16,7 +16,7 @@ class ForgotPasswordForm(FlaskForm): @auth_bp.route("/forgot_password", methods=["GET", "POST"]) @limiter.limit( - "10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit + "10/hour", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit ) def forgot_password(): form = ForgotPasswordForm(request.form) @@ -37,6 +37,5 @@ def forgot_password(): if user: LOG.d("Send forgot password email to %s", user) send_reset_password_email(user) - return redirect(url_for("auth.forgot_password")) return render_template("auth/forgot_password.html", form=form) diff --git a/app/app/auth/views/reset_password.py b/app/app/auth/views/reset_password.py index d3e9f74..e331cd9 100644 --- a/app/app/auth/views/reset_password.py +++ b/app/app/auth/views/reset_password.py @@ -60,8 +60,8 @@ def reset_password(): # this can be served to activate user too user.activated = True - # remove the reset password code - ResetPasswordCode.delete(reset_password_code.id) + # remove all reset password codes + ResetPasswordCode.filter_by(user_id=user.id).delete() # change the alternative_id to log user out on other browsers user.alternative_id = str(uuid.uuid4()) diff --git a/app/app/dashboard/views/setting.py b/app/app/dashboard/views/setting.py index 7ad4899..79ccb16 100644 --- a/app/app/dashboard/views/setting.py +++ b/app/app/dashboard/views/setting.py @@ -198,6 +198,16 @@ def setting(): ) return redirect(url_for("dashboard.setting")) + if current_user.profile_picture_id is not None: + current_profile_file = File.get_by( + id=current_user.profile_picture_id + ) + if ( + current_profile_file is not None + and current_profile_file.user_id == current_user.id + ): + s3.delete(current_user.path) + file_path = random_string(30) file = File.create(user_id=current_user.id, path=file_path) @@ -451,8 +461,13 @@ def send_change_email_confirmation(user: User, email_change: EmailChange): @dashboard_bp.route("/resend_email_change", methods=["GET", "POST"]) +@limiter.limit("5/hour") @login_required def resend_email_change(): + form = CSRFValidationForm() + if not form.validate(): + flash("Invalid request. Please try again", "warning") + return redirect(url_for("dashboard.setting")) email_change = EmailChange.get_by(user_id=current_user.id) if email_change: # extend email change expiration @@ -472,6 +487,10 @@ def resend_email_change(): @dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"]) @login_required def cancel_email_change(): + form = CSRFValidationForm() + if not form.validate(): + flash("Invalid request. Please try again", "warning") + return redirect(url_for("dashboard.setting")) email_change = EmailChange.get_by(user_id=current_user.id) if email_change: EmailChange.delete(email_change.id) diff --git a/app/app/models.py b/app/app/models.py index 76a71b5..7a1ad0a 100644 --- a/app/app/models.py +++ b/app/app/models.py @@ -580,19 +580,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): Session.flush() user.default_mailbox_id = mb.id - # create a first alias mail to show user how to use when they login - alias = Alias.create_new( - user, - prefix="simplelogin-newsletter", - mailbox_id=mb.id, - note="This is your first alias. It's used to receive SimpleLogin communications " - "like new features announcements, newsletters.", - ) - Session.flush() - - user.newsletter_alias_id = alias.id - Session.flush() - # generate an alternative_id if needed if "alternative_id" not in kwargs: user.alternative_id = str(uuid.uuid4()) @@ -611,6 +598,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): Session.flush() return user + # create a first alias mail to show user how to use when they login + alias = Alias.create_new( + user, + prefix="simplelogin-newsletter", + mailbox_id=mb.id, + note="This is your first alias. It's used to receive SimpleLogin communications " + "like new features announcements, newsletters.", + ) + Session.flush() + + user.newsletter_alias_id = alias.id + Session.flush() + if config.DISABLE_ONBOARDING: LOG.d("Disable onboarding emails") return user @@ -636,7 +636,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): return user def get_active_subscription( - self, + self, include_partner_subscription: bool = True ) -> Optional[ Union[ Subscription @@ -664,19 +664,24 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): if coinbase_subscription and coinbase_subscription.is_active(): return coinbase_subscription - partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id) - if partner_sub and partner_sub.is_active(): - return partner_sub + if include_partner_subscription: + partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id( + self.id + ) + if partner_sub and partner_sub.is_active(): + return partner_sub return None # region Billing - def lifetime_or_active_subscription(self) -> bool: + def lifetime_or_active_subscription( + self, include_partner_subscription: bool = True + ) -> bool: """True if user has lifetime licence or active subscription""" if self.lifetime: return True - return self.get_active_subscription() is not None + return self.get_active_subscription(include_partner_subscription) is not None def is_paid(self) -> bool: """same as _lifetime_or_active_subscription but not include free manual subscription""" @@ -705,14 +710,14 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): return True - def is_premium(self) -> bool: + def is_premium(self, include_partner_subscription: bool = True) -> bool: """ user is premium if they: - have a lifetime deal or - in trial period or - active subscription """ - if self.lifetime_or_active_subscription(): + if self.lifetime_or_active_subscription(include_partner_subscription): return True if self.trial_end and arrow.now() < self.trial_end: diff --git a/app/templates/dashboard/setting.html b/app/templates/dashboard/setting.html index 00a781f..ad4b7ee 100644 --- a/app/templates/dashboard/setting.html +++ b/app/templates/dashboard/setting.html @@ -181,10 +181,10 @@
-
- - {{ change_email_form.csrf_token }} -
+
+ + + {{ change_email_form.csrf_token }}
Account Email
This email address is used to log in to SimpleLogin. @@ -199,26 +199,30 @@ {{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }} {{ render_field_errors(change_email_form.email) }} - {% if pending_email %} - -
- Pending email change: {{ pending_email }} - - Resend - confirmation email - - - Cancel email - change - -
- {% endif %}
-
- + + {% if pending_email %} + +
+ Pending email change: {{ pending_email }} +
+ {{ change_email_form.csrf_token }} + Resend confirmation email +
+
+ {{ change_email_form.csrf_token }} + Cancel email change +
+
+ {% endif %} +
@@ -265,11 +269,15 @@
Password
-
You will receive an email containing instructions on how to change your password.
+
+ You will receive an email containing instructions on how to change your password. +
{{ csrf_form.csrf_token }} - +
diff --git a/app/tests/auth/test_reset_password.py b/app/tests/auth/test_reset_password.py new file mode 100644 index 0000000..adabc58 --- /dev/null +++ b/app/tests/auth/test_reset_password.py @@ -0,0 +1,26 @@ +from flask import url_for + +from app.db import Session +from app.models import User, ResetPasswordCode +from tests.utils import create_new_user, random_token + + +def test_successful_reset_password(flask_client): + user = create_new_user() + original_pass_hash = user.password + user_id = user.id + reset_code = random_token() + ResetPasswordCode.create(user_id=user.id, code=reset_code) + ResetPasswordCode.create(user_id=user.id, code=random_token()) + Session.commit() + + r = flask_client.post( + url_for("auth.reset_password", code=reset_code), + data={"password": "1231idsfjaads"}, + ) + + assert r.status_code == 302 + + assert ResetPasswordCode.get_by(user_id=user_id) is None + user = User.get(user_id) + assert user.password != original_pass_hash diff --git a/app/tests/models/test_user.py b/app/tests/models/test_user.py index d6293b6..211cb3f 100644 --- a/app/tests/models/test_user.py +++ b/app/tests/models/test_user.py @@ -1,7 +1,9 @@ +import arrow from app import config from app.db import Session -from app.models import User, Job -from tests.utils import random_email +from app.models import User, Job, PartnerSubscription, PartnerUser, ManualSubscription +from app.proton.utils import get_proton_partner +from tests.utils import random_email, random_token def test_create_from_partner(flask_client): @@ -11,6 +13,7 @@ def test_create_from_partner(flask_client): ) assert user.notification is False assert user.trial_end is None + assert user.newsletter_alias_id is None job = Session.query(Job).order_by(Job.id.desc()).first() assert job is not None assert job.name == config.JOB_SEND_PROTON_WELCOME_1 @@ -23,3 +26,23 @@ def test_user_created_by_partner(flask_client): regular_user = User.create(email=random_email()) assert regular_user.created_by_partner is False + + +def test_user_is_premium(flask_client): + user = User.create(email=random_email(), from_partner=True) + assert not user.is_premium() + partner_user = PartnerUser.create( + user_id=user.id, + partner_id=get_proton_partner().id, + partner_email=user.email, + external_user_id=random_token(), + flush=True, + ) + ps = PartnerSubscription.create( + partner_user_id=partner_user.id, end_at=arrow.now().shift(years=1), flush=True + ) + assert user.is_premium() + assert not user.is_premium(include_partner_subscription=False) + ManualSubscription.create(user_id=user.id, end_at=ps.end_at) + assert user.is_premium() + assert user.is_premium(include_partner_subscription=False)