Skip to content

Commit d1bbb2f

Browse files
committed
render qrcode and send backup tokens on tfa activation
1 parent 00de177 commit d1bbb2f

File tree

16 files changed

+311
-31
lines changed

16 files changed

+311
-31
lines changed

env/.env

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
FASTAPI_CONFIG=development
22

3+
FAKE_EMAIL_SENDER=[email protected]
4+
35
API_V1_STR=/api/v1
46
PROJECT_NAME=fastapi-2fa
57

@@ -11,8 +13,13 @@ ALGORITHM=HS256
1113
FERNET_KEY_TFA_TOKEN=J_TYpprFmoLlVM0MNZElt8IwEkvEEhAwCmb8P_f7Fro=
1214
TFA_BACKUP_TOKENS_NR=5
1315
TFA_TOKEN_LENGTH=6
16+
# default tolerance = 30 sec
17+
# this number is multiplied for 30 sec to increas it.
18+
# -->MAX = 10 => 5 minutes
19+
TOTP_TOKEN_DURATION=2
20+
TOTP_ISSUER_NAME=fastapi_2fa
1421

15-
ACCESS_TOKEN_EXPIRE_MINUTES=15
22+
ACCESS_TOKEN_EXPIRE_MINUTES=30
1623
REFRESH_TOKEN_EXPIRE_MINUTES=1440 # 24 h
1724
PRE_TFA_TOKEN_EXPIRE_MINUTES=5
1825

fastapi_2fa/api/endpoints/api_v1/auth.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Any
22

33
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
4+
from fastapi.responses import StreamingResponse
45
from fastapi.security import OAuth2PasswordRequestForm
56
from jose import jwt
67
from pydantic import ValidationError
@@ -12,6 +13,9 @@
1213
get_authenticated_user_pre_tfa)
1314
from fastapi_2fa.core import security
1415
from fastapi_2fa.core.config import settings
16+
from fastapi_2fa.core.enums import DeviceTypeEnum
17+
from fastapi_2fa.core.utils import send_backup_tokens
18+
from fastapi_2fa.crud.device import device_crud
1519
from fastapi_2fa.crud.users import user_crud
1620
from fastapi_2fa.models.users import User
1721
from fastapi_2fa.schemas.token_schema import TokenPayload, TokenSchema
@@ -20,20 +24,50 @@
2024
auth_router = APIRouter()
2125

2226

23-
@auth_router.post("/signup", summary="Create user", response_model=UserOut)
27+
@auth_router.post(
28+
"/signup",
29+
summary="Create user",
30+
responses={
31+
200: {
32+
"content": {"image/png": {}},
33+
"description": "Returns no content or a qr code "
34+
"if tfas is enabled and device_type "
35+
"is 'code_generator'",
36+
}
37+
},
38+
)
2439
async def signup(
25-
db: Session = Depends(get_db), form_data: UserCreate = Depends()
40+
db: Session = Depends(get_db), user_data: UserCreate = Depends()
2641
) -> Any:
2742
try:
28-
user = await user_crud.create(
43+
# async with db.begin_nested():
44+
user = await user_crud(transaction=True).create(
2945
db=db,
30-
user=form_data,
46+
user=user_data,
3147
)
32-
return user
48+
# raise Exception
49+
if user_data.tfa_enabled:
50+
device, qr_code = await device_crud(transaction=True).create(
51+
db=db,
52+
device=user_data.device,
53+
user=user
54+
)
55+
send_backup_tokens(user=user, device=device)
56+
57+
if device.device_type == DeviceTypeEnum.CODE_GENERATOR:
58+
return StreamingResponse(content=qr_code, media_type="image/png")
59+
60+
return Response(status_code=status.HTTP_200_OK)
61+
3362
except IntegrityError:
3463
raise HTTPException(
3564
status_code=status.HTTP_400_BAD_REQUEST,
36-
detail=f'User with email "{form_data.email}" already exists',
65+
detail=f'User with email `{user_data.email}` already exists',
66+
)
67+
except Exception as ex:
68+
raise HTTPException(
69+
status_code=status.HTTP_400_BAD_REQUEST,
70+
detail=str(ex),
3771
)
3872

3973

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,59 @@
11
from typing import Any
22

3-
from fastapi import APIRouter, Depends
3+
from fastapi import APIRouter, Depends, HTTPException, Response, status
4+
from fastapi.responses import StreamingResponse
45
from sqlalchemy.orm import Session
56

67
from fastapi_2fa.api.deps.db import get_db
78
from fastapi_2fa.api.deps.users import get_authenticated_user
9+
from fastapi_2fa.core.enums import DeviceTypeEnum
10+
from fastapi_2fa.core.utils import send_backup_tokens
811
from fastapi_2fa.crud.device import device_crud
912
from fastapi_2fa.crud.users import user_crud
1013
from fastapi_2fa.models.users import User
1114
from fastapi_2fa.schemas.device_schema import DeviceCreate
12-
from fastapi_2fa.schemas.user_schema import UserOut, UserUpdate
15+
from fastapi_2fa.schemas.user_schema import UserUpdate
1316

1417
tfa_router = APIRouter()
1518

1619

1720
@tfa_router.put(
1821
"/enable_tfa",
1922
summary="Enable two factor authentication for registered user",
20-
response_model=UserOut,
23+
responses={
24+
200: {
25+
"content": {"image/png": {}},
26+
"description": "Returns no content or a qr code "
27+
"if device_type is 'code_generator'",
28+
}
29+
},
2130
)
2231
async def enable_tfa(
2332
device: DeviceCreate,
2433
db: Session = Depends(get_db),
2534
user: User = Depends(get_authenticated_user),
2635
) -> Any:
27-
if not user_crud.is_tfa_enabled(user):
28-
async with db.begin_nested():
29-
user = await user_crud.update(
30-
db=db,
31-
db_obj=user,
32-
obj_in=UserUpdate(tfa_enabled=True)
33-
)
34-
await device_crud.create(
35-
db=db,
36-
device=device,
37-
user=user
38-
)
39-
await db.refresh(user)
40-
return user
36+
if not user_crud(transaction=True).is_tfa_enabled(user):
37+
user = await user_crud.update(
38+
db=db,
39+
db_obj=user,
40+
obj_in=UserUpdate(tfa_enabled=True)
41+
)
42+
device, qr_code = await device_crud(transaction=True).create(
43+
db=db,
44+
device=device,
45+
user=user
46+
)
47+
48+
send_backup_tokens(user=user, device=device)
49+
50+
if device.device_type == DeviceTypeEnum.CODE_GENERATOR:
51+
return StreamingResponse(content=qr_code, media_type="image/png")
52+
53+
return Response(status_code=status.HTTP_200_OK)
54+
55+
raise HTTPException(
56+
status_code=status.HTTP_400_BAD_REQUEST,
57+
detail='Two factor authentication already '
58+
f'active for user {user.email}'
59+
)

fastapi_2fa/core/config.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from typing import Any, Dict, Optional
44

55
from dotenv import load_dotenv
6-
from pydantic import AnyHttpUrl, BaseSettings, PostgresDsn, validator
6+
from pydantic import (AnyHttpUrl, BaseSettings, EmailStr, Field, PostgresDsn,
7+
validator)
78

89
load_dotenv(dotenv_path="./env/.env")
910

@@ -12,6 +13,8 @@ class BaseConfig(BaseSettings):
1213
API_V1_STR: str = os.environ.get("API_V1_STR")
1314
PROJECT_NAME: str = os.environ.get("PROJECT_NAME")
1415

16+
FAKE_EMAIL_SENDER: EmailStr = os.environ.get("FAKE_EMAIL_SENDER")
17+
1518
JWT_SECRET_KEY: str = os.environ.get("JWT_SECRET_KEY")
1619
JWT_SECRET_KEY_REFRESH: str = os.environ.get("JWT_SECRET_KEY_REFRESH")
1720
PRE_TFA_SECRET_KEY: str = os.environ.get("PRE_TFA_SECRET_KEY")
@@ -21,6 +24,15 @@ class BaseConfig(BaseSettings):
2124

2225
TFA_BACKUP_TOKENS_NR: int = os.environ.get("TFA_BACKUP_TOKENS_NR")
2326
TFA_TOKEN_LENGTH: int = os.environ.get("TFA_TOKEN_LENGTH")
27+
TOTP_ISSUER_NAME: str = os.environ.get("TOTP_ISSUER_NAME")
28+
# default tolerance = 30 sec
29+
# this number is multiplied for 30 sec to increas it.
30+
# -->MAX = 10 => 5 minutes
31+
TOTP_TOKEN_DURATION: int = Field(
32+
default=os.environ.get("TOTP_TOKEN_DURATION", 2),
33+
gt=0,
34+
le=10
35+
)
2436

2537
ACCESS_TOKEN_EXPIRE_MINUTES: int = os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", 15)
2638
REFRESH_TOKEN_EXPIRE_MINUTES: int = os.environ.get(

fastapi_2fa/core/two_factor_auth.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import io
12
import random
23
from typing import Iterator
34

45
import pyotp
6+
import qrcode
57
from fernet import Fernet
68

79
from fastapi_2fa.core.config import settings
@@ -43,3 +45,14 @@ def get_fake_otp_tokens(
4345
str(random.randint(0, 9)) for _ in range(nr_digits)
4446
)
4547
yield random_otp
48+
49+
def qr_code_from_key(encoded_key: str, user_email: str):
50+
decoded_key = _fernet_decode(value=encoded_key)
51+
qrcode_key = pyotp.TOTP(
52+
decoded_key, interval=settings.TOTP_TOKEN_DURATION
53+
).provisioning_uri(user_email, issuer_name=settings.TOTP_ISSUER_NAME)
54+
img = qrcode.make(qrcode_key)
55+
buffer = io.BytesIO()
56+
img.save(buffer, format="PNG")
57+
buffer.seek(0)
58+
return buffer

fastapi_2fa/core/utils.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from dataclasses import dataclass
2+
3+
from pydantic import EmailStr
4+
5+
from fastapi_2fa.core.config import settings
6+
from fastapi_2fa.models.device import Device
7+
from fastapi_2fa.models.users import User
8+
from fastapi_2fa.tasks.tasks import send_email
9+
10+
11+
@dataclass(slots=True, frozen=True)
12+
class Email:
13+
to_: list[EmailStr]
14+
from_: EmailStr
15+
text_: str
16+
17+
def __repr__(self) -> str:
18+
return (
19+
f"Email (to: {self.to_}), "
20+
f"(from: {self.from_}), "
21+
f"(text: {self.text_})"
22+
)
23+
24+
25+
def send_backup_tokens(user: User, device: Device):
26+
email_ob = Email(
27+
to_=[user.email],
28+
from_=settings.FAKE_EMAIL_SENDER,
29+
text_=f"Backup tokens : {device.backup_tokens}"
30+
)
31+
send_email(email=email_ob)
32+
33+
34+
def send_totp_token(user: User, token: str):
35+
email_ob = Email(
36+
to_=[user.email],
37+
from_=settings.FAKE_EMAIL_SENDER,
38+
text_=f"Access TOTP token : {token}"
39+
)
40+
send_email(email=email_ob)

fastapi_2fa/crud/device.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from qrcode.image.svg import SvgImage
12
from sqlalchemy.orm import Session
23

4+
from fastapi_2fa.core.enums import DeviceTypeEnum
35
from fastapi_2fa.core.two_factor_auth import (
4-
create_encoded_two_factor_auth_key, get_fake_otp_tokens)
6+
create_encoded_two_factor_auth_key, get_fake_otp_tokens, qr_code_from_key)
57
from fastapi_2fa.crud.base_crud import CrudBase
68
from fastapi_2fa.models.device import BackupToken, Device
79
from fastapi_2fa.models.users import User
@@ -31,5 +33,14 @@ async def create(
3133
if await self.handle_commit(db):
3234
await db.refresh(db_device)
3335

36+
# generate qr_code
37+
qr_code = None
38+
if device.device_type == DeviceTypeEnum.CODE_GENERATOR:
39+
qr_code = qr_code_from_key(
40+
encoded_key=encoded_key,
41+
user_email=user.email
42+
)
43+
return db_device, qr_code
44+
3445

3546
device_crud = DeviceCrud(model=Device)

fastapi_2fa/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212

1313

14+
# routers
1415
app.include_router(router=router, prefix=settings.API_V1_STR)
1516

1617

fastapi_2fa/models/device.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,26 @@ class Device(Base):
2121
uselist=True,
2222
)
2323

24+
def __repr__(self) -> str:
25+
return (
26+
f"<{self.__class__.__name__}("
27+
f"id={self.id}, "
28+
f"device_type={self.device_type}, "
29+
f"user={self.user.full_name}, "
30+
f")>"
31+
)
32+
2433

2534
class BackupToken(Base):
2635
id = Column(Integer, primary_key=True, index=True)
2736
device_id = Column(Integer, ForeignKey("device.id"))
28-
device = relationship("Device", back_populates="backup_tokens", uselist=True)
37+
device = relationship("Device", back_populates="backup_tokens", uselist=False)
2938
token = Column(String(length=8))
39+
40+
def __repr__(self) -> str:
41+
return (
42+
f"<{self.__class__.__name__}("
43+
f"id={self.id}, "
44+
f"device={self.device}, "
45+
f")>"
46+
)

fastapi_2fa/models/users.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ def __repr__(self) -> str:
1818
return (
1919
f"<{self.__class__.__name__}("
2020
f"id={self.id}, "
21+
f"email={self.email}, "
22+
f"tfa={self.tfa_enabled}, "
2123
f"full_name={self.full_name}, "
2224
f")>"
2325
)

0 commit comments

Comments
 (0)