Skip to content

Commit 8c96fd2

Browse files
committed
mycodeplug/mail.py: send OTP token via mailgun
if MG_TOKEN and MG_DOMAIN are not in the environ then fall back to the "print to console" method of delivering the OTP from the user module (now called "local_otp_delivery").
1 parent 5a8e6e2 commit 8c96fd2

4 files changed

Lines changed: 89 additions & 16 deletions

File tree

src/mycodeplug/api.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import importlib.metadata
2-
import logging
32
import os
43
from typing import Optional
54

65
from fastapi import Depends, FastAPI, HTTPException, Request
76
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
87
from pydantic import BaseModel
98

9+
from .logging import getLogger
10+
from .mail import otp_delivery
1011
from .user import (
1112
AuthenticationError,
1213
EditableUser,
1314
get_current_active,
14-
otp_delivery,
1515
Token,
1616
UnknownUser,
1717
User,
@@ -20,13 +20,7 @@
2020
APP_NAME = "mycodeplug"
2121

2222
app = FastAPI()
23-
24-
# set logging based on MYCODEPLUG_LOGLEVEL
25-
app_loglevel = getattr(logging, os.environ.get("MYCODEPLUG_LOGLEVEL", "INFO").upper())
26-
uvicorn_logger = logging.getLogger("uvicorn")
27-
logger = logging.getLogger(APP_NAME)
28-
logger.setLevel(app_loglevel)
29-
logger.handlers = uvicorn_logger.handlers
23+
logger = getLogger(APP_NAME)
3024

3125

3226
class RootData(BaseModel):
@@ -47,7 +41,7 @@ async def root() -> RootData:
4741

4842

4943
@app.post("/login")
50-
async def login(email: str, request: Request, deliver = Depends(otp_delivery)):
44+
def login(email: str, request: Request, deliver = Depends(otp_delivery)):
5145
"""
5246
Trigger a login request for the given email address.
5347
@@ -92,7 +86,7 @@ def _token(email: str, otp: str, request: Request) -> Token:
9286

9387

9488
@app.post("/token", response_model=Token)
95-
async def token(
89+
def token(
9690
request: Request, form_data: OAuth2PasswordRequestForm = Depends()
9791
) -> Token:
9892
"""
@@ -106,7 +100,7 @@ async def token(
106100

107101

108102
@app.get("/magic/{email}/{otp}", response_model=Token)
109-
async def magic(email: str, otp: str, request: Request) -> Token:
103+
def magic(email: str, otp: str, request: Request) -> Token:
110104
"""
111105
Magic link login: XXX: Needs to be a JS application to save
112106
the token client side.
@@ -131,7 +125,13 @@ async def get_users_me(current_user: User = Depends(get_current_active)) -> User
131125

132126

133127
@app.post("/users/me")
134-
async def post_users_me(data: EditableUser, request: Request, otp: Optional[str] = None, current_user: User = Depends(get_current_active), deliver = Depends(otp_delivery)):
128+
def post_users_me(
129+
data: EditableUser,
130+
request: Request,
131+
otp: Optional[str] = None,
132+
current_user: User = Depends(get_current_active),
133+
deliver = Depends(otp_delivery),
134+
):
135135
updated_settings = data.dict(exclude_none=True, exclude_unset=True, exclude_defaults=True)
136136
if "email" in updated_settings:
137137
if otp is not None:

src/mycodeplug/logging.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import os
2+
import logging
3+
4+
# set logging based on MYCODEPLUG_LOGLEVEL
5+
app_loglevel = getattr(logging, os.environ.get("MYCODEPLUG_LOGLEVEL", "INFO").upper())
6+
uvicorn_logger = logging.getLogger("uvicorn")
7+
8+
9+
def getLogger(*args, **kwargs) -> logging.Logger:
10+
logger = logging.getLogger(*args, **kwargs)
11+
logger.setLevel(app_loglevel)
12+
logger.handlers = uvicorn_logger.handlers
13+
return logger

src/mycodeplug/mail.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
Handle email in and out of the application
3+
"""
4+
import os
5+
from urllib.parse import urljoin
6+
7+
import httpx
8+
9+
from .logging import getLogger
10+
from .user import User
11+
12+
13+
BASE_URL = os.environ.get("BASE_URL")
14+
DOMAIN = os.environ.get("MG_DOMAIN")
15+
TOKEN = os.environ.get("MG_TOKEN")
16+
MG_API = f"https://api.mailgun.net/v3/{DOMAIN}/messages"
17+
FROM = "MyCodeplug.com <service@mycodeplug.com>"
18+
OTP_MESSAGE = {
19+
"from": FROM,
20+
"subject": "Click this link to login",
21+
"text": """{user},
22+
Your one-time password is {otp}. This password expires in 5 minutes.
23+
24+
Use the following link to login: {link}
25+
26+
-MyCodeplug
27+
"""
28+
}
29+
30+
logger = getLogger(__name__)
31+
32+
33+
def otp_delivery():
34+
"""
35+
:return: callable accepting a User and otp string, arranging for it to be sent to the user
36+
"""
37+
def deliver(user: User, otp: str):
38+
data = OTP_MESSAGE.copy()
39+
data["to"] = user.email
40+
data["text"] = data["text"].format(
41+
user=user.name or user.email,
42+
otp=otp,
43+
link=urljoin(BASE_URL, "/magic/{}/{}".format(user.email, otp)),
44+
)
45+
httpx.post(
46+
MG_API,
47+
data=data,
48+
auth=("api", TOKEN)
49+
).raise_for_status()
50+
return {"detail": "new OTP sent"}
51+
52+
return deliver
53+
54+
55+
if None in (TOKEN, DOMAIN):
56+
logger.warning(
57+
"Mailgun token and/or domain not available in environment. "
58+
"Falling back to local/weak OTP delivery",
59+
)
60+
from .user import local_otp_delivery as otp_delivery

src/mycodeplug/user.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ async def get_token(jwt_raw: str = Depends(oauth2_scheme)) -> TokenData:
328328
return TokenData.from_jwt(jwt_raw)
329329

330330

331-
async def get_current(token_data: TokenData = Depends(get_token)) -> User:
331+
def get_current(token_data: TokenData = Depends(get_token)) -> User:
332332
"""
333333
Fetch the User from the oauth session token.
334334
@@ -338,7 +338,7 @@ async def get_current(token_data: TokenData = Depends(get_token)) -> User:
338338
return User.from_token(token_data)
339339

340340

341-
async def get_current_active(current: User = Depends(get_current)) -> User:
341+
def get_current_active(current: User = Depends(get_current)) -> User:
342342
"""
343343
Provide an enabled User from the oauth session token (or raise HTTP 400).
344344
@@ -350,7 +350,7 @@ async def get_current_active(current: User = Depends(get_current)) -> User:
350350
return current
351351

352352

353-
def otp_delivery():
353+
def local_otp_delivery():
354354
"""
355355
:return: callable accepting a User and otp string, arranging for it to be sent to the user
356356
"""

0 commit comments

Comments
 (0)