Skip to content

Commit

Permalink
Merge pull request #364 from TeskaLabs/feature/password-link-in-response
Browse files Browse the repository at this point in the history
Invitation URL in response, resending invitations
  • Loading branch information
byewokko authored Apr 25, 2024
2 parents b3dd8be + 27c0d23 commit 55c96d0
Show file tree
Hide file tree
Showing 12 changed files with 357 additions and 243 deletions.
22 changes: 18 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# CHANGELOG

## v24.17

### Pre-releases
- `v24.17-alpha1`

### Fix
- Fix the initialization and updating of built-in resources (#363, `v24.06-alpha15`)
- Fix searching credentials with multiple filters (#362, `v24.06-alpha14`)

### Features
- When invitation cannot be created because the user already exists, the invitation is re-sent (#364, `v24.17-alpha1`)
- When no communication channel is configured, invitation and password reset URLs are returned in admin responses (#364, `v24.17-alpha1`)
- Listing assigned tenants and roles no longer requires resource authorization (#348, `v24.06-alpha13`)
- List credentials from authorized tenant only (#348, `v24.06-alpha13`)

---


## v24.06

### Pre-releases
Expand Down Expand Up @@ -27,8 +45,6 @@
- Disable special characters in tenant ID (#349, `v24.06-alpha6`)

### Fix
- Fix the initialization and updating of built-in resources (#363, `v24.06-alpha15`)
- Fix searching credentials with multiple filters (#362, `v24.06-alpha14`)
- Better TOTP error responses (#352, `v24.06-alpha10`)
- Fix resource editability (#355, `v24.06-alpha9`)
- Make FIDO MDS request non-blocking using TaskService (#354, `v24.06-alpha8`)
Expand All @@ -37,8 +53,6 @@
- Fix the initialization of NoTenantsError (#346, `v24.06-alpha2`)

### Features
- Listing assigned tenants and roles no longer requires resource authorization (#348, `v24.06-alpha13`)
- List credentials from authorized tenant only (#348, `v24.06-alpha13`)
- Client cache (#361, `v24.06-alpha13`)
- Update OpenAPI specs (#360, `v24.06-alpha12`)
- Client secret management (#359, `v24.06-alpha11`)
Expand Down
11 changes: 8 additions & 3 deletions seacatauth/authn/login_factors/smscode.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class SMSCodeFactor(LoginFactorABC):
Type = "smscode"

async def is_eligible(self, login_data) -> bool:
if not self.AuthenticationService.CommunicationService.is_enabled("sms"):
# SMS provider is not configured
return False

cred_svc = self.AuthenticationService.CredentialsService
cred_id = login_data["credentials_id"]
if cred_id == "":
Expand Down Expand Up @@ -52,9 +56,10 @@ async def send_otp(self, login_session) -> bool:

# Send SMS
comm_svc = self.AuthenticationService.CommunicationService
success = await comm_svc.sms_login(phone=phone, otp=token)
if not success:
L.error("Unable to send SMS login code.", struct_data={
try:
await comm_svc.sms_login(credentials=credentials, otp=token)
except Exception as e:
L.error("Unable to send SMS login code: {}".format(e), struct_data={
"cid": login_session.SeacatLogin.CredentialsId,
"lsid": login_session.Id,
"phone": phone,
Expand Down
2 changes: 1 addition & 1 deletion seacatauth/communication/email_smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,4 @@ async def send_message(self, *, sender, to, subject, message_body, text_type="ht
)
L.log(asab.LOG_NOTICE, "Email sent", struct_data={'result': result[1]})

return True
return
213 changes: 69 additions & 144 deletions seacatauth/communication/service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import logging
import re
import typing

import asab
import urllib.parse

from .abc import CommunicationProviderABC
from .builders import MessageBuilderABC
from .. import exceptions

#

Expand Down Expand Up @@ -86,177 +86,102 @@ def __init__(self, app, service_name="seacatauth.CommunicationService"):
else:
L.error("Unsupported communication provider: '{}'".format(provider_id))

if len(self.CommunicationProviders) == 0:
L.error("No communication provider configured.")

def is_enabled(self, channel=None):
if not channel:
return len(self.CommunicationProviders) > 0
else:
return channel in self.CommunicationProviders

def get_communication_provider(self, channel):
provider = self.CommunicationProviders.get(channel)
if provider is None:
raise KeyError("No communication provider for '{}' channel configured.".format(channel))
return provider


def get_message_builder(self, channel):
builder = self.MessageBuilders.get(channel)
if builder is None:
raise KeyError("No message builder for '{}' channel configured.".format(channel))
return builder

def parse_channels(self, channels):
# TODO: proper bool operator handling:
# if any channel of the or_group returns True (=successful send), break the loop
# Maybe this could be an iterative decorator, e.g.
# ```
# @communication.channels("email&sms")
# async def password_reset(...)
# ```
"""
Channels will be a string of channel names joined by | and &.
It is evaluated in a boolean-like manner from left to right.
E.g. The string "sms&slack|email&push" commands the service to send an SMS AND a Slack message.
If either fails, it shall both send an email AND a push notification.
"""
if re.match(r"^[\w&|]+$", channels) is None:
raise ValueError("Channel string '{}' contains invalid characters.".format(channels))
for or_group in channels.split("|"):
for channel in or_group.split("&"):
yield channel

async def password_reset(
self, *,
phone=None, email=None, locale=None, username=None, reset_url=None, welcome=False
):
channels = "email&sms"
if welcome is True:
message_id = "password_reset_welcome"
else:
message_id = "password_reset"
locale = locale or self.DefaultLocale

async def password_reset(self, *, credentials, reset_url, welcome=False):
if not self.is_enabled():
raise exceptions.CommunicationNotConfiguredError()
channels = ["email", "sms"]
template_id = "password_reset_welcome" if welcome else "password_reset"
success = []
for channel in self.parse_channels(channels):
try:
provider = self.get_communication_provider(channel)
message_builder = self.get_message_builder(channel)
except KeyError as e:
L.error("Cannot send {} message: {}".format(channel, e))
continue

# Template provider produces a message object with "message_body"
# and other attributes characteristic for the channel
for channel in ["email", "sms"]:
try:
message_dict = message_builder.build_message(
template_name=message_id,
locale=locale,
phone=phone,
email=email,
username=username,
await self.build_and_send_message(
template_id=template_id,
channel=channel,
email=credentials.get("email"),
phone=credentials.get("phone"),
username=credentials.get("username"),
reset_url=reset_url,
app_name=self.AppName
)
except Exception as e:
# TODO: custom errors: MessageBuild(CommunicationError)
L.error("Message build failed: {} ({})".format(type(e).__name__, str(e)))
success.append(channel)
continue
except exceptions.MessageDeliveryError:
L.error("Failed to send message via specified channel.", struct_data={
"channel": channel, "template": template_id, "cid": credentials["_id"]})
continue

# Communication provider sends the message
try:
status = await provider.send_message(**message_dict)
success.append(status)
except Exception as e:
# TODO: custom errors: MessageDelivery(CommunicationError)
L.error("Message delivery failed: {} ({})".format(type(e).__name__, str(e)))

# If no channel succeeds to send the message, raise error
# TODO: handle this in the channel iterator once it's implemented
if True not in success:
L.error("Communication failed on all channels.", struct_data={
"channels": channels
})
return False
return True


async def invitation(
self, *,
phone=None, email=None, locale=None, username=None, tenants, registration_uri, expires_at=None
):
channel = "email"
message_id = "invitation"
locale = locale or self.DefaultLocale

success = False
if len(success) == 0:
raise exceptions.MessageDeliveryError(
"Failed to deliver message on all channels.", template_id=template_id, channel=channels)


async def invitation(self, *, credentials, tenants, registration_uri, expires_at=None):
if not self.is_enabled():
raise exceptions.CommunicationNotConfiguredError()
await self.build_and_send_message(
template_id="invitation",
channel="email",
email=credentials.get("email"),
username=credentials.get("username"),
tenants=tenants,
registration_uri=registration_uri,
expires_at=expires_at,
)


async def sms_login(self, *, credentials: dict, otp: str):
if not self.is_enabled():
raise exceptions.CommunicationNotConfiguredError()
await self.build_and_send_message(
template_id="login_otp",
channel="sms",
phone=credentials.get("phone"),
otp=otp
)


async def build_and_send_message(self, template_id, channel, **kwargs):
if not self.is_enabled():
raise exceptions.CommunicationNotConfiguredError()
try:
provider = self.get_communication_provider(channel)
message_builder = self.get_message_builder(channel)
except KeyError as e:
L.error("Cannot send {} message: {}".format(channel, e))
return False
except KeyError:
raise exceptions.MessageDeliveryError("Communication channel not configured.", channel=channel)

# Template provider produces a message object with "message_body"
# and other attributes characteristic for the channel
try:
message_dict = message_builder.build_message(
template_name=message_id,
locale=locale,
phone=phone,
email=email,
username=username,
tenants=tenants,
registration_uri=registration_uri,
expires_at=expires_at,
app_name=self.AppName
template_name=template_id,
locale=self.DefaultLocale,
app_name=self.AppName,
**kwargs
)
except Exception as e:
L.error("Message build failed: {} ({})".format(type(e).__name__, str(e)))
return False
raise exceptions.MessageDeliveryError(
"Failed to build message from template.", template_id=template_id, channel=channel) from e

# Communication provider sends the message
try:
success = success or await provider.send_message(**message_dict)
await provider.send_message(**message_dict)
except Exception as e:
L.error("Message delivery failed: {} ({})".format(type(e).__name__, str(e)))

return True


async def sms_login(
self, *,
phone=None, locale=None, otp=None
):
channels = "sms"
message_id = "login_otp"
locale = locale or self.DefaultLocale

success = []
for channel in self.parse_channels(channels):
try:
provider = self.get_communication_provider(channel)
message_builder = self.get_message_builder(channel)
except KeyError as e:
L.error("Cannot send {} message: {}".format(channel, e))
continue

try:
message_dict = message_builder.build_message(
template_name=message_id,
locale=locale,
phone=phone,
otp=otp,
app_name=self.AppName
)
except Exception as e:
L.error("Message build failed: {} ({})".format(type(e).__name__, str(e)))
continue

try:
status = await provider.send_message(**message_dict)
success.append(status)
except Exception as e:
L.error("Message delivery failed: {} ({})".format(type(e).__name__, str(e)))

if True not in success:
L.error("Communication failed on all channels.", struct_data={
"channels": channels
})
return False
return True
raise exceptions.MessageDeliveryError(
"Failed to deliver message.", template_id=template_id, channel=channel) from e
3 changes: 1 addition & 2 deletions seacatauth/communication/sms_smsbranacz.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async def send_message(self, *, phone, message_body):
asab.LOG_NOTICE, "SMSBrana.cz provider is in mock mode. Message will not be sent.",
struct_data=url_params
)
return True
return

async with aiohttp.ClientSession() as session:
async with session.get(self.URL, params=url_params) as resp:
Expand All @@ -105,4 +105,3 @@ async def send_message(self, *, phone, message_body):
raise RuntimeError("SMS delivery failed.")
else:
L.log(asab.LOG_NOTICE, "SMS sent")
return True
Loading

0 comments on commit 55c96d0

Please sign in to comment.