Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Superuser can explicitly request invitations with link in response #450

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions seacatauth/credentials/change_password/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ async def admin_request_password_reset(self, request, *, json_data):
try:
reset_url = await self.ChangePasswordService.init_password_reset_by_admin(
credentials,
expiration=json_data.get("expiration")
link_output=json_data.get("password_reset_link", False),
expiration=json_data.get("expiration"),
)
except exceptions.CredentialsNotFoundError:
L.log(asab.LOG_NOTICE, "Password reset denied: Credentials not found", struct_data={
Expand All @@ -217,8 +218,6 @@ async def admin_request_password_reset(self, request, *, json_data):

response_data = {"result": "OK"}
if reset_url:
# Password reset URL was not sent because CommunicationService is disabled
# Add the URL to admin response
response_data["reset_url"] = reset_url

return asab.web.rest.json_response(request, response_data)
Expand Down
38 changes: 24 additions & 14 deletions seacatauth/credentials/change_password/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import logging
import datetime
import re
import typing

import asab
import asab.exceptions

from ... import exceptions
from ... import exceptions, generic
from ...generic import generate_ergonomic_token
from ...events import EventTypes

Expand Down Expand Up @@ -123,34 +125,42 @@ async def password_policy(self) -> dict:
async def init_password_reset_by_admin(
self,
credentials: dict,
link_output: typing.Optional[str] = None,
is_new_user: bool = False,
expiration: float = None,
):
"""
Create a password reset link and send it to the user via email or other way
"""
session_ctx = generic.SessionContext.get()
if link_output == "response":
if not session_ctx.is_superuser():
raise exceptions.AccessDeniedError("Not allowed to receive password reset URL in response.")

# Deny password reset to suspended credentials
if credentials.get("suspended") is True:
raise exceptions.CredentialsSuspendedError(credentials["_id"])

password_reset_token = await self.create_password_reset_token(credentials, expiration=expiration)
reset_url = self.format_password_reset_url(password_reset_token)

if not self.CommunicationService.is_enabled():
if link_output == "email":
# Send the message
try:
await self.CommunicationService.password_reset(
credentials=credentials,
reset_url=reset_url,
new_user=is_new_user
)
L.log(asab.LOG_NOTICE, "Password reset message sent.", struct_data={"cid": credentials["_id"]})
except exceptions.MessageDeliveryError as e:
raise e

elif link_output == "response":
return reset_url

# Send the message
try:
await self.CommunicationService.password_reset(
credentials=credentials,
reset_url=reset_url,
new_user=is_new_user
)
L.log(asab.LOG_NOTICE, "Password reset message sent.", struct_data={"cid": credentials["_id"]})
except exceptions.MessageDeliveryError as e:
raise e

return None
else:
return None


async def init_lost_password_reset(self, credentials: dict):
Expand Down
12 changes: 8 additions & 4 deletions seacatauth/credentials/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,12 @@ async def create_credentials(self, request, *, json_data):
"""
Create new credentials
"""
password_link = json_data.pop("passwordlink", False)
if "passwordlink" in json_data:
link_output = json_data.pop("passwordlink") and "email"
elif "password_reset_link" in json_data:
link_output = json_data.pop("password_reset_link")
else:
link_output = False

provider_id = request.match_info["provider"]
provider = self.CredentialsService.CredentialProviders[provider_id]
Expand All @@ -297,13 +302,14 @@ async def create_credentials(self, request, *, json_data):
"_provider_id": provider.ProviderID
}

if password_link:
if link_output:
change_pwd_svc = self.SessionService.App.get_service("seacatauth.ChangePasswordService")
credentials = await self.CredentialsService.get(credentials_id)
try:
reset_url = await change_pwd_svc.init_password_reset_by_admin(
credentials,
expiration=json_data.get("expiration"),
link_output=link_output,
is_new_user=True,
)
except exceptions.CredentialsNotFoundError:
Expand All @@ -319,8 +325,6 @@ async def create_credentials(self, request, *, json_data):
return asab.web.rest.json_response(request, {"result": "FAILED"}, status=500)

if reset_url:
# Password reset URL was not sent because CommunicationService is disabled
# Add the URL to admin response
response_data["reset_url"] = reset_url

return asab.web.rest.json_response(request, response_data)
Expand Down
136 changes: 82 additions & 54 deletions seacatauth/credentials/registration/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import asab.utils
import asab.exceptions

from ... import generic
from ...models.const import ResourceId
from ...decorators import access_control
from .. import schema
Expand Down Expand Up @@ -72,19 +73,12 @@ async def public_create_invitation(self, request, *, tenant, credentials_id, jso

credential_data = {"email": json_data.get("email")}

response_data = {"result": "OK"}

# Prepare credentials, assign tenant and send invitation email
invited_credentials_id, registration_url = await self._prepare_invitation(
tenant, credential_data, expiration, access_ips,
return await self._prepare_invitation(
request, tenant, credential_data, expiration, access_ips,
link_output=json_data.get("invitation_link"),
invited_by_cid=credentials_id
)
if registration_url:
L.log(asab.LOG_NOTICE, "Including invitation URL in REST response.", struct_data={
"cid": invited_credentials_id, "requested_by": credentials_id})
response_data["registration_url"] = registration_url

return asab.web.rest.json_response(request, response_data)


@asab.web.rest.json_schema_handler(schema.CREATE_INVITATION_ADMIN)
Expand All @@ -109,43 +103,53 @@ async def admin_create_invitation(self, request, *, tenant, credentials_id, json
credential_data = json_data["credentials"]

# Prepare credentials and assign tenant
invited_credentials_id, registration_url = await self._prepare_invitation(
tenant, credential_data, expiration, access_ips,
return await self._prepare_invitation(
request, tenant, credential_data, expiration, access_ips,
link_output=json_data.get("invitation_link"),
invited_by_cid=credentials_id
)

response_data = {
"result": "OK",
"credentials_id": invited_credentials_id,
}
if registration_url:
# URL was not sent because CommunicationService is disabled
# Add the URL to admin response
L.log(asab.LOG_NOTICE, "Including invitation URL in REST response.", struct_data={
"cid": invited_credentials_id, "requested_by": credentials_id})
response_data["registration_url"] = registration_url

return asab.web.rest.json_response(request, response_data)


async def _prepare_invitation(
self,
request: aiohttp.web.Request,
tenant: str,
credential_data: dict,
expiration: float,
access_ips: list,
invited_by_cid: typing.Optional[str]
):
link_output: typing.Optional[str] = None,
invited_by_cid: typing.Optional[str] = None,
) -> aiohttp.web.Response:
"""
Prepare credentials for registration. Either create a new set of credentials, or locate the existing one.

@param tenant: Tenant to invite into
@param credential_data: Username, email address and/or phone number
@param expiration: Invitation expiration in seconds
@param access_ips: Source IPs of the invitation request
@param invited_by_cid: Credentials ID of the invitation request
@return:
Args:
tenant: Tenant to invite into
credential_data: Username, email address and/or phone number
expiration: Invitation expiration in seconds
access_ips: Source IPs of the invitation request
invited_by_cid: Credentials ID of the invitation request
"""
session_ctx = generic.SessionContext.get()
link_output = link_output or "email"

# Ensure there is a way to communicate the invitation link
# TODO: Refactor, de-duplicate
if link_output == "email":
if not self.RegistrationService.CommunicationService.is_enabled():
return asab.web.rest.json_response(request, status=400, data={
"result": "ERROR",
"tech_err": "Sending emails is not supported."
})
elif link_output == "response":
if not session_ctx.is_superuser():
return asab.web.rest.json_response(request, status=403, data={
"result": "ERROR",
"tech_err": "Not allowed to receive invitation link in response."
})
else:
raise asab.exceptions.ValidationError("Unsupported 'link_output' value: {!r}".format(link_output))

# Prepare credentials and registration code
registration_code = None
try:
Expand Down Expand Up @@ -178,33 +182,52 @@ async def _prepare_invitation(
except asab.exceptions.Conflict:
L.log(asab.LOG_NOTICE, "Tenant already assigned.", struct_data={"cid": credentials_id, "t": tenant})

# Re/send invitation email
response_data = {
"result": "OK",
"credentials_id": credentials_id,
}

# (Re)send invitation email
if registration_code:
registration_uri = self.RegistrationService.format_registration_uri(registration_code)
if self.RegistrationService.CommunicationService.is_enabled():
registration_url = self.RegistrationService.format_registration_uri(registration_code)
if link_output == "email":
await self.RegistrationService.CommunicationService.invitation(
credentials=credential_data,
registration_uri=registration_uri,
registration_uri=registration_url,
tenants=[tenant],
expires_at=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=expiration)
)
return credentials_id, None
else:
L.log(
asab.LOG_NOTICE,
"Cannot send invitation message: Communication service is disabled.",
struct_data={"cid": credentials_id}
)
return credentials_id, registration_uri

return credentials_id, None
elif link_output == "response":
response_data["registration_url"] = registration_url

return asab.web.rest.json_response(request, response_data)


@access_control(ResourceId.TENANT_ASSIGN)
async def resend_invitation(self, request):
"""
Resend invitation to an already invited user and extend the expiration of the invitation.
"""
session_ctx = generic.SessionContext.get()
link_output = request.match_info.get("link_output", "email")
# Ensure there is a way to communicate the invitation link
# TODO: Refactor, de-duplicate
if link_output == "email":
if not self.RegistrationService.CommunicationService.is_enabled():
return asab.web.rest.json_response(request, status=400, data={
"result": "ERROR",
"tech_err": "Sending emails is not supported."
})
elif link_output == "response":
if not session_ctx.is_superuser():
return asab.web.rest.json_response(request, status=403, data={
"result": "ERROR",
"tech_err": "Not allowed to receive invitation link in response."
})
else:
raise asab.exceptions.ValidationError("Unsupported 'link_output' value: {!r}".format(link_output))

credentials_id = request.match_info["credentials_id"]
credentials = await self.CredentialsService.get(credentials_id, include=["__registration"])

Expand All @@ -222,16 +245,21 @@ async def resend_invitation(self, request):
else:
expiration = credentials["__registration"]["exp"]

tenants = await self.RegistrationService.TenantService.get_tenants(credentials_id)
response_data = {"result": "OK"}
registration_url = self.RegistrationService.format_registration_uri(credentials["__registration"]["code"])
if link_output == "email":
tenants = await self.RegistrationService.TenantService.get_tenants(credentials_id)
await self.RegistrationService.CommunicationService.invitation(
credentials=credentials,
registration_uri=registration_url,
tenants=tenants,
expires_at=expiration,
)

await self.RegistrationService.CommunicationService.invitation(
credentials=credentials,
tenants=tenants,
registration_uri=self.RegistrationService.format_registration_uri(credentials["__registration"]["code"]),
expires_at=expiration,
)
elif link_output == "response":
response_data["registration_url"] = registration_url

return asab.web.rest.json_response(request, {"result": "OK"})
return asab.web.rest.json_response(request, response_data)


@asab.web.rest.json_schema_handler(schema.REQUEST_SELF_INVITATION)
Expand Down
Loading