From 08b0e0262b3f82401566534d6904adcf2c585ebb Mon Sep 17 00:00:00 2001 From: ablogo Date: Wed, 14 Jan 2026 22:25:59 -0600 Subject: [PATCH] adding 2 factor authentification --- .env | 6 +++- src/dependency_injection/containers.py | 14 +++++++- src/main.py | 3 +- src/models/user_model.py | 6 ++-- src/routers/admin/security_router.py | 42 ++++++++++++++++++++++++ src/routers/auth_router.py | 6 ++-- src/routers/users_router.py | 39 ++++++++++++++++++---- src/services/crypto_service.py | 2 +- src/services/login_service.py | 9 +++--- src/services/otp_service.py | 41 +++++++++++++++++++++++ src/services/totp_service.py | 45 ++++++++++++++++++++++++++ src/services/user_service.py | 18 +++++++++++ 12 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 src/routers/admin/security_router.py create mode 100644 src/services/otp_service.py create mode 100644 src/services/totp_service.py diff --git a/.env b/.env index d1ad8de..2b32972 100644 --- a/.env +++ b/.env @@ -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" \ No newline at end of file +CORS_ALLOWED_HOSTS="http://localhost:8081,http://localhost:8002" +TOTP_SECRET= +TOTP_DIGEST=sha1 +TOTP_RETURN_DIGITS=8 +TOTP_TIME_STEP=30 \ No newline at end of file diff --git a/src/dependency_injection/containers.py b/src/dependency_injection/containers.py index 8857c41..3d56f81 100644 --- a/src/dependency_injection/containers.py +++ b/src/dependency_injection/containers.py @@ -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() @@ -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"]) @@ -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 ) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 74bae34..a1e8c2e 100644 --- a/src/main.py +++ b/src/main.py @@ -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 @@ -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("/") diff --git a/src/models/user_model.py b/src/models/user_model.py index fa40a5c..714b441 100644 --- a/src/models/user_model.py +++ b/src/models/user_model.py @@ -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) diff --git a/src/routers/admin/security_router.py b/src/routers/admin/security_router.py new file mode 100644 index 0000000..440db84 --- /dev/null +++ b/src/routers/admin/security_router.py @@ -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) \ No newline at end of file diff --git a/src/routers/auth_router.py b/src/routers/auth_router.py index 95b56ac..4fd3565 100644 --- a/src/routers/auth_router.py +++ b/src/routers/auth_router.py @@ -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) diff --git a/src/routers/users_router.py b/src/routers/users_router.py index a58e44b..bfeb532 100644 --- a/src/routers/users_router.py +++ b/src/routers/users_router.py @@ -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 @@ -12,6 +11,7 @@ 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 @@ -19,15 +19,16 @@ 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()) @@ -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()) @@ -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)]): @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/src/services/crypto_service.py b/src/services/crypto_service.py index 428aa52..8fc5675 100644 --- a/src/services/crypto_service.py +++ b/src/services/crypto_service.py @@ -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() # ) diff --git a/src/services/login_service.py b/src/services/login_service.py index fd8ba77..9f56205 100644 --- a/src/services/login_service.py +++ b/src/services/login_service.py @@ -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 diff --git a/src/services/otp_service.py b/src/services/otp_service.py new file mode 100644 index 0000000..111c4e6 --- /dev/null +++ b/src/services/otp_service.py @@ -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 \ No newline at end of file diff --git a/src/services/totp_service.py b/src/services/totp_service.py new file mode 100644 index 0000000..de868e0 --- /dev/null +++ b/src/services/totp_service.py @@ -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) \ No newline at end of file diff --git a/src/services/user_service.py b/src/services/user_service.py index 60a7120..280e92c 100644 --- a/src/services/user_service.py +++ b/src/services/user_service.py @@ -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