Compare commits

..

4 Commits

Author SHA1 Message Date
a829074584 4.57.2
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m6s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m48s
Build-Release-Image / Merge-Images (push) Successful in 20s
Build-Release-Image / Create-Release (push) Successful in 11s
Build-Release-Image / Notify (push) Successful in 2s
2024-11-06 12:00:08 +00:00
25834e8f61 4.56.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m15s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m45s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 21s
2024-11-05 12:00:07 +00:00
a62b43b7c4 4.56.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m30s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m37s
Build-Release-Image / Merge-Images (push) Successful in 22s
Build-Release-Image / Create-Release (push) Successful in 24s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-25 12:00:05 +01:00
44fda2d94e 4.56.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m24s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m34s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-24 12:00:05 +01:00
32 changed files with 592 additions and 107 deletions

View File

@ -7,8 +7,4 @@ If you want be up to date on security patches, make sure your SimpleLogin image
## Reporting a Vulnerability
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
We will review the potential threat and fix it as fast as we can.
We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet.
If you want to report a vulnerability, please take a look at our bug bounty program at https://proton.me/security/bug-bounty.

View File

@ -3,12 +3,15 @@ from dataclasses import dataclass
from enum import Enum
from typing import Optional
import arrow
from arrow import Arrow
from newrelic import agent
from sqlalchemy import or_
from app.db import Session
from app.email_utils import send_welcome_email
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.partner_user_utils import create_partner_user, create_partner_subscription
from app.utils import sanitize_email, canonicalize_email
from app.errors import (
@ -54,6 +57,21 @@ class LinkResult:
strategy: str
def send_user_plan_changed_event(partner_user: PartnerUser) -> Optional[int]:
subscription_end = partner_user.user.get_active_subscription_end(
include_partner_subscription=False
)
end_timestamp = None
if partner_user.user.lifetime:
end_timestamp = arrow.get("2038-01-01").timestamp
elif subscription_end:
end_timestamp = subscription_end.timestamp
event = UserPlanChanged(plan_end_time=end_timestamp)
EventDispatcher.send_event(partner_user.user, EventContent(user_plan_change=event))
Session.flush()
return end_timestamp
def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
if plan.type == SLPlanType.Free:
@ -88,6 +106,8 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
action=UserAuditLogAction.SubscriptionExtended,
message="Extended partner subscription",
)
Session.flush()
send_user_plan_changed_event(partner_user)
Session.commit()
@ -112,6 +132,7 @@ def ensure_partner_user_exists_for_user(
partner_email=link_request.email,
external_user_id=link_request.external_user_id,
)
Session.commit()
LOG.i(
f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}"

View File

@ -16,6 +16,8 @@ from flask_admin.contrib import sqla
from flask_login import current_user
from app.db import Session
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.models import (
User,
ManualSubscription,
@ -39,6 +41,7 @@ from app.models import (
UserAuditLog,
)
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def _admin_action_formatter(view, context, model, name):
@ -351,17 +354,42 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
manual_sub.end_at = manual_sub.end_at.shift(years=1)
else:
manual_sub.end_at = arrow.now().shift(years=1, days=1)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription to user {user.email}",
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success")
continue
else:
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} created manual subscription to user {user.email}",
)
manual_sub = ManualSubscription.create(
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
ManualSubscription.create(
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
flash(f"New {way} manual subscription for {user} is created", "success")
flash(f"New {way} manual subscription for {user} is created", "success")
Session.commit()
@ -453,14 +481,7 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 year more?",
)
def extend_1y(self, ids):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
ms.end_at = ms.end_at.shift(years=1)
flash(f"Extend subscription for 1 year for {ms.user}", "success")
AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 year"
)
Session.commit()
self.__extend_manual_subscription(ids, msg="1 year", years=1)
@action(
"extend_1m",
@ -468,11 +489,26 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 month more?",
)
def extend_1m(self, ids):
self.__extend_manual_subscription(ids, msg="1 month", months=1)
def __extend_manual_subscription(self, ids: List[int], msg: str, **kwargs):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
ms.end_at = ms.end_at.shift(months=1)
flash(f"Extend subscription for 1 month for {ms.user}", "success")
sub: ManualSubscription = ms
sub.end_at = sub.end_at.shift(**kwargs)
flash(f"Extend subscription for {msg} for {sub.user}", "success")
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription for {msg} for {sub.user}",
)
AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 month"
current_user.id, sub.user.id, sub.end_at, msg
)
EventDispatcher.send_event(
user=sub.user,
content=EventContent(
user_plan_change=UserPlanChanged(plan_end_time=sub.end_at.timestamp)
),
)
Session.commit()

View File

@ -419,9 +419,8 @@ def create_contact_route(alias_id):
if not data:
return jsonify(error="request body cannot be empty"), 400
alias: Alias = Alias.get(alias_id)
if alias.user_id != g.user.id:
alias: Optional[Alias] = Alias.get_by(id=alias_id, user_id=g.user.id)
if not alias:
return jsonify(error="Forbidden"), 403
contact_address = data.get("contact")

View File

@ -38,7 +38,11 @@ def create_mailbox():
the new mailbox dict
"""
user = g.user
mailbox_email = sanitize_email(request.get_json().get("email"))
email = request.get_json().get("email")
if not email:
return jsonify(error="Invalid email"), 400
mailbox_email = sanitize_email(email)
try:
new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox

View File

@ -10,6 +10,7 @@ from app.events.auth_event import LoginEvent
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.pw_models import PasswordOracle
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
@ -43,6 +44,13 @@ def login():
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user or not user.check_password(form.password.data):
if not user:
# Do the hash to avoid timing attacks nevertheless
dummy_pw = PasswordOracle()
dummy_pw.password = (
"$2b$12$ZWqpL73h4rGNfLkJohAFAu0isqSw/bX9p/tzpbWRz/To5FAftaW8u"
)
dummy_pw.check_password(form.password.data)
# Trigger rate limiter
g.deduct_limit = True
form.password.data = None

View File

@ -309,6 +309,7 @@ JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
JOB_SEND_EVENT_TO_WEBHOOK = "send-event-to-webhook"
# for pagination
PAGE_LIMIT = 20

View File

@ -16,6 +16,7 @@ from app.utils import sanitize_email
class ContactCreateError(Enum):
InvalidEmail = "Invalid email"
NotAllowed = "Your plan does not allow to create contacts"
Unknown = "Unknown error when trying to create contact"
@dataclass
@ -87,8 +88,10 @@ def create_contact(
return __update_contact_if_needed(contact, name, mail_from)
# Create the contact
reply_email = generate_reply_email(email, alias)
alias_id = alias.id
try:
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
is_invalid_email = email == ""
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
@ -98,9 +101,10 @@ def create_contact(
mail_from=mail_from,
automatic_created=automatic_created,
flags=flags,
invalid_email=email == "",
invalid_email=is_invalid_email,
commit=True,
)
contact_id = contact.id
if automatic_created:
trail = ". Automatically created"
else:
@ -108,17 +112,27 @@ def create_contact(
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.CreateContact,
message=f"Created contact {contact.id} ({contact.email}){trail}",
message=f"Created contact {contact_id} ({email}){trail}",
commit=True,
)
LOG.d(
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}"
f"Created contact {contact} for alias {alias} with email {email} invalid_email={is_invalid_email}"
)
return ContactCreateResult(contact, created=True, error=None)
except IntegrityError:
Session.rollback()
LOG.info(
f"Contact with email {email} for alias_id {alias.id} already existed, fetching from DB"
f"Contact with email {email} for alias_id {alias_id} already existed, fetching from DB"
)
contact = Contact.get_by(alias_id=alias.id, website_email=email)
return __update_contact_if_needed(contact, name, mail_from)
return ContactCreateResult(contact, created=True, error=None)
contact: Optional[Contact] = Contact.get_by(
alias_id=alias_id, website_email=email
)
if contact:
return __update_contact_if_needed(contact, name, mail_from)
else:
LOG.warning(
f"Could not find contact with email {email} for alias_id {alias_id} and it should exist"
)
return ContactCreateResult(
None, created=False, error=ContactCreateError.Unknown
)

View File

@ -1,3 +1,5 @@
import secrets
import arrow
from flask import (
render_template,
@ -163,7 +165,7 @@ def send_reset_password_email(user):
"""
# the activation code is valid for 1h
reset_password_code = ResetPasswordCode.create(
user_id=user.id, code=random_string(60)
user_id=user.id, code=secrets.token_urlsafe(32)
)
Session.commit()

View File

@ -1,3 +1,4 @@
import arrow
from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
@ -7,6 +8,8 @@ from app.config import ADMIN_EMAIL
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import send_email
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.models import LifetimeCoupon
@ -40,6 +43,14 @@ def lifetime_licence():
current_user.lifetime_coupon_id = coupon.id
if coupon.paid:
current_user.paid_lifetime = True
EventDispatcher.send_event(
user=current_user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=arrow.get("2038-01-01").timestamp
)
),
)
Session.commit()
# notify admin

View File

@ -121,10 +121,16 @@ def mailbox_route():
@login_required
def mailbox_verify():
mailbox_id = request.args.get("mailbox_id")
if not mailbox_id:
LOG.i("Missing mailbox_id")
flash("You followed an invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
code = request.args.get("code")
if not code:
# Old way
return verify_with_signed_secret(mailbox_id)
try:
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
except mailbox_utils.MailboxError as e:

View File

@ -0,0 +1,70 @@
from __future__ import annotations
import base64
from typing import Optional
import arrow
from app import config
from app.errors import ProtonPartnerNotSetUp
from app.events.generated import event_pb2
from app.events.generated.event_pb2 import EventContent
from app.models import (
User,
Job,
PartnerUser,
)
from app.proton.utils import get_proton_partner
from events.event_sink import EventSink
class SendEventToWebhookJob:
def __init__(self, user: User, event: EventContent):
self._user: User = user
self._event: EventContent = event
def run(self, sink: EventSink) -> bool:
# Check if the current user has a partner_id
try:
proton_partner_id = get_proton_partner().id
except ProtonPartnerNotSetUp:
return False
# It has. Retrieve the information for the PartnerUser
partner_user = PartnerUser.get_by(
user_id=self._user.id, partner_id=proton_partner_id
)
if partner_user is None:
return True
event = event_pb2.Event(
user_id=self._user.id,
external_user_id=partner_user.external_user_id,
partner_id=partner_user.partner_id,
content=self._event,
)
serialized = event.SerializeToString()
return sink.send_data_to_webhook(serialized)
@staticmethod
def create_from_job(job: Job) -> Optional[SendEventToWebhookJob]:
user = User.get(job.payload["user_id"])
if not user:
return None
event_data = base64.b64decode(job.payload["event"])
event = event_pb2.EventContent()
event.ParseFromString(event_data)
return SendEventToWebhookJob(user=user, event=event)
def store_job_in_db(self, run_at: Optional[arrow.Arrow]) -> Job:
stub = self._event.SerializeToString()
return Job.create(
name=config.JOB_SEND_EVENT_TO_WEBHOOK,
payload={
"user_id": self._user.id,
"event": base64.b64encode(stub).decode("utf-8"),
},
run_at=run_at if run_at is not None else arrow.now(),
commit=True,
)

View File

@ -1,6 +1,5 @@
import dataclasses
import secrets
import random
from enum import Enum
from typing import Optional
import arrow
@ -37,8 +36,9 @@ class OnlyPaidError(MailboxError):
class CannotVerifyError(MailboxError):
def __init__(self, msg: str):
def __init__(self, msg: str, deleted_activation_code: bool = False):
self.msg = msg
self.deleted_activation_code = deleted_activation_code
MAX_ACTIVATION_TRIES = 3
@ -171,17 +171,17 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
f"User {user} failed to verify mailbox {mailbox_id} because it does not exist"
)
raise MailboxError("Invalid mailbox")
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
if mailbox.verified:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's already verified"
)
clear_activation_codes_for_mailbox(mailbox)
return mailbox
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
activation = (
MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id)
@ -196,7 +196,10 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
if activation.tries >= MAX_ACTIVATION_TRIES:
LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times")
clear_activation_codes_for_mailbox(mailbox)
raise CannotVerifyError("Invalid activation code. Please request another code.")
raise CannotVerifyError(
"Invalid activation code. Please request another code.",
deleted_activation_code=True,
)
if activation.created_at < arrow.now().shift(minutes=-15):
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because code is too old"
@ -229,7 +232,7 @@ def generate_activation_code(
if config.MAILBOX_VERIFICATION_OVERRIDE_CODE:
code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE
else:
code = "{:06d}".format(random.randint(1, 999999))
code = "{:06d}".format(secrets.randbelow(1000000))[:6]
else:
code = secrets.token_urlsafe(16)
return MailboxActivation.create(

View File

@ -24,6 +24,7 @@ from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import deferred
from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType
@ -3781,15 +3782,18 @@ class SyncEvent(Base, ModelMixin):
)
def mark_as_taken(self, allow_taken_older_than: Optional[Arrow] = None) -> bool:
taken_condition = ["taken_time IS NULL"]
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
if allow_taken_older_than:
taken_condition.append("taken_time < :taken_older_than")
args["taken_older_than"] = allow_taken_older_than.datetime
sql_taken_condition = "({})".format(" OR ".join(taken_condition))
sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}"
res = Session.execute(sql, args)
Session.commit()
try:
taken_condition = ["taken_time IS NULL"]
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
if allow_taken_older_than:
taken_condition.append("taken_time < :taken_older_than")
args["taken_older_than"] = allow_taken_older_than.datetime
sql_taken_condition = "({})".format(" OR ".join(taken_condition))
sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}"
res = Session.execute(sql, args)
Session.commit()
except ObjectDeletedError:
return False
return res.rowcount > 0

View File

@ -1,8 +1,10 @@
from typing import Optional
import arrow
from arrow import Arrow
from app.models import PartnerUser, PartnerSubscription, User
from app import config
from app.models import PartnerUser, PartnerSubscription, User, Job
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@ -15,6 +17,11 @@ def create_partner_user(
partner_email=partner_email,
external_user_id=external_user_id,
)
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.LinkAccount,

View File

@ -2,11 +2,9 @@ from dataclasses import dataclass
from enum import Enum
from flask import url_for
from typing import Optional
import arrow
from app import config
from app.errors import LinkException
from app.models import User, Partner, Job
from app.models import User, Partner
from app.proton.proton_client import ProtonClient, ProtonUser
from app.account_linking import (
process_login_case,
@ -43,21 +41,12 @@ class ProtonCallbackHandler:
def __init__(self, proton_client: ProtonClient):
self.proton_client = proton_client
def _initial_alias_sync(self, user: User):
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
commit=True,
)
def handle_login(self, partner: Partner) -> ProtonCallbackResult:
try:
user = self.__get_partner_user()
if user is None:
return generate_account_not_allowed_to_log_in()
res = process_login_case(user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult(
redirect_to_login=False,
flash_message=None,
@ -86,7 +75,6 @@ class ProtonCallbackHandler:
if user is None:
return generate_account_not_allowed_to_log_in()
res = process_link_case(user, current_user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult(
redirect_to_login=False,
flash_message="Account successfully linked",

View File

@ -1,4 +1,3 @@
import random
import re
import secrets
import string
@ -32,8 +31,9 @@ def random_words(words: int = 2, numbers: int = 0):
fields = [secrets.choice(_words) for i in range(words)]
if numbers > 0:
digits = "".join([str(random.randint(0, 9)) for i in range(numbers)])
return "_".join(fields) + digits
digits = [n for n in range(10)]
suffix = "".join([str(secrets.choice(digits)) for i in range(numbers)])
return "_".join(fields) + suffix
else:
return "_".join(fields)

View File

@ -286,8 +286,16 @@ def notify_manual_sub_end():
def poll_apple_subscription():
"""Poll Apple API to update AppleSubscription"""
# todo: only near the end of the subscription
for apple_sub in AppleSubscription.all():
for apple_sub in (
AppleSubscription.filter(
AppleSubscription.expires_date < arrow.now().shift(days=15)
)
.enable_eagerloads(False)
.yield_per(100)
):
if not apple_sub.is_valid():
# Subscription is not valid anymore and hasn't been renewed
continue
if not apple_sub.product_id:
LOG.d("Ignore %s", apple_sub)
continue
@ -900,6 +908,24 @@ def check_mailbox_valid_pgp_keys():
def check_custom_domain():
# Delete custom domains that haven't been verified in a month
for custom_domain in (
CustomDomain.filter(
CustomDomain.verified == False, # noqa: E712
CustomDomain.created_at < arrow.now().shift(months=-1),
)
.enable_eagerloads(False)
.yield_per(100)
):
alias_count = Alias.filter(Alias.custom_domain_id == custom_domain.id).count()
if alias_count > 0:
LOG.warn(
f"Custom Domain {custom_domain} has {alias_count} aliases. Won't delete"
)
else:
LOG.i(f"Deleting unverified old custom domain {custom_domain}")
CustomDomain.delete(custom_domain.id)
LOG.d("Check verified domain for DNS issues")
for custom_domain in CustomDomain.filter_by(verified=True): # type: CustomDomain
@ -971,7 +997,7 @@ def delete_expired_tokens():
LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row)
async def _hibp_check(api_key, queue):
async def _hibp_check(api_key: str, queue: asyncio.Queue):
"""
Uses a single API key to check the queue as fast as possible.
@ -990,11 +1016,16 @@ async def _hibp_check(api_key, queue):
if not alias:
continue
user = alias.user
if user.disabled or not user.is_paid():
if user.disabled or not user.is_premium():
# Mark it as hibp done to skip it as if it had been checked
alias.hibp_last_check = arrow.utcnow()
Session.commit()
continue
if alias.flags & Alias.FLAG_PARTNER_CREATED > 0:
# Mark as hibp done
alias.hibp_last_check = arrow.utcnow()
Session.commit()
continue
LOG.d("Checking HIBP for %s", alias)

View File

@ -16,13 +16,25 @@ jobs:
shell: /bin/bash
schedule: "15 2 * * *"
captureStderr: true
onFailure:
retry:
maximumRetries: 10
initialDelay: 1
maximumDelay: 30
backoffMultiplier: 2
- name: SimpleLogin HIBP check
command: python /code/cron.py -j check_hibp
shell: /bin/bash
schedule: "15 3 * * *"
schedule: "16 */4 * * *"
captureStderr: true
concurrencyPolicy: Forbid
onFailure:
retry:
maximumRetries: 10
initialDelay: 1
maximumDelay: 30
backoffMultiplier: 2
- name: SimpleLogin Notify HIBP breaches
command: python /code/cron.py -j notify_hibp
@ -31,6 +43,7 @@ jobs:
captureStderr: true
concurrencyPolicy: Forbid
- name: SimpleLogin Delete Logs
command: python /code/cron.py -j delete_logs
shell: /bin/bash

View File

@ -177,7 +177,9 @@ from init_app import load_pgp_public_keys
from server import create_light_app
def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Contact:
def get_or_create_contact(
from_header: str, mail_from: str, alias: Alias
) -> Optional[Contact]:
"""
contact_from_header is the RFC 2047 format FROM header
"""
@ -208,6 +210,8 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
automatic_created=True,
from_partner=False,
)
if contact_result.error:
LOG.w(f"Error creating contact: {contact_result.error.value}")
return contact_result.contact
@ -558,7 +562,7 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
if not user.is_active():
LOG.w(f"User {user} has been soft deleted")
return False, status.E502
return [(False, status.E502)]
if not user.can_send_or_receive():
LOG.i(f"User {user} cannot receive emails")
@ -579,6 +583,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
from_header = get_header_unicode(msg[headers.FROM])
LOG.d("Create or get contact for from_header:%s", from_header)
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
if not contact:
return [(False, status.E504)]
alias = (
contact.alias
) # In case the Session was closed in the get_or_create we re-fetch the alias

View File

@ -12,6 +12,10 @@ class EventSink(ABC):
def process(self, event: SyncEvent) -> bool:
pass
@abstractmethod
def send_data_to_webhook(self, data: bytes) -> bool:
pass
class HttpEventSink(EventSink):
def process(self, event: SyncEvent) -> bool:
@ -21,9 +25,16 @@ class HttpEventSink(EventSink):
LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}")
if self.send_data_to_webhook(event.content):
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
return False
def send_data_to_webhook(self, data: bytes) -> bool:
res = requests.post(
url=EVENT_WEBHOOK,
data=event.content,
data=data,
headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
)
@ -36,7 +47,6 @@ class HttpEventSink(EventSink):
)
return False
else:
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
@ -44,3 +54,7 @@ class ConsoleEventSink(EventSink):
def process(self, event: SyncEvent) -> bool:
LOG.info(f"Handling event {event.id}")
return True
def send_data_to_webhook(self, data: bytes) -> bool:
LOG.info(f"Sending {len(data)} bytes to webhook")
return True

View File

@ -18,6 +18,7 @@ from app.events.event_dispatcher import PostgresDispatcher
from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob
from app.jobs.send_event_job import SendEventToWebhookJob
from app.log import LOG
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@ -300,6 +301,10 @@ def process_job(job: Job):
send_alias_creation_events_for_user(
user, dispatcher=PostgresDispatcher.get()
)
elif job.name == config.JOB_SEND_EVENT_TO_WEBHOOK:
send_job = SendEventToWebhookJob.create_from_job(job)
if send_job:
send_job.run()
else:
LOG.e("Unknown job name %s", job.name)

View File

@ -0,0 +1,28 @@
"""Preserve user id on alias delete
Revision ID: 4882cc49dde9
Revises: 32f25cbf12f6
Create Date: 2024-11-06 10:10:40.235991
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4882cc49dde9'
down_revision = '32f25cbf12f6'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('deleted_alias', sa.Column('user_id', sa.Integer(), server_default=None, nullable=True))
with op.get_context().autocommit_block():
op.create_index('ix_deleted_alias_user_id_created_at', 'deleted_alias', ['user_id', 'created_at'], unique=False, postgresql_concurrently=True)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_deleted_alias_user_id_created_at', table_name='deleted_alias')
op.drop_column('deleted_alias', 'user_id')

View File

@ -0,0 +1,28 @@
"""Revert user id on deleted alias
Revision ID: bc9aa210efa3
Revises: 4882cc49dde9
Create Date: 2024-11-06 12:44:44.129691
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bc9aa210efa3'
down_revision = '4882cc49dde9'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_deleted_alias_user_id_created_at', table_name='deleted_alias')
op.drop_column('deleted_alias', 'user_id')
def downgrade():
op.add_column('deleted_alias', sa.Column('user_id', sa.Integer(), server_default=None, nullable=True))
with op.get_context().autocommit_block():
op.create_index('ix_deleted_alias_user_id_created_at', 'deleted_alias', ['user_id', 'created_at'], unique=False, postgresql_concurrently=True)

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
import argparse
import time
import arrow
from sqlalchemy import func
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.models import PartnerUser, User
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Send lifetime users to proton"
)
parser.add_argument(
"-s", "--start_pu_id", default=0, type=int, help="Initial partner_user_id"
)
parser.add_argument(
"-e", "--end_pu_id", default=0, type=int, help="Last partner_user_id"
)
args = parser.parse_args()
pu_id_start = args.start_pu_id
max_pu_id = args.end_pu_id
if max_pu_id == 0:
max_pu_id = Session.query(func.max(PartnerUser.id)).scalar()
print(f"Checking partner user {pu_id_start} to {max_pu_id}")
step = 1000
done = 0
start_time = time.time()
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step):
users = (
Session.query(User)
.join(PartnerUser, PartnerUser.user_id == User.id)
.filter(
PartnerUser.id >= batch_start,
PartnerUser.id < batch_start + step,
User.lifetime == True, # noqa :E712
)
).all()
for user in users:
# Just in case the == True cond is wonky
if not user.lifetime:
continue
with_lifetime += 1
event = UserPlanChanged(plan_end_time=arrow.get("2038-01-01").timestamp)
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
Session.flush()
Session.commit()
elapsed = time.time() - start_time
last_batch_id = batch_start + step
time_per_alias = elapsed / (last_batch_id)
remaining = max_pu_id - last_batch_id
time_remaining = remaining / time_per_alias
hours_remaining = time_remaining / 60.0
print(
f"\PartnerUser {batch_start}/{max_pu_id} {with_lifetime} {hours_remaining:.2f} mins remaining"
)
print(f"With SL lifetime {with_lifetime}")

View File

@ -2,10 +2,10 @@
import argparse
import time
import arrow
from sqlalchemy import func
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.account_linking import send_user_plan_changed_event
from app.models import PartnerUser
from app.db import Session
@ -30,6 +30,7 @@ step = 100
updated = 0
start_time = time.time()
with_premium = 0
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step):
partner_users = (
Session.query(PartnerUser).filter(
@ -37,18 +38,12 @@ for batch_start in range(pu_id_start, max_pu_id, step):
)
).all()
for partner_user in partner_users:
subscription_end = partner_user.user.get_active_subscription_end(
include_partner_subscription=False
)
end_timestamp = None
if subscription_end:
with_premium += 1
end_timestamp = subscription_end.timestamp
event = UserPlanChanged(plan_end_time=end_timestamp)
EventDispatcher.send_event(
partner_user.user, EventContent(user_plan_change=event)
)
Session.flush()
subscription_end = send_user_plan_changed_event(partner_user)
if subscription_end is not None:
if subscription_end > arrow.get("2038-01-01").timestamp:
with_lifetime += 1
else:
with_premium += 1
updated += 1
Session.commit()
elapsed = time.time() - start_time
@ -60,4 +55,4 @@ for batch_start in range(pu_id_start, max_pu_id, step):
print(
f"\PartnerUser {batch_start}/{max_pu_id} {updated} {hours_remaining:.2f} mins remaining"
)
print(f"With SL premium {with_premium}")
print(f"With SL premium {with_premium} lifetime {with_lifetime}")

View File

@ -8,8 +8,10 @@
<tr>
<th scope="col">User ID</th>
<th scope="col">Email</th>
<th scope="col">Verified</th>
<th scope="col">Status</th>
<th scope="col">Paid</th>
<th scope="col">Premium</th>
<th>Subscription</th>
<th>Created At</th>
<th>Updated At</th>
@ -20,13 +22,18 @@
<tr>
<td>{{ user.id }}</td>
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td>
{% if user.activated %}
<td class="text-success">Activated</td>
{% else %}
<td class="text-warning">Pending</td>
{% endif %}
{% if user.disabled %}
<td class="text-danger">Disabled</td>
{% else %}
<td class="text-success">Enabled</td>
{% endif %}
<td>{{ "yes" if user.is_paid() else "No" }}</td>
<td>{{ "yes" if user.is_premium() else "No" }}</td>
<td>{{ user.get_active_subscription() }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>

View File

@ -511,6 +511,19 @@ def test_create_contact_route_invalid_alias(flask_client):
assert r.status_code == 403
def test_create_contact_route_non_existing_alias(flask_client):
user, api_key = get_new_user_and_api_key()
Session.commit()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=99999999),
headers={"Authentication": api_key.code},
json={"contact": "First Last <first@example.com>"},
)
assert r.status_code == 403
def test_create_contact_route_free_users(flask_client):
user, api_key = get_new_user_and_api_key()

View File

@ -5,7 +5,7 @@ from app.models import Mailbox
from tests.utils import login
def test_create_mailbox(flask_client):
def test_create_mailbox_valid(flask_client):
login(flask_client)
r = flask_client.post(
@ -21,10 +21,34 @@ def test_create_mailbox(flask_client):
assert r.json["default"] is False
assert r.json["nb_alias"] == 0
# invalid email address
def test_create_mailbox_invalid_email(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={"email": "gmail.com"},
json={"email": "gmail.com"}, # not an email address
)
assert r.status_code == 400
assert r.json == {"error": "Invalid email"}
def test_create_mailbox_empty_payload(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={},
)
assert r.status_code == 400
assert r.json == {"error": "Invalid email"}
def test_create_mailbox_empty_email(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={"email": ""},
)
assert r.status_code == 400

View File

@ -0,0 +1,40 @@
import arrow
from app import config
from app.events.generated.event_pb2 import EventContent, AliasDeleted
from app.jobs.send_event_job import SendEventToWebhookJob
from app.models import PartnerUser
from app.proton.utils import get_proton_partner
from events.event_sink import ConsoleEventSink
from tests.utils import create_new_user, random_token
def test_serialize_and_deserialize_job():
user = create_new_user()
alias_id = 34
alias_email = "a@b.c"
event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email))
run_at = arrow.now().shift(hours=10)
db_job = SendEventToWebhookJob(user, event).store_job_in_db(run_at=run_at)
assert db_job.run_at == run_at
assert db_job.name == config.JOB_SEND_EVENT_TO_WEBHOOK
job = SendEventToWebhookJob.create_from_job(db_job)
assert job._user.id == user.id
assert job._event.alias_deleted.id == alias_id
assert job._event.alias_deleted.email == alias_email
def test_send_event_to_webhook():
user = create_new_user()
PartnerUser.create(
user_id=user.id,
partner_id=get_proton_partner().id,
external_user_id=random_token(10),
flush=True,
)
alias_id = 34
alias_email = "a@b.c"
event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email))
job = SendEventToWebhookJob(user, event)
sink = ConsoleEventSink()
assert job.run(sink)

View File

@ -25,15 +25,17 @@ class MockProtonClient(ProtonClient):
return self.user
def check_initial_sync_job(user: User):
def check_initial_sync_job(user: User, expected: bool):
found = False
for job in Job.yield_per_query(10).filter_by(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
state=JobState.ready.value,
):
if job.payload.get("user_id") == user.id:
found = True
Job.delete(job.id)
return
assert False
break
assert expected == found
def test_proton_callback_handler_unexistant_sl_user():
@ -69,10 +71,9 @@ def test_proton_callback_handler_unexistant_sl_user():
)
assert partner_user is not None
assert partner_user.external_user_id == external_id
check_initial_sync_job(res.user)
def test_proton_callback_handler_existant_sl_user():
def test_proton_callback_handler_existing_sl_user():
email = random_email()
sl_user = User.create(email, commit=True)
@ -98,7 +99,43 @@ def test_proton_callback_handler_existant_sl_user():
sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id)
assert sa is not None
assert sa.partner_email == user.email
check_initial_sync_job(res.user)
check_initial_sync_job(res.user, True)
def test_proton_callback_handler_linked_sl_user():
email = random_email()
external_id = random_string()
sl_user = User.create(email, commit=True)
PartnerUser.create(
user_id=sl_user.id,
partner_id=get_proton_partner().id,
external_user_id=external_id,
partner_email=email,
commit=True,
)
user = UserInformation(
email=email,
name=random_string(),
id=external_id,
plan=SLPlan(type=SLPlanType.Premium, expiration=Arrow.utcnow().shift(hours=2)),
)
handler = ProtonCallbackHandler(MockProtonClient(user=user))
res = handler.handle_login(get_proton_partner())
assert res.user is not None
assert res.user.id == sl_user.id
# Ensure the user is not marked as created from partner
assert User.FLAG_CREATED_FROM_PARTNER != (
res.user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert res.user.notification is True
assert res.user.trial_end is not None
sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id)
assert sa is not None
assert sa.partner_email == user.email
check_initial_sync_job(res.user, False)
def test_proton_callback_handler_none_user_login():

View File

@ -286,6 +286,15 @@ def test_verify_other_users_mailbox():
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
def test_verify_other_users_already_verified_mailbox():
other = create_new_user()
mailbox = Mailbox.create(
user_id=other.id, email=random_email(), verified=True, commit=True
)
with pytest.raises(mailbox_utils.MailboxError):
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
@mail_sender.store_emails_test_decorator
def test_verify_fail():
output = mailbox_utils.create_mailbox(user, random_email())
@ -305,10 +314,13 @@ def test_verify_too_may():
output = mailbox_utils.create_mailbox(user, random_email())
output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES
Session.commit()
with pytest.raises(mailbox_utils.CannotVerifyError):
try:
mailbox_utils.verify_mailbox_code(
user, output.mailbox.id, output.activation.code
)
assert False
except mailbox_utils.CannotVerifyError as e:
assert e.deleted_activation_code
@mail_sender.store_emails_test_decorator