Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ LOG_LEVEL=DEBUG
JWT_SECRET_KEY=
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=15
CORS_ALLOWED_HOSTS="http://localhost:8081,http://localhost:8082"
CORS_ALLOWED_HOSTS="http://localhost:8081,http://localhost:8002"
TOTP_SECRET=
TOTP_DIGEST=sha1
TOTP_RETURN_DIGITS=8
TOTP_TIME_STEP=30
14 changes: 13 additions & 1 deletion src/dependency_injection/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from src.services import mongodb_service
from src.services.crypto_service import CryptoService
from src.logging.mongo_logging import MongoLogger
from src.services.totp_service import TOTP

load_dotenv()

Expand All @@ -15,10 +16,12 @@ class Container(containers.DeclarativeContainer):
"src.routers.auth_router",
"src.routers.users_router",
"src.routers.admin.users_router",
"src.routers.admin.security_router",
"src.services.user_service",
"src.services.login_service",
"src.services.jwt_service",
"src.routers.products_router"
"src.routers.products_router",
"src.services.totp_service",
])

#config = providers.Configuration(ini_files=["config.ini"])
Expand All @@ -40,4 +43,13 @@ class Container(containers.DeclarativeContainer):
crypto_service = providers.Singleton(
CryptoService,
logging
)

totp = providers.Singleton(
TOTP,
os.environ["TOTP_SECRET"],
os.environ["TOTP_DIGEST"],
int(os.environ["TOTP_TIME_STEP"]),
int(os.environ["TOTP_RETURN_DIGITS"]),
logging
)
3 changes: 2 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import os

from src.routers import auth_router, products_router, users_router
from src.routers.admin import users_router as admin_user_router
from src.routers.admin import users_router as admin_user_router, security_router
from src.middlewares.jwt_middleware import JWTMiddleware
from src.middlewares.http_middleware import HttpMiddleware
from src.dependency_injection.containers import Container
Expand Down Expand Up @@ -41,6 +41,7 @@ async def shutdown():
app.include_router(users_router.router)
app.include_router(products_router.router)
app.include_router(admin_user_router.router)
app.include_router(security_router.router)

#Root route
@app.get("/")
Expand Down
6 changes: 4 additions & 2 deletions src/models/user_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ class User(BaseModel):
name: str
last_name: Optional[str] = None
email: Annotated[str, BeforeValidator(validate_email)]
email_verified: bool = False
password: str
twofactor_enabled: bool = False
address: Optional[List[Address]] = list()
created_at: datetime = datetime.now()
updated_at: Optional[datetime] = None
online: bool = False
disabled: bool = False
created_at: datetime = datetime.now()
updated_at: Optional[datetime] = None

#model_config = ConfigDict(arbitrary_types_allowed=True)

Expand Down
42 changes: 42 additions & 0 deletions src/routers/admin/security_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Response
from dependency_injector.wiring import Provide, inject

from src.dependency_injection.containers import Container
from src.logging.mongo_logging import MongoLogger
from src.middlewares.auth_jwt import JWTCustom
import src.services.totp_service as securitySvc

oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in")
router = APIRouter(
tags=["security"],
dependencies=[Depends(oauth2_scheme)],
prefix="/security")
totp_dependency = Annotated[securitySvc.TOTP, Depends(Provide[Container.totp])]

@router.get("/2fa-now")
@inject
async def get_2f_code(totp: totp_dependency, email: Optional[str] = None):
totp_code = await totp.now(email)
if totp_code:
return Response(content=totp_code, media_type="plain/text")
else:
return Response(status_code=404)

@router.get("/2fa-at")
@inject
async def get_2f_code_at(time: int, totp: totp_dependency, email: Optional[str] = None):
otp_code = await totp.at(time, email)
if otp_code:
return Response(content=otp_code, media_type="plain/text")
else:
return Response(status_code=404)

@router.get("/2fa-verify")
@inject
async def get_2f_code_verify(code: str, totp: totp_dependency, email: Optional[str] = None):
result = await totp.verify(code, email)
if result:
return Response(status_code=200)
else:
return Response(status_code=401)
6 changes: 4 additions & 2 deletions src/routers/auth_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@ async def sign_in(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db
try:
client_host = request.client.host # type: ignore
log.logger.info(f"{ form_data.username } login from ip: { client_host }")
content = ""
content = None
status_code = status.HTTP_401_UNAUTHORIZED
token = await login(form_data.username, form_data.password, db.database)
result, token = await login(form_data.username, form_data.password, db.database)
if token is not None:
content = token.model_dump()
status_code = status.HTTP_200_OK
elif result and token is None:
status_code = status.HTTP_412_PRECONDITION_FAILED

except Exception as e:
log.logger.error(e)
Expand Down
39 changes: 33 additions & 6 deletions src/routers/users_router.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from typing import Annotated
from fastapi import APIRouter, Depends, UploadFile, status
from fastapi import APIRouter, Depends, Response, UploadFile, status
from fastapi.responses import JSONResponse, StreamingResponse
from pymongo.asynchronous.database import AsyncDatabase
from dependency_injector.wiring import Provide, inject
import io

Expand All @@ -12,22 +11,24 @@
from src.services.mongodb_service import MongoAsyncService
from src.services.user_service import change_password, insert_address
from src.services.jwt_service import verify_token_from_requests
from src.services.totp_service import TOTP
import src.services.user_service as uSvc
from src.dependencies import get_db

oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in")
router = APIRouter(tags=["users"], dependencies=[Depends(oauth2_scheme)])
#db_dependency = Annotated[AsyncDatabase, Depends(get_db)]
db_dependency = Annotated[MongoAsyncService, Depends(Provide[Container.database_client])]
totp_dependency = Annotated[TOTP, Depends(Provide[Container.totp])]

# Route to add an users
@router.get("/user", response_model_by_alias=False)
@router.get("/user", response_model_by_alias = False)
@inject
async def get_user(db: db_dependency, email: Annotated[str, Depends(verify_token_from_requests)]) -> User | None:
user = await uSvc.get_user(email, db.get_db())
return user

@router.post("/user/img", response_model_by_alias=False)
@router.post("/user/img", response_model_by_alias = False)
@inject
async def add_user_image(file: UploadFile, db: db_dependency, email: Annotated[str, Depends(verify_token_from_requests)]):
result = await uSvc.add_user_picture(file, file.content_type, email, db.get_db())
Expand All @@ -36,7 +37,7 @@ async def add_user_image(file: UploadFile, db: db_dependency, email: Annotated[s
else:
return JSONResponse(None, status.HTTP_400_BAD_REQUEST)

@router.get("/user/img", response_model_by_alias=False)
@router.get("/user/img", response_model_by_alias = False)
@inject
async def get_user_image(db: db_dependency, email: Annotated[str, Depends(verify_token_from_requests)]):
result = await uSvc.get_user_picture(email, db.get_db())
Expand All @@ -57,6 +58,23 @@ async def update_password(db: db_dependency, password: str, email: Annotated[str
result = await change_password(db.get_db(), email, password)
return result

@router.get("/user/2fa")
@inject
async def get_2f_code(email: Annotated[str, Depends(oauth2_scheme)], totp: totp_dependency):
totp_code = await totp.now(email)
if totp_code:
return Response(content=totp_code, media_type="plain/text")
else:
return Response(status_code=404)

@router.get("/user/2fa-verify")
@inject
async def get_2f_code_verify(code: str, email: Annotated[str, Depends(oauth2_scheme)], totp: totp_dependency):
if await totp.verify(code, email):
return Response(status_code=200)
else:
return Response(status_code=401)

@router.post("/user/address")
@inject
async def create_address(db: db_dependency, address: Address, email: Annotated[str, Depends(oauth2_scheme)]):
Expand All @@ -67,4 +85,13 @@ async def create_address(db: db_dependency, address: Address, email: Annotated[s
@inject
async def update_address(db: db_dependency, address: Address, email: Annotated[str, Depends(oauth2_scheme)]):
result = await uSvc.update_address(db.get_db(), email, address)
return result
return result

@router.post("/user/change-status")
@inject
async def change_status(db: db_dependency, user_status: bool, email: Annotated[str, Depends(verify_token_from_requests)]):
result = await uSvc.change_status(user_status, email, db.get_db())
if result:
return Response(status_code = status.HTTP_200_OK)
else:
return Response(status_code = status.HTTP_412_PRECONDITION_FAILED)
2 changes: 1 addition & 1 deletion src/services/crypto_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def get_private_key(self):
print(f"read private key ")

# private_key = rsa.generate_private_key(
# public_exponent=65537,
# public_exponent=,
# key_size=4096,
# backend=default_backend()
# )
Expand Down
9 changes: 5 additions & 4 deletions src/services/login_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ async def login(username, password, db, crypto = crypto_service, log = logger):
user = await get_user(username, db)
if user is not None:
is_password_valid = await crypto.verify_password(password, user.password)
if is_password_valid or user.disabled:
if not user.email_verified:
return True, None
if is_password_valid and not user.disabled:
token = await create_token({ "sub": user.email, "name": user.name })
return Token(access_token=token, token_type="bearer")
return None
return True, Token(access_token=token, token_type="bearer")
except Exception as e:
log.logger.error(e)
return None
return False, None
41 changes: 41 additions & 0 deletions src/services/otp_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import hmac, struct


class OTP():
DIGITS_POWER = [1,10,100,1000,10000,100000,1000000,10000000,100000000]

def __init__(self) -> None:
pass

def get_hmac_sha(self, secret: bytes, moving_factor: bytes, digest: str):
return hmac.new(secret, moving_factor, digest)

def to_bytes(self, value: str):
bytes_object = value.encode('ascii')
hex_string = bytes_object.hex()
return bytes(bytearray.fromhex(hex_string))

def generate_OPT(self, secret: str, moving_factor: int, return_digits: int, digest: str):
otp_code = ""
try:
msg = struct.pack('>Q', moving_factor)
k = self.to_bytes(secret)
hasher = self.get_hmac_sha(k, msg, digest)
#hasher_hex = hasher.hexdigest()
#hmac_hash = bytes.fromhex(hasher_hex)
hmac_hash = bytearray(hasher.digest())
offset = hmac_hash[-1] & 0xF
bin_code = (
(hmac_hash[offset] & 0x7F) << 24 |
(hmac_hash[offset + 1] & 0xFF) << 16 |
(hmac_hash[offset + 2] & 0xFF) << 8 |
(hmac_hash[offset + 3] & 0xFF)
)
otp_code = str(bin_code % self.DIGITS_POWER[return_digits])

while (otp_code.__len__() < return_digits):
otp_code = "0" + otp_code

except Exception as e:
raise e
return otp_code
45 changes: 45 additions & 0 deletions src/services/totp_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from datetime import datetime
from typing import Optional, Union
from src.services.otp_service import OTP
import time

from src.logging.mongo_logging import MongoLogger

class TOTP(OTP):

def __init__(self, secret: str, digest: str, time_step: int, return_digits: int, log: MongoLogger) -> None:
self.secret = secret
self.digest = digest
self.time_step = time_step
self.return_digits = return_digits
self.log = log
super().__init__()

async def at(self, for_time: Union[int, datetime], secret: Optional[str] = None, time_window: int = 0) -> str:
otp = ""
try:
secret = self.secret if secret is None else secret
if isinstance(for_time, int):
for_time = self.to_unix_time(datetime.fromtimestamp(for_time))
otp = self.generate_OPT(secret, self.to_unix_time(for_time), self.return_digits, self.digest)
except Exception as e:
self.log.logger.error(e)
return otp

async def now(self, secret: Optional[str] = None) -> str:
return self.generate_OPT(self.secret if secret is None else secret, self.to_unix_time(datetime.now()), self.return_digits, self.digest)

async def verify(self, otp_to_validate: str, secret: Optional[str] = None, for_time: Optional[datetime] = None, time_window: int = 0) -> bool:
result = False
try:
secret = self.secret if secret is None else secret
for_time = datetime.now() if for_time is None else for_time
otp = await self.at(for_time, secret, time_window)
if otp_to_validate == otp:
result = True
except Exception as e:
self.log.logger.error(e)
return result

def to_unix_time(self, date) -> int:
return int(time.mktime(date.utctimetuple()) / self.time_step)
18 changes: 18 additions & 0 deletions src/services/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,21 @@ async def update_address(db: AsyncDatabase, email: str, address: Address, log =
log.logger.error(e)
finally:
return result

@inject
async def change_status(status: bool, email: str, db: AsyncDatabase, log = log_service) -> bool:
result = False
try:
user_db = await db[users_collection].find_one({'email': email})

if user_db != None:
query_filter = {"email": email}
update_op = {"$set" : {"online" : status }}
op_result = await db[users_collection].update_one(query_filter, update_op)

if op_result.modified_count > 0:
result = True

except Exception as e:
log.logger.error(e)
return result