def mfa_setup(): if current_user.enable_otp: flash("you have already enabled MFA", "warning") return redirect(url_for("dashboard.index")) otp_token_form = OtpTokenForm() if not current_user.otp_secret: LOG.d("Generate otp_secret for user %s", current_user) current_user.otp_secret = pyotp.random_base32() Session.commit() totp = pyotp.TOTP(current_user.otp_secret) if otp_token_form.validate_on_submit(): token = otp_token_form.token.data.replace(" ", "") if totp.verify(token) and current_user.last_otp != token: current_user.enable_otp = True current_user.last_otp = token Session.commit() flash("MFA has been activated", "success") return redirect(url_for("dashboard.recovery_code_route")) else: flash("Incorrect token", "warning") otp_uri = pyotp.totp.TOTP(current_user.otp_secret).provisioning_uri( name=current_user.email, issuer_name="SimpleLogin") return render_template("dashboard/mfa_setup.html", otp_token_form=otp_token_form, otp_uri=otp_uri)
def test_should_disable_bounces_every_day(flask_client): """if an alias has bounces every day at least 9 days in the last 10 days, disable alias""" user = login(flask_client) alias = Alias.create_new_random(user) Session.commit() assert not should_disable(alias) # create a lot of bounce on this alias contact = Contact.create( user_id=user.id, alias_id=alias.id, website_email="*****@*****.**", reply_email="*****@*****.**", commit=True, ) for i in range(9): EmailLog.create( user_id=user.id, contact_id=contact.id, alias_id=contact.alias_id, commit=True, bounced=True, created_at=arrow.now().shift(days=-i), ) assert should_disable(alias)
def test_should_disable_bounce_consecutive_days(flask_client): user = login(flask_client) alias = Alias.create_new_random(user) Session.commit() contact = Contact.create( user_id=user.id, alias_id=alias.id, website_email="*****@*****.**", reply_email="*****@*****.**", commit=True, ) # create 6 bounce on this alias in the last 24h: alias is not disabled for _ in range(6): EmailLog.create( user_id=user.id, contact_id=contact.id, alias_id=contact.alias_id, commit=True, bounced=True, ) assert not should_disable(alias) # create 2 bounces in the last 7 days: alias should be disabled for _ in range(2): EmailLog.create( user_id=user.id, contact_id=contact.id, alias_id=contact.alias_id, commit=True, bounced=True, created_at=arrow.now().shift(days=-3), ) assert should_disable(alias)
def test_import_no_mailboxes(flask_client): # Create user user = login(flask_client) # Check start state assert len(Alias.filter_by(user_id=user.id).all()) == 1 # Onboarding alias # Create domain CustomDomain.create( user_id=user.id, domain="my-domain.com", ownership_verified=True ) Session.commit() alias_data = [ "alias,note", "[email protected],Used on eBay", '[email protected],"Used on Facebook, Instagram."', ] file = File.create(path="/test", commit=True) batch_import = BatchImport.create(user_id=user.id, file_id=file.id) import_from_csv(batch_import, user, alias_data) assert len(Alias.filter_by(user_id=user.id).all()) == 3 # +2
def test_alias_transfer(flask_client): user = login(flask_client) mb = Mailbox.create(user_id=user.id, email="*****@*****.**", commit=True) alias = Alias.create_new_random(user) Session.commit() AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id, commit=True) new_user = User.create( email="*****@*****.**", password="******", activated=True, commit=True, ) Mailbox.create( user_id=new_user.id, email="*****@*****.**", verified=True, commit=True ) alias_transfer.transfer(alias, new_user, new_user.mailboxes()) # refresh from db alias = Alias.get(alias.id) assert alias.user == new_user assert set(alias.mailboxes) == set(new_user.mailboxes()) assert len(alias.mailboxes) == 2
def setup_sl_domain() -> SLDomain: """Take the first SLDomain and set its can_use_subdomain=True""" sl_domain: SLDomain = SLDomain.first() sl_domain.can_use_subdomain = True Session.commit() return sl_domain
def test_create_delete_api_key(flask_client): user = login(flask_client) Session.commit() # create api_key create_r = flask_client.post( url_for("dashboard.api_key"), data={ "form-name": "create", "name": "for test" }, follow_redirects=True, ) assert create_r.status_code == 200 api_key = ApiKey.get_by(user_id=user.id) assert ApiKey.count() == 1 assert api_key.name == "for test" # delete api_key delete_r = flask_client.post( url_for("dashboard.api_key"), data={ "form-name": "delete", "api-key-id": api_key.id }, follow_redirects=True, ) assert delete_r.status_code == 200 assert ApiKey.count() == 0
def delete_alias(alias: Alias, user: User): """ Delete an alias and add it to either global or domain trash Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create """ # save deleted alias to either global or domain trash if alias.custom_domain_id: if not DomainDeletedAlias.get_by(email=alias.email, domain_id=alias.custom_domain_id): LOG.d("add %s to domain %s trash", alias, alias.custom_domain_id) Session.add( DomainDeletedAlias( user_id=user.id, email=alias.email, domain_id=alias.custom_domain_id, )) Session.commit() else: if not DeletedAlias.get_by(email=alias.email): LOG.d("add %s to global trash", alias) Session.add(DeletedAlias(email=alias.email)) Session.commit() LOG.i("delete alias %s", alias) Alias.filter(Alias.id == alias.id).delete() Session.commit()
def test_auth_login_success(flask_client, mfa: bool): User.create( email="*****@*****.**", password=PASSWORD_1, name="Test User", activated=True, enable_otp=mfa, ) Session.commit() r = flask_client.post( "/api/auth/login", json={ "email": "*****@*****.**", "password": PASSWORD_2, "device": "Test Device", }, ) assert r.status_code == 200 assert r.json["name"] == "Test User" assert r.json["email"] if mfa: assert r.json["api_key"] is None assert r.json["mfa_enabled"] assert r.json["mfa_key"] else: assert r.json["api_key"] assert not r.json["mfa_enabled"] assert r.json["mfa_key"] is None
def test_cancel_mailbox_email_change(flask_client): user = login(flask_client) # create a mailbox mb = Mailbox.create(user_id=user.id, email="*****@*****.**") Session.commit() # update mailbox email r = flask_client.put( f"/api/mailboxes/{mb.id}", json={"email": "*****@*****.**"}, ) assert r.status_code == 200 mb = Mailbox.get(mb.id) assert mb.new_email == "*****@*****.**" # cancel mailbox email change r = flask_client.put( url_for("api.delete_mailbox", mailbox_id=mb.id), json={"cancel_email_change": True}, ) assert r.status_code == 200 mb = Mailbox.get(mb.id) assert mb.new_email is None
def test_set_mailbox_as_default(flask_client): user = login(flask_client) mb = Mailbox.create(user_id=user.id, email="*****@*****.**", verified=True, commit=True) assert user.default_mailbox_id != mb.id r = flask_client.put( f"/api/mailboxes/{mb.id}", json={"default": True}, ) assert r.status_code == 200 assert user.default_mailbox_id == mb.id # <<< Cannot set an unverified mailbox as default >>> mb.verified = False Session.commit() r = flask_client.put( f"/api/mailboxes/{mb.id}", json={"default": True}, ) assert r.status_code == 400 assert r.json == { "error": "Unverified mailbox cannot be used as default mailbox" }
def test_logout(flask_client): # create user, user is activated User.create(email="[email protected]", password="******", name="Test User", activated=True) Session.commit() # login user flask_client.post( url_for("auth.login"), data={ "email": "[email protected]", "password": "******" }, follow_redirects=True, ) # logout r = flask_client.get( url_for("auth.logout"), follow_redirects=True, ) assert r.status_code == 200
def reservation_route(reservation_id: int): reservation: PhoneReservation = PhoneReservation.get(reservation_id) if not reservation or reservation.user_id != current_user.id: flash("Unknown error, redirect back to phone page", "warning") return redirect(url_for("phone.index")) phone_number = reservation.number if request.method == "POST": if request.form.get("form-name") == "release": time_left = reservation.end - arrow.now() if time_left.seconds > 0: current_user.phone_quota += time_left.seconds // 60 flash( f"Your phone quota is increased by {time_left.seconds // 60} minutes", "success", ) reservation.end = arrow.now() Session.commit() flash(f"{phone_number.number} is released", "success") return redirect(url_for("phone.index")) return render_template( "phone/phone_reservation.html", phone_number=phone_number, reservation=reservation, now=arrow.now(), )
def send_email_at_most_times( user: User, alert_type: str, to_email: str, subject, plaintext, html=None, max_times=1, ) -> bool: """Same as send_email with rate control over alert_type. Sent at most `max_times` This is used to inform users about a warning. Return true if the email is sent, otherwise False """ to_email = sanitize_email(to_email) nb_alert = SentAlert.filter_by(alert_type=alert_type, to_email=to_email).count() if nb_alert >= max_times: LOG.w( "%s emails were sent to %s alert type %s", nb_alert, to_email, alert_type, ) return False SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email) Session.commit() send_email(to_email, subject, plaintext, html) return True
def app_route(): client_users = (ClientUser.filter_by(user_id=current_user.id).options( joinedload(ClientUser.client)).options(joinedload( ClientUser.alias)).all()) sorted(client_users, key=lambda cu: cu.client.name) if request.method == "POST": client_user_id = request.form.get("client-user-id") client_user = ClientUser.get(client_user_id) if not client_user or client_user.user_id != current_user.id: flash( "Unknown error, sorry for the inconvenience, refresh the page", "error") return redirect(request.url) client = client_user.client ClientUser.delete(client_user_id) Session.commit() flash(f"Link with {client.name} has been removed", "success") return redirect(request.url) return render_template( "dashboard/app.html", client_users=client_users, )
def test_auth_login_device_exist(flask_client): User.create(email="*****@*****.**", password="******", name="Test User", activated=True) Session.commit() r = flask_client.post( url_for("api.auth_login"), json={ "email": "*****@*****.**", "password": "******", "device": "Test Device", }, ) assert r.status_code == 200 api_key = r.json["api_key"] assert not r.json["mfa_enabled"] assert r.json["mfa_key"] is None assert r.json["name"] == "Test User" # same device, should return same api_key r = flask_client.post( url_for("api.auth_login"), json={ "email": "*****@*****.**", "password": "******", "device": "Test Device", }, ) assert r.json["api_key"] == api_key
def test_get_alias_infos_pinned_alias(flask_client): """Different scenarios with pinned alias""" user = User.create( email="[email protected]", password="******", name="Test User", activated=True, commit=True, ) # to have 3 pages: 2*PAGE_LIMIT + the alias automatically created for a new account for _ in range(2 * PAGE_LIMIT): Alias.create_new_random(user) first_alias = Alias.order_by(Alias.id).first() # should return PAGE_LIMIT alias alias_infos = get_alias_infos_with_pagination_v3(user) assert len(alias_infos) == PAGE_LIMIT # make sure first_alias is not returned as the default order is alias creation date assert first_alias not in [ai.alias for ai in alias_infos] # pin the first alias first_alias.pinned = True Session.commit() alias_infos = get_alias_infos_with_pagination_v3(user) # now first_alias is the first result assert first_alias == alias_infos[0].alias # and the page size is still the same assert len(alias_infos) == PAGE_LIMIT # pinned alias isn't included in the search alias_infos = get_alias_infos_with_pagination_v3(user, query="no match") assert len(alias_infos) == 0
async def delete_by_id(fizz_id: int, session: Session) -> List[int]: fizz = session.query(Fizz).filter(Fizz.fizz_id == fizz_id).first() if not fizz: return [] session.delete(fizz) session.commit() return [fizz_id]
def test_delete_all_api_keys(flask_client): # create two test users user_1 = login(flask_client) user_2 = User.create(email="[email protected]", password="******", name="Test User 2", activated=True) Session.commit() # create api_key for both users ApiKey.create(user_1.id, "for test") ApiKey.create(user_1.id, "for test 2") ApiKey.create(user_2.id, "for test") Session.commit() assert (ApiKey.count() == 3 ) # assert that the total number of API keys for all users is 3. # assert that each user has the API keys created assert ApiKey.filter(ApiKey.user_id == user_1.id).count() == 2 assert ApiKey.filter(ApiKey.user_id == user_2.id).count() == 1 # delete all of user 1's API keys r = flask_client.post( url_for("dashboard.api_key"), data={"form-name": "delete-all"}, follow_redirects=True, ) assert r.status_code == 200 assert ( ApiKey.count() == 1 ) # assert that the total number of API keys for all users is now 1. assert (ApiKey.filter(ApiKey.user_id == user_1.id).count() == 0 ) # assert that user 1 now has 0 API keys assert (ApiKey.filter(ApiKey.user_id == user_2.id).count() == 1 ) # assert that user 2 still has 1 API key
def test_authorize_page_login_user_non_supported_flow(flask_client): """return 400 if the flow is not supported""" user = login(flask_client) client = Client.create_new("test client", user.id) Session.commit() # Not provide any flow r = flask_client.get( url_for( "oauth.authorize", client_id=client.oauth_client_id, state="teststate", redirect_uri="http://localhost", # not provide response_type param here )) # Provide a not supported flow html = r.get_data(as_text=True) assert r.status_code == 400 assert "SimpleLogin only support the following OIDC flows" in html r = flask_client.get( url_for( "oauth.authorize", client_id=client.oauth_client_id, state="teststate", redirect_uri="http://localhost", # SL does not support this flow combination response_type="code token id_token", )) html = r.get_data(as_text=True) assert r.status_code == 400 assert "SimpleLogin only support the following OIDC flows" in html
def test_out_of_quota(flask_client): user = login(flask_client) user.trial_end = None Session.commit() # create MAX_NB_EMAIL_FREE_PLAN custom alias to run out of quota for _ in range(MAX_NB_EMAIL_FREE_PLAN): Alias.create_new(user, prefix="test") word = random_word() suffix = f".{word}@{EMAIL_DOMAIN}" signed_suffix = signer.sign(suffix).decode() r = flask_client.post( "/api/v3/alias/custom/new", json={ "alias_prefix": "prefix", "signed_suffix": signed_suffix, "note": "test note", "mailbox_ids": [user.default_mailbox_id], "name": "your name", }, ) assert r.status_code == 400 assert r.json == { "error": "You have reached the limitation of a " "free account with the maximum of 3 aliases, please upgrade your plan to create more aliases" }
def test_add_alias_multiple_mailboxes(flask_client): user = login(flask_client) Session.commit() alias_suffix = AliasSuffix( is_custom=False, suffix=f".12345@{EMAIL_DOMAIN}", is_premium=False, domain=EMAIL_DOMAIN, ) signed_alias_suffix = signer.sign(alias_suffix.serialize()).decode() # create with a multiple mailboxes mb1 = Mailbox.create(user_id=user.id, email="*****@*****.**", verified=True) Session.commit() r = flask_client.post( url_for("dashboard.custom_alias"), data={ "prefix": "prefix", "signed-alias-suffix": signed_alias_suffix, "mailboxes": [user.default_mailbox_id, mb1.id], }, follow_redirects=True, ) assert r.status_code == 200 assert f"Alias prefix.12345@{EMAIL_DOMAIN} has been created" in str(r.data) alias = Alias.order_by(Alias.created_at.desc()).first() assert alias._mailboxes
def test_website_send_to(flask_client): user = User.create( email="[email protected]", password="******", name="Test User", activated=True, commit=True, ) alias = Alias.create_new_random(user) Session.commit() # non-empty name c1 = Contact.create( user_id=user.id, alias_id=alias.id, website_email="*****@*****.**", reply_email="rep@SL", name="First Last", ) assert c1.website_send_to( ) == '"First Last | abcd at example.com" <rep@SL>' # empty name, ascii website_from, easy case c1.name = None c1.website_from = "First Last <*****@*****.**>" assert c1.website_send_to( ) == '"First Last | abcd at example.com" <rep@SL>' # empty name, RFC 2047 website_from c1.name = None c1.website_from = "=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <*****@*****.**>" assert c1.website_send_to( ) == '"Nhơn Nguyễn | abcd at example.com" <rep@SL>'
def check_mailbox_valid_domain(): """detect if there's mailbox that's using an invalid domain""" mailbox_ids = (Session.query(Mailbox.id).filter( Mailbox.verified.is_(True), Mailbox.disabled.is_(False)).all()) mailbox_ids = [e[0] for e in mailbox_ids] # iterate over id instead of mailbox directly # as a mailbox can be deleted during the sleep time for mailbox_id in mailbox_ids: mailbox = Mailbox.get(mailbox_id) # a mailbox has been deleted if not mailbox: continue if email_can_be_used_as_mailbox(mailbox.email): LOG.d("Mailbox %s valid", mailbox) mailbox.nb_failed_checks = 0 else: mailbox.nb_failed_checks += 1 nb_email_log = nb_email_log_for_mailbox(mailbox) LOG.w( "issue with mailbox %s domain. #alias %s, nb email log %s", mailbox, mailbox.nb_alias(), nb_email_log, ) # send a warning if mailbox.nb_failed_checks == 5: if mailbox.user.email != mailbox.email: send_email( mailbox.user.email, f"Mailbox {mailbox.email} is disabled", render( "transactional/disable-mailbox-warning.txt.jinja2", mailbox=mailbox, ), render( "transactional/disable-mailbox-warning.html", mailbox=mailbox, ), retries=3, ) # alert if too much fail and nb_email_log > 100 if mailbox.nb_failed_checks > 10 and nb_email_log > 100: mailbox.disabled = True if mailbox.user.email != mailbox.email: send_email( mailbox.user.email, f"Mailbox {mailbox.email} is disabled", render("transactional/disable-mailbox.txt.jinja2", mailbox=mailbox), render("transactional/disable-mailbox.html", mailbox=mailbox), retries=3, ) Session.commit()
def block_contact(contact_id): contact = Contact.get(contact_id) if not contact: flash("Incorrect link. Redirect you to the home page", "warning") return redirect(url_for("dashboard.index")) if contact.user_id != current_user.id: flash( "You don't have access to this page. Redirect you to the home page", "warning", ) return redirect(url_for("dashboard.index")) # automatic unsubscribe, according to https://tools.ietf.org/html/rfc8058 if request.method == "POST": contact.block_forward = True flash(f"Emails sent from {contact.website_email} are now blocked", "success") Session.commit() return redirect( url_for( "dashboard.alias_contact_manager", alias_id=contact.alias_id, highlight_contact_id=contact.id, )) else: # ask user confirmation return render_template("dashboard/block_contact.html", contact=contact)
def auth_payload(user, device) -> dict: ret = { "name": user.name or "", "email": user.email, "mfa_enabled": user.enable_otp } # do not give api_key, user can only obtain api_key after OTP verification if user.enable_otp: s = Signer(FLASK_SECRET) ret["mfa_key"] = s.sign(str(user.id)) ret["api_key"] = None else: api_key = ApiKey.get_by(user_id=user.id, name=device) if not api_key: LOG.d("create new api key for %s and %s", user, device) api_key = ApiKey.create(user.id, device) Session.commit() ret["mfa_key"] = None ret["api_key"] = api_key.code # so user is automatically logged in on the web login_user(user) return ret
def test_should_disable_bounces_account(flask_client): """if an account has more than 10 bounces every day for at least 5 days in the last 10 days, disable alias""" user = login(flask_client) alias = Alias.create_new_random(user) Session.commit() # create a lot of bounces on alias contact = Contact.create( user_id=user.id, alias_id=alias.id, website_email="*****@*****.**", reply_email="*****@*****.**", commit=True, ) for day in range(6): for _ in range(10): EmailLog.create( user_id=user.id, contact_id=contact.id, alias_id=contact.alias_id, commit=True, bounced=True, created_at=arrow.now().shift(days=-day), ) alias2 = Alias.create_new_random(user) assert should_disable(alias2)
def test_handle_coinbase_event_extend_subscription(flask_client): user = User.create( email="[email protected]", password="******", name="Test User", activated=True, ) user.trial_end = None Session.commit() cb = CoinbaseSubscription.create(user_id=user.id, end_at=arrow.now().shift(days=-400), commit=True) assert not cb.is_active() assert not user.is_paid() assert not user.is_premium() handle_coinbase_event( {"data": { "code": "AAAAAA", "metadata": { "user_id": str(user.id) } }}) assert user.is_paid() assert user.is_premium() assert CoinbaseSubscription.get_by(user_id=user.id) is not None
def update_user_info(): """ Input - profile_picture (optional): base64 of the profile picture. Set to null to remove the profile picture - name (optional) """ user = g.user data = request.get_json() or {} if "profile_picture" in data: if data["profile_picture"] is None: if user.profile_picture_id: file = user.profile_picture user.profile_picture_id = None Session.flush() if file: File.delete(file.id) s3.delete(file.path) Session.flush() else: raw_data = base64.decodebytes(data["profile_picture"].encode()) file_path = random_string(30) file = File.create(user_id=user.id, path=file_path) Session.flush() s3.upload_from_bytesio(file_path, BytesIO(raw_data)) user.profile_picture_id = file.id Session.flush() if "name" in data: user.name = data["name"] Session.commit() return jsonify(user_to_dict(user))
def change_email(): code = request.args.get("code") email_change: EmailChange = EmailChange.get_by(code=code) if not email_change: return render_template("auth/change_email.html") if email_change.is_expired(): # delete the expired email EmailChange.delete(email_change.id) Session.commit() return render_template("auth/change_email.html") user = email_change.user user.email = email_change.new_email EmailChange.delete(email_change.id) Session.commit() flash("Your new email has been updated", "success") login_user(user) return redirect(url_for("dashboard.index"))