Skip to content

Commit

Permalink
Configure 2fa
Browse files Browse the repository at this point in the history
  • Loading branch information
vesnushka committed Oct 7, 2024
1 parent 575ffe8 commit 3086695
Show file tree
Hide file tree
Showing 28 changed files with 1,767 additions and 3 deletions.
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "aidbox-2fa"]
path = aidbox-2fa
url = [email protected]:beda-software/aidbox-2fa.git
1 change: 1 addition & 0 deletions aidbox-2fa
Submodule aidbox-2fa added at 88ac31
18 changes: 16 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
services:
backend:
build: ../ucf-app
aidbox-2fa:
build: ./aidbox-2fa
depends_on:
devbox-healthcheck:
condition: service_healthy
links:
- devbox
env_file:
- ./env/aidbox-2fa
tty: true
volumes:
- ./aidbox-2fa:/app
ucf-app:
build: ./ucf-app
depends_on:
devbox-healthcheck:
condition: service_healthy
Expand All @@ -9,6 +21,8 @@ services:
env_file:
- ./env/ucf-app
tty: true
volumes:
- ./ucf-app:/app
sdc-ide:
image: bedasoftware/sdc-ide:master
depends_on:
Expand Down
5 changes: 5 additions & 0 deletions env/aidbox
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ BOX_FEATURES_FTR_PULL_ENABLE=false
AIDBOX_SDC_ENABLED=false

UCF_APP_CLIENT_SECRET=secret

TWO_FACTOR_ISSUER_NAME=MammoChat
TWO_FACTOR_VALID_PAST_TOKENS_COUNT=5
TWO_FACTOR_WEBHOOK_URL="http://devbox:8080/webhook/two-factor-confirmation"
TWO_FACTOR_WEBHOOK_AUTHORIZATION="Basic cm9vdDpzZWNyZXQ=" # root:secret
16 changes: 16 additions & 0 deletions env/aidbox-2fa
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
APP_INIT_CLIENT_ID=root
APP_INIT_CLIENT_SECRET=secret
APP_INIT_URL=http://devbox:8080

APP_ID=aidbox-2fa-app
APP_SECRET=secret

APP_URL=http://aidbox-2fa:8081
APP_PORT=8081
AIO_PORT=8081
AIO_HOST=0.0.0.0

TWO_FACTOR_ISSUER_NAME=MammoChat
TWO_FACTOR_VALID_PAST_TOKENS_COUNT=5
TWO_FACTOR_WEBHOOK_URL="http://devbox:8080/webhook/two-factor-confirmation"
TWO_FACTOR_WEBHOOK_AUTHORIZATION="Basic cm9vdDpzZWNyZXQ=" # root:secret
2 changes: 1 addition & 1 deletion env/ucf-app
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ APP_INIT_URL=http://devbox:8080
APP_ID=ucf-app
APP_SECRET=secret

APP_URL=http://backend:8081
APP_URL=http://ucf-app:8081
APP_PORT=8081
AIO_PORT=8081
AIO_HOST=0.0.0.0
9 changes: 9 additions & 0 deletions resources/seeds/NotificationTemplate/email-layout.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
template: |-
<style type="text/css">
</style>
<div>
{{ body }}
</div>
id: email-layout

6 changes: 6 additions & 0 deletions resources/seeds/NotificationTemplate/reset-user-password.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
subject: Reset password
template: |-
<p>Dear {{user.name.givenName}},<br />
To reset your password click this </p>
<a href="{{confirm-href}}">link</a>
id: reset-user-password
5 changes: 5 additions & 0 deletions resources/seeds/NotificationTemplate/verify-two-factor.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
subject: Verify two factor
template: |-
<p>Dear {{user.name.givenName}},<br />
Your code is {{token}} </p>
id: verify-two-factor
1 change: 1 addition & 0 deletions ucf-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
17 changes: 17 additions & 0 deletions ucf-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM python:3.12

RUN addgroup --gid 1000 dockeruser
RUN adduser --disabled-login --uid 1000 --gid 1000 dockeruser
RUN mkdir -p /app/
RUN chown -R dockeruser:dockeruser /app/

RUN pip install poetry

USER dockeruser

COPY . /app
WORKDIR /app

RUN poetry install

CMD ["poetry", "run", "gunicorn", "app.main:application", "--bind", "0.0.0.0:8081", "--worker-class", "aiohttp.GunicornWebWorker", "--reload"]
Empty file added ucf-app/README.md
Empty file.
Empty file added ucf-app/app/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions ucf-app/app/aidbox/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import app.aidbox.operations
import app.aidbox.subscriptions
import app.aidbox.notification
import app.aidbox.two_factor

from .sdk import sdk
158 changes: 158 additions & 0 deletions ucf-app/app/aidbox/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import logging

from aidbox_python_sdk.aidboxpy import AsyncAidboxClient
from jinja2 import Environment, Undefined
from jinja2.ext import Extension
from premailer import transform

from app.config import emr as emr_config

async def send_email(
client: AsyncAidboxClient, to, template_id, payload, attachments=None, *, save=True
):
notification = client.resource(
"Notification",
provider=emr_config.EMAIL_PROVIDER,
providerData={
"fromApp": True,
"type": "email",
"to": to,
"payload": payload,
"template": {"resourceType": "NotificationTemplate", "id": template_id},
"attachments": attachments or [],
},
)
if save:
await notification.save()
return notification


class SilentUndefined(Undefined):
def _fail_with_undefined_error(self, *args, **kwargs): # type: ignore
return None


class RenderBlocksExtension(Extension):
def __init__(self, environment):
super().__init__(environment)
environment.extend(render_blocks=[])

def filter_stream(self, stream):
block_level = 0
skip_level = 0
in_endblock = False

for token in stream:
if token.type == "block_begin":
if stream.current.value == "block":
block_level += 1
if stream.look().value not in self.environment.render_blocks: # type: ignore
skip_level = block_level

if token.value == "endblock":
in_endblock = True

if skip_level == 0:
yield token

if token.type == "block_end":
if in_endblock:
in_endblock = False
block_level -= 1

if skip_level == block_level + 1:
skip_level = 0


jinja_env = Environment(undefined=SilentUndefined)

jinja_subject_env = Environment(undefined=SilentUndefined, extensions=[RenderBlocksExtension])
jinja_subject_env.render_blocks = ["subject"] # type: ignore

jinja_body_env = Environment(undefined=SilentUndefined, extensions=[RenderBlocksExtension])
jinja_body_env.render_blocks = ["body"] # type: ignore


class SendNotificationExceptionError(Exception):
pass


async def send_console(to, subject, body, attachments=None):
logging.info(
"New notification:\nTo: %s\nSubject: %s\n%s\nattachments: %s",
to,
subject,
body,
attachments or [],
)


providers = {
"console": send_console,
}


async def notification_sub(app, action, resource, _previous_resource):
sdk_settings = app["settings"]
client = app["client"]
if action == "create":
provider_data = resource["providerData"]

# Skip processing for non-app notifications
if not provider_data.get("fromApp"):
return

notification_type = provider_data.get("type")
if notification_type == "email":
payload = {
**provider_data.get("payload", {}),
"frontend_url": sdk_settings.FRONTEND_URL,
# "backend_url": sdk_settings.backend_public_url,
# "current_date": format_fhir_date(get_now()),
}

template = await provider_data["template"].to_resource()
subject_template = jinja_subject_env.from_string(template["subject"])
body_template = jinja_body_env.from_string(template["template"])
subject = subject_template.render(payload)
body = body_template.render(payload)
layout = await client.resources("NotificationTemplate").get(id="email-layout")
body = transform(
jinja_env.from_string(layout["template"]).render({"body": body, **payload})
)
props = {
"subject": subject.strip(),
"body": body.strip(),
"to": provider_data["to"],
}
if "attachments" in provider_data:
props["attachments"] = provider_data["attachments"]
elif notification_type == "sms":
props = {
"to": provider_data["to"],
"body": provider_data["body"].strip(),
}
else:
raise Exception("Notification type `%s` is not supported", notification_type)

provider = resource["provider"]

send_fn = providers.get(provider)
if not send_fn:
logging.warning("Unhandled notification for provider %s", provider)
return

try:
await send_fn(**props) # type: ignore

resource["status"] = "delivered"
await resource.save()
except SendNotificationExceptionError as exc:
logging.debug(exc)
resource["status"] = "error"
await resource.save()
except Exception as exc:
logging.exception(exc)
resource["status"] = "failure"
await resource.save()
raise
Empty file.
5 changes: 5 additions & 0 deletions ucf-app/app/aidbox/sdk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from aidbox_python_sdk.sdk import SDK

from .settings import settings

sdk = SDK(settings)
17 changes: 17 additions & 0 deletions ucf-app/app/aidbox/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from aidbox_python_sdk.settings import Required
from aidbox_python_sdk.settings import Settings as AidboxSettings

from app.config import aidbox, emr


class Settings(AidboxSettings):
APP_ID = Required(v_type=str)
FRONTEND_URL = Required(v_type=str)
APP_INIT_URL = Required(v_type=str)


settings = Settings(
APP_ID=aidbox.APP_ID,
FRONTEND_URL=emr.FRONTEND_URL,
APP_INIT_URL=aidbox.APP_INIT_URL,
)
42 changes: 42 additions & 0 deletions ucf-app/app/aidbox/subscriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import uuid

from app.aidbox.sdk import sdk
from app.config.emr import FRONTEND_URL
from app.aidbox.notification import notification_sub

@sdk.subscription("User")
async def user_created(event, request):
aidbox = request.app["client"]
if event["action"] == "create":
user = aidbox.resource("User", **event["resource"])
if user["data"].get("resetPassword", False):
reset_token = uuid.uuid4()
user["data"]["reset_token"] = str(reset_token)
await user.save()
notification = aidbox.resource(
"Notification",
**{
"provider": "smtp-provider",
"providerData": {
"to": user["email"],
"subject": "Password reset",
"template": {
"id": "reset-user-password",
"resourceType": "NotificationTemplate",
},
"payload": {
"user": user.serialize(),
"confirm-href": f"{FRONTEND_URL}/reset-password/{reset_token}",
},
},
},
)
await notification.save()


@sdk.subscription("Notification")
async def notification_handler(event, request):
aidbox = request.app["client"]
notification = aidbox.resource("Notification", **event["resource"])

await notification_sub(request.app, event["action"], notification, None)
29 changes: 29 additions & 0 deletions ucf-app/app/aidbox/two_factor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import logging

from aiohttp import web

from app.aidbox.notification import send_email
from app.aidbox.sdk import sdk

from aidbox_python_sdk.types import SDKOperation, SDKOperationRequest
from aidbox_python_sdk import app_keys as ak
from aidbox_python_sdk.aidboxpy import AsyncAidboxClient, AsyncAidboxResource

@sdk.operation(["POST"], ["webhook", "two-factor-confirmation"])
async def auth_webhook_two_factor_confirmation_op(_operation: SDKOperation, request: SDKOperationRequest):
client = request["app"][ak.client]
user = client.resource("User", **request["resource"]["user"])
token = request["resource"]["token"]
await send_confirmation_token(client, user, token)
return web.json_response({})


async def send_confirmation_token(client: AsyncAidboxClient, user: AsyncAidboxResource, token: str):
logging.info(
"OTP for user {email}: {token}".format(email=user["email"], token=token)
)

await send_email(client, user["email"], "verify-two-factor", {
"token": token,
"user": user.serialize()
})
2 changes: 2 additions & 0 deletions ucf-app/app/aidbox/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def get_error_payload(message, *, code):
return {"error": code, "error_description": message}
Empty file added ucf-app/app/config/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions ucf-app/app/config/aidbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from os import environ

APP_INIT_URL = environ["APP_INIT_URL"]
APP_INIT_CLIENT_ID = environ["APP_INIT_CLIENT_ID"]
APP_INIT_CLIENT_SECRET = environ["APP_INIT_CLIENT_SECRET"]
APP_ID=environ["APP_ID"]
Loading

0 comments on commit 3086695

Please sign in to comment.