Skip to content

Commit 2c34093

Browse files
committed
handled email device and backup tokens
1 parent af70033 commit 2c34093

File tree

11 files changed

+255
-65
lines changed

11 files changed

+255
-65
lines changed

fastapi_2fa/api/deps/users.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi_2fa.core.config import settings
99
from fastapi_2fa.crud.users import user_crud
1010
from fastapi_2fa.models.users import User
11-
from fastapi_2fa.schemas.token_schema import TokenPayload
11+
from fastapi_2fa.schemas.jwt_token_schema import JwtTokenPayload
1212

1313
reuseable_oauth = OAuth2PasswordBearer(
1414
tokenUrl=f"{settings.API_V1_STR}/auth/login", scheme_name="JWT"
@@ -25,7 +25,7 @@ async def _get_user_from_jwt(
2525
):
2626
try:
2727
payload = jwt.decode(token=token, key=key, algorithms=[settings.ALGORITHM])
28-
token_data = TokenPayload(**payload)
28+
token_data = JwtTokenPayload(**payload)
2929
except jwt.ExpiredSignatureError:
3030
raise HTTPException(
3131
status_code=status.HTTP_401_UNAUTHORIZED,

fastapi_2fa/api/endpoints/api_v1/auth.py

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@
99
from sqlalchemy.orm import Session
1010

1111
from fastapi_2fa.api.deps.db import get_db
12-
from fastapi_2fa.api.deps.users import (get_authenticated_user,
13-
get_authenticated_user_pre_tfa)
12+
from fastapi_2fa.api.deps.users import get_authenticated_user
1413
from fastapi_2fa.core import security
1514
from fastapi_2fa.core.config import settings
1615
from fastapi_2fa.core.enums import DeviceTypeEnum
17-
from fastapi_2fa.core.utils import send_backup_tokens
18-
from fastapi_2fa.core.two_factor_auth import verify_token
16+
from fastapi_2fa.core.two_factor_auth import send_tfa_token
17+
from fastapi_2fa.core.utils import send_mail_backup_tokens
1918
from fastapi_2fa.crud.device import device_crud
2019
from fastapi_2fa.crud.users import user_crud
2120
from fastapi_2fa.models.users import User
22-
from fastapi_2fa.schemas.token_schema import TokenPayload, TokenSchema, PreTfaTokenSchema
21+
from fastapi_2fa.schemas.jwt_token_schema import (JwtTokenPayload,
22+
JwtTokenSchema,
23+
PreTfaJwtTokenSchema)
2324
from fastapi_2fa.schemas.user_schema import UserCreate, UserOut
2425

2526
auth_router = APIRouter()
@@ -53,7 +54,7 @@ async def signup(
5354
device=user_data.device,
5455
user=user
5556
)
56-
send_backup_tokens(user=user, device=device)
57+
send_mail_backup_tokens(user=user, device=device)
5758

5859
if device.device_type == DeviceTypeEnum.CODE_GENERATOR:
5960
return StreamingResponse(content=qr_code, media_type="image/png")
@@ -76,7 +77,7 @@ async def signup(
7677
"/login",
7778
summary="Create access and refresh tokens for user",
7879
status_code=status.HTTP_200_OK,
79-
response_model=TokenSchema | PreTfaTokenSchema,
80+
response_model=JwtTokenSchema | PreTfaJwtTokenSchema,
8081
)
8182
async def login(
8283
response: Response,
@@ -94,44 +95,26 @@ async def login(
9495
detail="Incorrect email or password",
9596
)
9697

97-
# verify 2 factor authentication
98-
if user_crud.is_tfa_enabled(user=user):
98+
# handle users with tfa enabled
99+
if user.tfa_enabled:
100+
send_tfa_token(
101+
user=user,
102+
device_type=user.device.device_type
103+
)
99104
response.status_code = status.HTTP_202_ACCEPTED
100-
return PreTfaTokenSchema(
105+
return PreTfaJwtTokenSchema(
101106
access_token=security.create_pre_tfa_token(user.id),
102107
refresh_token=None,
103108
)
104109

105110
# create access and refresh tokens
106-
return TokenSchema(
111+
return JwtTokenSchema(
107112
access_token=security.create_jwt_access_token(user.id),
108113
refresh_token=security.create_jwt_refresh_token(user.id),
109114
)
110115

111116

112-
@auth_router.post(
113-
"/login/tfa",
114-
summary="Verify two factor authentication token",
115-
response_model=TokenSchema,
116-
)
117-
async def login_tfa(
118-
tfa_token: str,
119-
db: Session = Depends(get_db),
120-
user: User = Depends(get_authenticated_user_pre_tfa),
121-
) -> Any:
122-
if verify_token(user=user, token=tfa_token):
123-
return TokenSchema(
124-
access_token=security.create_jwt_access_token(user.id),
125-
refresh_token=security.create_jwt_refresh_token(user.id),
126-
)
127-
128-
raise HTTPException(
129-
status_code=status.HTTP_403_FORBIDDEN,
130-
detail="TOTP token mismatch"
131-
)
132-
133-
134-
@auth_router.post(
117+
@auth_router.get(
135118
"/test-token", summary="Test if the access token is ok", response_model=UserOut
136119
)
137120
async def test_token(user: User = Depends(get_authenticated_user)):
@@ -141,7 +124,7 @@ async def test_token(user: User = Depends(get_authenticated_user)):
141124
return user
142125

143126

144-
@auth_router.post("/refresh", summary="Refresh token", response_model=TokenSchema)
127+
@auth_router.post("/refresh", summary="Refresh token", response_model=JwtTokenSchema)
145128
async def refresh_token(
146129
db: Session = Depends(get_db), refresh_token: str = Body(embed=True, title='refresh token')
147130
):
@@ -151,7 +134,7 @@ async def refresh_token(
151134
key=settings.JWT_SECRET_KEY_REFRESH,
152135
algorithms=[settings.ALGORITHM],
153136
)
154-
token_data = TokenPayload(**payload)
137+
token_data = JwtTokenPayload(**payload)
155138
except (jwt.JWTError, ValidationError):
156139
raise HTTPException(
157140
status_code=status.HTTP_403_FORBIDDEN,

fastapi_2fa/api/endpoints/api_v1/two_factor_auth.py

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,123 @@
55
from sqlalchemy.orm import Session
66

77
from fastapi_2fa.api.deps.db import get_db
8-
from fastapi_2fa.api.deps.users import get_authenticated_user
8+
from fastapi_2fa.api.deps.users import (get_authenticated_user,
9+
get_authenticated_user_pre_tfa)
10+
from fastapi_2fa.core import security
911
from fastapi_2fa.core.enums import DeviceTypeEnum
10-
from fastapi_2fa.core.utils import send_backup_tokens
12+
from fastapi_2fa.core.two_factor_auth import (qr_code_from_key,
13+
verify_backup_token,
14+
verify_token)
15+
from fastapi_2fa.core.utils import send_mail_backup_tokens
16+
from fastapi_2fa.crud.backup_token import backup_token_crud
1117
from fastapi_2fa.crud.device import device_crud
1218
from fastapi_2fa.crud.users import user_crud
1319
from fastapi_2fa.models.users import User
1420
from fastapi_2fa.schemas.device_schema import DeviceCreate
21+
from fastapi_2fa.schemas.jwt_token_schema import JwtTokenSchema
1522
from fastapi_2fa.schemas.user_schema import UserUpdate
1623

1724
tfa_router = APIRouter()
1825

1926

27+
@tfa_router.post(
28+
"/login_tfa",
29+
summary="Verify two factor authentication token",
30+
response_model=JwtTokenSchema,
31+
)
32+
async def login_tfa(
33+
tfa_token: str,
34+
db: Session = Depends(get_db),
35+
user: User = Depends(get_authenticated_user_pre_tfa),
36+
) -> Any:
37+
if verify_token(user=user, token=tfa_token):
38+
return JwtTokenSchema(
39+
access_token=security.create_jwt_access_token(user.id),
40+
refresh_token=security.create_jwt_refresh_token(user.id),
41+
)
42+
43+
raise HTTPException(
44+
status_code=status.HTTP_403_FORBIDDEN,
45+
detail="TOTP token mismatch"
46+
)
47+
48+
49+
@tfa_router.post(
50+
"/recover_tfa",
51+
summary="Checks and consumes one of the user's backup tokens re initializing",
52+
response_model=JwtTokenSchema,
53+
)
54+
async def recover_tfa(
55+
tfa_backup_token: str,
56+
db: Session = Depends(get_db),
57+
user: User = Depends(get_authenticated_user_pre_tfa),
58+
) -> Any:
59+
if backup_tokens := await backup_token_crud.get_user_backup_tokens(
60+
db=db,
61+
user=user
62+
):
63+
matched_bkp_token = verify_backup_token(
64+
backup_tokens=backup_tokens,
65+
tfa_backup_token=tfa_backup_token
66+
)
67+
68+
if matched_bkp_token:
69+
print('..consuming backup token')
70+
await backup_token_crud.remove(
71+
db=db, id=matched_bkp_token.id
72+
)
73+
return JwtTokenSchema(
74+
access_token=security.create_jwt_access_token(user.id),
75+
refresh_token=security.create_jwt_refresh_token(user.id),
76+
)
77+
78+
raise HTTPException(
79+
status_code=status.HTTP_403_FORBIDDEN,
80+
detail="TOTP backup token not found"
81+
)
82+
83+
# user has elapsed all backup tokens
84+
raise HTTPException(
85+
status_code=status.HTTP_404_NOT_FOUND,
86+
detail=f"User {user.email} has elapsed his backup tokens, "
87+
"please contact the system administrator"
88+
)
89+
90+
91+
@tfa_router.get(
92+
"/get_my_qrcode",
93+
summary="Returns authenticated user's qr_code "
94+
"if user's device is of type 'code_generator'",
95+
responses={
96+
200: {
97+
"content": {"image/png": {}},
98+
"description": "Returns no content or a qr code "
99+
"if tfas is enabled and device_type "
100+
"is 'code_generator'",
101+
}
102+
},
103+
)
104+
async def get_my_qrcode(
105+
user: User = Depends(get_authenticated_user),
106+
) -> Any:
107+
if (
108+
user.tfa_enabled and
109+
user_crud.device.device_type == DeviceTypeEnum.CODE_GENERATOR
110+
):
111+
qr_code = qr_code_from_key(
112+
encoded_key=user.device.key,
113+
user_email=user.email
114+
)
115+
return StreamingResponse(content=qr_code, media_type="image/png")
116+
117+
# user has elapsed all backup tokens
118+
raise HTTPException(
119+
status_code=status.HTTP_400_BAD_REQUEST,
120+
detail=f"User {user.email} has not tfa enabled or "
121+
"has not a 'code_generator' device"
122+
)
123+
124+
20125
@tfa_router.put(
21126
"/enable_tfa",
22127
summary="Enable two factor authentication for registered user",
@@ -33,8 +138,8 @@ async def enable_tfa(
33138
db: Session = Depends(get_db),
34139
user: User = Depends(get_authenticated_user),
35140
) -> Any:
36-
if not user_crud(transaction=True).is_tfa_enabled(user):
37-
user = await user_crud.update(
141+
if not user.tfa_enabled:
142+
user = await user_crud(transaction=True).update(
38143
db=db,
39144
db_obj=user,
40145
obj_in=UserUpdate(tfa_enabled=True)
@@ -45,7 +150,7 @@ async def enable_tfa(
45150
user=user
46151
)
47152

48-
send_backup_tokens(user=user, device=device)
153+
send_mail_backup_tokens(user=user, device=device)
49154

50155
if device.device_type == DeviceTypeEnum.CODE_GENERATOR:
51156
return StreamingResponse(content=qr_code, media_type="image/png")

fastapi_2fa/core/two_factor_auth.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
from fernet import Fernet
88

99
from fastapi_2fa.core.config import settings
10+
from fastapi_2fa.core.enums import DeviceTypeEnum
11+
from fastapi_2fa.core.utils import send_mail_totp_token
12+
from fastapi_2fa.models.device import BackupToken
13+
from fastapi_2fa.models.users import User
1014

1115
ENCODING = 'utf-8'
1216

@@ -47,15 +51,52 @@ def get_fake_otp_tokens(
4751
yield random_otp
4852

4953

50-
def verify_token(user: User, token:str) -> bool:
54+
55+
def get_current_totp(user: User) -> pyotp.TOTP:
5156
assert user.tfa_enabled is True, 'User does not have TFA enabled'
5257
assert user.device is not None, 'User has no associated device'
5358
decoded_key = _fernet_decode(value=user.device.key)
54-
totp = pyotp.TOTP(decoded_key)
55-
result = totp.verify(token, valid_window=settings.TOTP_TOKEN_TOLERANCE)
59+
return pyotp.TOTP(decoded_key)
60+
61+
62+
def verify_token(user: User, token: str) -> bool:
63+
totp: pyotp.TOTP = get_current_totp(user=user)
64+
result = totp.verify(token, valid_window=settings.TOTP_TOKEN_TOLERANCE)
5665
return result
5766

5867

68+
def verify_backup_token(
69+
backup_tokens: list[BackupToken], tfa_backup_token: str
70+
) -> BackupToken | None:
71+
"""Checks between "backup_tokens" if there is the "tfa_backup_token"
72+
73+
Args:
74+
backup_tokens list[BackupToken]
75+
tfa_backup_token (str)
76+
77+
Returns:
78+
BackupToken | None: the matched BackupToken or None if no backup tokens matched
79+
"""
80+
for bkp_token in backup_tokens:
81+
if bkp_token.token == tfa_backup_token:
82+
# consume backup token and return access jwt
83+
print('Found backup token match..')
84+
return bkp_token
85+
86+
87+
def send_tfa_token(
88+
user: User,
89+
device_type: DeviceTypeEnum
90+
) -> None:
91+
if device_type == DeviceTypeEnum.EMAIL:
92+
totp: pyotp.TOTP = get_current_totp(user=user)
93+
current_totp = totp.now()
94+
send_mail_totp_token(
95+
user=user,
96+
token=current_totp
97+
)
98+
99+
59100
def qr_code_from_key(encoded_key: str, user_email: str):
60101
decoded_key = _fernet_decode(value=encoded_key)
61102
qrcode_key = pyotp.TOTP(

fastapi_2fa/core/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def __repr__(self) -> str:
2222
)
2323

2424

25-
def send_backup_tokens(user: User, device: Device):
25+
def send_mail_backup_tokens(user: User, device: Device):
2626
email_ob = Email(
2727
to_=[user.email],
2828
from_=settings.FAKE_EMAIL_SENDER,
@@ -31,7 +31,7 @@ def send_backup_tokens(user: User, device: Device):
3131
send_email(email=email_ob)
3232

3333

34-
def send_totp_token(user: User, token: str):
34+
def send_mail_totp_token(user: User, token: str):
3535
email_ob = Email(
3636
to_=[user.email],
3737
from_=settings.FAKE_EMAIL_SENDER,

fastapi_2fa/crud/backup_token.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from sqlalchemy import select
2+
from sqlalchemy.orm import Session, joinedload
3+
4+
from fastapi_2fa.crud.base_crud import CrudBase
5+
from fastapi_2fa.models.device import BackupToken, Device
6+
from fastapi_2fa.models.users import User
7+
from fastapi_2fa.schemas.backup_token_schema import (BackupTokenCreate,
8+
BackupTokenUpdate)
9+
10+
11+
class BackupTokenCrud(CrudBase[Device, BackupTokenCreate, BackupTokenUpdate]):
12+
13+
@staticmethod
14+
async def get_user_backup_tokens(db: Session, user: User) -> list:
15+
if not user.device:
16+
return []
17+
result = await db.execute(
18+
select(Device).where(
19+
Device.id == user.device.id
20+
).options(joinedload(Device.backup_tokens))
21+
)
22+
device_with_bkp_tokens: Device = result.scalar()
23+
return device_with_bkp_tokens.backup_tokens
24+
25+
26+
backup_token_crud = BackupTokenCrud(model=BackupToken)

0 commit comments

Comments
 (0)