From 766eb75a34b2d63aeec52bc1d34e5b3c067f91dc Mon Sep 17 00:00:00 2001 From: ablogo Date: Tue, 10 Feb 2026 10:17:50 -0600 Subject: [PATCH 1/6] adding google OAuth2 authentication --- .env | 8 ++- src/main.py | 3 +- src/models/user_model.py | 1 + src/models/user_picture.py | 1 + src/routers/auth_router.py | 18 ++++-- src/routers/oauth2_router.py | 86 ++++++++++++++++++++++++++++ src/routers/users_router.py | 2 +- src/services/jwt_service.py | 9 +++ src/services/login_service.py | 12 ++++ src/services/oauth_google_service.py | 47 +++++++++++++++ src/services/user_service.py | 57 ++++++++++++------ 11 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 src/routers/oauth2_router.py create mode 100644 src/services/oauth_google_service.py diff --git a/.env b/.env index 081fedd..0f88d90 100644 --- a/.env +++ b/.env @@ -14,4 +14,10 @@ CORS_ALLOWED_HOSTS="http://localhost:8081,http://localhost:8002" TOTP_SECRET=12345678901234567890 TOTP_DIGEST=sha1 TOTP_RETURN_DIGITS=8 -TOTP_TIME_STEP=30 \ No newline at end of file +TOTP_TIME_STEP=30 +GOOGLE_OAUTH_ID= +GOOGLE_OAUTH_CLIENT= +GOOGLE_OAUTH_SECRET= +GOOGLE_OAUTH_REDIRECT_RESPONSE= +GOOGLE_OAUTH_JS_ORIGINS= +GOOGLE_OAUTH_SCOPES= \ No newline at end of file diff --git a/src/main.py b/src/main.py index a1e8c2e..944fd7a 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,7 @@ from dotenv import load_dotenv import os -from src.routers import auth_router, products_router, users_router +from src.routers import auth_router, products_router, users_router, oauth2_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 @@ -42,6 +42,7 @@ async def shutdown(): app.include_router(products_router.router) app.include_router(admin_user_router.router) app.include_router(security_router.router) +app.include_router(oauth2_router.router) #Root route @app.get("/") diff --git a/src/models/user_model.py b/src/models/user_model.py index 2513958..d58f15d 100644 --- a/src/models/user_model.py +++ b/src/models/user_model.py @@ -27,6 +27,7 @@ class User(BaseModel): twofactor_enabled: bool = False roles: Optional[List[str]] = list() address: Optional[List[Address]] = list() + issuer: Optional[str] = None online: bool = False disabled: bool = False created_at: datetime = datetime.now() diff --git a/src/models/user_picture.py b/src/models/user_picture.py index e4d98ce..fa32358 100644 --- a/src/models/user_picture.py +++ b/src/models/user_picture.py @@ -9,6 +9,7 @@ class UserPicture(BaseModel): id: Optional[PyObjectId] = Field(default= None, serialization_alias="_id") content_type: str | None = None picture: Optional[bytes] = None + picture_url: Optional[str] = None created_at: datetime = datetime.now() updated_at: Optional[datetime] = None diff --git a/src/routers/auth_router.py b/src/routers/auth_router.py index 6a584d9..1dfdd73 100644 --- a/src/routers/auth_router.py +++ b/src/routers/auth_router.py @@ -16,7 +16,7 @@ router = APIRouter( tags=["auth"], prefix="/auth" - ) +) oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in") db_dependency = Annotated[MongoAsyncService, Depends(Provide[Container.database_client])] log_dependency = Annotated[log2mongo, Depends(Provide[Container.logging])] @@ -29,8 +29,14 @@ async def sign_up(model: SignUp, db: db_dependency, log: log_dependency, request log.logger.info(f"{ model.email } sign-up from ip: { client_host }") content = "" status_code = 0 - user = await create_user(db.database, User(name= model.name, email= model.email, password= model.password, disabled=False)) - if user is not None: + user = await create_user(db.database, User( + name = model.name, + email = model.email, + password = model.password, + issuer = '', + disabled = False) + ) + if user: content = user.model_dump_json() status_code = status.HTTP_200_OK else: @@ -51,7 +57,7 @@ async def sign_in(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db content = None status_code = status.HTTP_401_UNAUTHORIZED result, token = await login(form_data.username, form_data.password, db.database) - if token is not None: + if token: content = token.model_dump() status_code = status.HTTP_200_OK elif result and token is None: @@ -67,9 +73,9 @@ async def sign_in(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db async def validate_token(log: log_dependency, email: Annotated[str, Depends(oauth2_scheme)]): try: status_code = status.HTTP_401_UNAUTHORIZED - if email is not None: + if email: status_code = status.HTTP_200_OK except Exception as e: log.logger.error(e) finally: - return Response(email, status_code, media_type="text/plain") \ No newline at end of file + return Response(email, status_code, media_type = "text/plain") diff --git a/src/routers/oauth2_router.py b/src/routers/oauth2_router.py new file mode 100644 index 0000000..89fc312 --- /dev/null +++ b/src/routers/oauth2_router.py @@ -0,0 +1,86 @@ +from typing import Annotated +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.responses import JSONResponse, RedirectResponse +from dependency_injector.wiring import Provide, inject +from log2mongo import log2mongo + +from src.middlewares.auth_jwt import JWTCustom +from src.models.user_model import User +from src.services.jwt_service import get_token_unverify_signature_data +from src.services.mongodb_service import MongoAsyncService +from src.dependency_injection.containers import Container +from src.services.login_service import external_login +from src.services.oauth_google_service import get_auth_url, get_auth_response +from src.services.user_service import add_user_picture, create_user, get_user + +router = APIRouter( + tags=["OAuth2"], + prefix="/oauth" +) +oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in") +db_dependency = Annotated[MongoAsyncService, Depends(Provide[Container.database_client])] +log_dependency = Annotated[log2mongo, Depends(Provide[Container.logging])] + +@router.get("/google-url") +@inject +async def get_google_url(db: db_dependency, log: log_dependency, request: Request): + try: + log.logger.info(f" sign-up from ip: { request.client.host }") # type: ignore + content = "" + status_code = 0 + auth_url = await get_auth_url() + if auth_url: + content = auth_url + status_code = status.HTTP_307_TEMPORARY_REDIRECT + else: + content = "Unable to create user" + status_code = status.HTTP_400_BAD_REQUEST + + except Exception as e: + log.logger.error(e) + finally: + if status_code == status.HTTP_307_TEMPORARY_REDIRECT: + return RedirectResponse(content) + else: + return Response(content, status_code) + +@router.get("/google-response") +@inject +async def get_google_response(db: db_dependency, log: log_dependency, request: Request): + try: + log.logger.info(f"Google sign-up from ip: { request.client.host }") # type: ignore + content = "" + status_code = status.HTTP_401_UNAUTHORIZED + google_token = await get_auth_response(request.url.__str__()) + + if google_token: + token_data = await get_token_unverify_signature_data(google_token.id_token.__str__()) # type: ignore + user = await get_user(token_data['email'], db.get_db()) + + if user is None: + user = await create_user(db.database, User( + name = token_data['given_name'], + last_name = token_data['family_name'], + email= token_data['email'], + email_verified = True, + password= token_data['at_hash'], + issuer = token_data['iss'], + disabled=False)) + + if user: + result = await add_user_picture(token_data['email'], db.get_db(), pic_url = token_data["picture"]) + + if user: + result, token = await external_login(user.email, token_data['iss'], db.database) + + if token: + content = token.model_dump() + status_code = status.HTTP_200_OK + + except Exception as e: + log.logger.error(e) + finally: + if status_code == status.HTTP_200_OK: + return JSONResponse(content, status_code) + else: + return Response(status_code = status_code) diff --git a/src/routers/users_router.py b/src/routers/users_router.py index 05dc4b0..a2b4736 100644 --- a/src/routers/users_router.py +++ b/src/routers/users_router.py @@ -31,7 +31,7 @@ async def get_user(db: db_dependency, email: Annotated[str, Depends(verify_token @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()) + result = await uSvc.add_user_picture(email, db.get_db(), file = file, content_type = file.content_type) if result: return JSONResponse("", status.HTTP_200_OK) else: diff --git a/src/services/jwt_service.py b/src/services/jwt_service.py index bcecba4..afde78a 100644 --- a/src/services/jwt_service.py +++ b/src/services/jwt_service.py @@ -91,6 +91,15 @@ async def get_email(token: str, crypto = crypto_service, log = log_service): try: payload = jwt.decode(token, str(os.environ["JWT_SECRET_KEY"]), os.environ["JWT_ALGORITHM"]) return await crypto.decrypt_text(payload.get("sub")) + except Exception as e: + log.logger.error(e) + raise e + +@inject +async def get_token_unverify_signature_data(token: str, crypto = crypto_service, log = log_service): + try: + payload = jwt.decode(token, options = {"verify_signature" : False}) + return payload except Exception as e: log.logger.error(e) raise e \ No newline at end of file diff --git a/src/services/login_service.py b/src/services/login_service.py index 6fc9661..1b6abbb 100644 --- a/src/services/login_service.py +++ b/src/services/login_service.py @@ -24,3 +24,15 @@ async def login(username: str, password: str, db, crypto = crypto_service, log = except Exception as e: log.logger.error(e) return False, None + +@inject +async def external_login(username: str, issuer: str, db, crypto = crypto_service, log = logger): + try: + user = await get_user(username, db) + if user: + if user.issuer == issuer: + token = await create_token({ "sub": user.email, "name": user.name, "roles": user.roles }) + return True, Token(access_token = token, token_type = "bearer") + except Exception as e: + log.logger.error(e) + return False, None diff --git a/src/services/oauth_google_service.py b/src/services/oauth_google_service.py new file mode 100644 index 0000000..b51e8a6 --- /dev/null +++ b/src/services/oauth_google_service.py @@ -0,0 +1,47 @@ +import os +import google.oauth2.credentials +from log2mongo import log2mongo +import google_auth_oauthlib.flow +from dependency_injector.wiring import Provide, inject + +from src.dependency_injection.containers import Container + +log_service: log2mongo = Provide[Container.logging] + +@inject +async def get_auth_url(log = log_service): + try: + flow = google_auth_oauthlib.flow.Flow.from_client_config( + {"web":{"client_id":os.environ["GOOGLE_OAUTH_CLIENT"],"project_id":os.environ["GOOGLE_OAUTH_ID"],"auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":os.environ["GOOGLE_OAUTH_SECRET"],"javascript_origins":os.environ["GOOGLE_OAUTH_JS_ORIGINS"].split(',') if os.environ["GOOGLE_OAUTH_JS_ORIGINS"] else []}}, + scopes = ['https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'openid'] + ) + flow.redirect_uri = os.environ["GOOGLE_OAUTH_REDIRECT_RESPONSE"] + + auth_url , state = flow.authorization_url( + acces_type = 'offline', + include_grand_scopes = 'true', + prompt = 'consent' + ) + + return auth_url + + except Exception as e: + log.logger.error(e) + +@inject +async def get_auth_response(url: str, log = log_service): + try: + credentials = None + flow = google_auth_oauthlib.flow.Flow.from_client_config( + {"web":{"client_id":os.environ["GOOGLE_OAUTH_CLIENT"],"project_id":os.environ["GOOGLE_OAUTH_ID"],"auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":os.environ["GOOGLE_OAUTH_SECRET"],"javascript_origins":os.environ["GOOGLE_OAUTH_JS_ORIGINS"].split(',') if os.environ["GOOGLE_OAUTH_JS_ORIGINS"] else []}}, + scopes = ['https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'openid'] + ) + flow.redirect_uri = os.environ["GOOGLE_OAUTH_REDIRECT_RESPONSE"] + + auth_reponse = flow.fetch_token(authorization_response = url) + credentials = flow.credentials + + except Exception as e: + log.logger.error(e) + finally: + return credentials \ No newline at end of file diff --git a/src/services/user_service.py b/src/services/user_service.py index 5ed01a2..add628a 100644 --- a/src/services/user_service.py +++ b/src/services/user_service.py @@ -55,30 +55,49 @@ async def get_users(db: AsyncDatabase, log = log_service) -> list[User] | None: return users @inject -async def add_user_picture(file: UploadFile, content_type: str | None, email: str, db: AsyncDatabase, log = log_service) -> bool: +async def add_user_picture(email: str, db: AsyncDatabase, file: UploadFile | None = None, pic_url: str | None = None, content_type: str | None = None, log = log_service) -> bool: try: result = False user_bd = await db[users_collection].find_one({'email': email}) - if user_bd is not None: + + if user_bd: + user_picture = await db[users_pics_collection].find_one({'_id': user_bd["_id"]}) user_pic = UserPicture( - id = user_bd["_id"], - content_type = content_type + id = user_bd["_id"] ) - img = await file.read() - user_picture = await db[users_pics_collection].find_one({'_id': user_bd["_id"]}) - - if user_picture is not None: - query_filter = {"_id": user_bd["_id"]} - update_op = {"$set" : {"picture" : Binary(img), "content_type": content_type }} - op_result = await db[users_pics_collection].update_one(query_filter, update_op) - - if op_result.modified_count > 0: - result = True - else: - user_pic.picture = Binary(img) - op_result = await db[users_pics_collection].insert_one(user_pic.model_dump(by_alias=True)) - if op_result.inserted_id is not None: - result = True + + if file: + content_type = content_type + img = await file.read() + + if user_picture is not None: + query_filter = {"_id": user_bd["_id"]} + update_op = {"$set" : {"picture" : Binary(img), "content_type": content_type, "picture_url": "" }} + op_result = await db[users_pics_collection].update_one(query_filter, update_op) + + if op_result.modified_count > 0: + result = True + else: + user_pic.picture = Binary(img) + op_result = await db[users_pics_collection].insert_one(user_pic.model_dump(by_alias=True)) + + if op_result.inserted_id is not None: + result = True + elif pic_url: + if user_picture is not None: + query_filter = {"_id": user_bd["_id"]} + update_op = {"$set" : {"picture_url" : pic_url, "content_type": 'text/plain', "picture": None }} + op_result = await db[users_pics_collection].update_one(query_filter, update_op) + + if op_result.modified_count > 0: + result = True + else: + user_pic.picture_url = pic_url + op_result = await db[users_pics_collection].insert_one(user_pic.model_dump(by_alias=True)) + + if op_result.inserted_id is not None: + result = True + except Exception as e: log.logger.error(e) finally: From 73e0cb1f8ee49bdd433d0b9e8723b9c9fb9a0d21 Mon Sep 17 00:00:00 2001 From: ablogo Date: Wed, 25 Feb 2026 20:23:29 -0600 Subject: [PATCH 2/6] Add address funtions to admin router --- src/models/address_model.py | 6 ++- src/routers/admin/users_router.py | 65 +++++++++++++++++++------------ src/routers/users_router.py | 16 ++++++-- src/services/user_service.py | 51 +++++++++++++----------- 4 files changed, 84 insertions(+), 54 deletions(-) diff --git a/src/models/address_model.py b/src/models/address_model.py index a5dd614..3509ca2 100644 --- a/src/models/address_model.py +++ b/src/models/address_model.py @@ -5,14 +5,16 @@ from src.models.pydantic_objects import PyObjectId -def set_id(value) -> ObjectId: +def set_id(value): if not value: return ObjectId() + if not isinstance(value, ObjectId): + return ObjectId(value) return value class Address(BaseModel): - id: Annotated[Optional[PyObjectId], PlainValidator(set_id), Field(validate_default=True)] = Field(default=None, validation_alias="_id") + id: Annotated[Optional[PyObjectId], PlainValidator(set_id), Field(validate_default=True, serialization_alias="_id")] = Field(default=None, validation_alias="_id") country: str state: str colony: str diff --git a/src/routers/admin/users_router.py b/src/routers/admin/users_router.py index 1bf5b1e..94c4421 100644 --- a/src/routers/admin/users_router.py +++ b/src/routers/admin/users_router.py @@ -6,7 +6,7 @@ from src.models.user_model import User from src.models.address_model import Address -from src.services.user_service import change_password, insert_address +from src.services.user_service import change_password, get_address, insert_address from src.dependencies import get_db from src.middlewares.auth_roles_jwt import JWTCustom from src.dependency_injection.containers import Container @@ -22,14 +22,12 @@ @router.get("/user", response_model=User) @inject -async def get_user(email: str, db: db_dependency, log: log_dependency): +async def get_user(email: str, db: db_dependency, log: log_dependency, response: Response) -> User | None: try: - log.logger.info(f"get user: {email}") user = await uSvc.get_user(email, db) if user: - return Response(content=user.model_dump_json(), media_type="application/json") - else: - return Response(status_code=404) + return user + response.status_code = 404 except Exception as e: log.logger.error(e) @@ -37,22 +35,21 @@ async def get_user(email: str, db: db_dependency, log: log_dependency): @inject async def get_users(db: db_dependency, log: log_dependency, response: Response) -> list[User] | None: try: - log.logger.info(f"get users: {db.name}") users = await uSvc.get_users(db) - if users == None: - response.status_code = 404 - return users + if users: + return users + response.status_code = 404 except Exception as e: log.logger.error(e) @router.post("/users") @inject -async def create_user(model: User, db: db_dependency, log: log_dependency): +async def create_user(model: User, db: db_dependency, log: log_dependency, response: Response): try: user = await uSvc.create_user(db, model) - if user is not None: + if user: return user - + response.status_code = 400 except Exception as e: log.logger.error(e) @@ -62,45 +59,63 @@ async def update_user(model: User, db: db_dependency, log: log_dependency, respo try: result = await uSvc.update_user(db, model) if result: - response.status_code = 404 + response.status_code = 200 + return + response.status_code = 400 except Exception as e: log.logger.error(e) @router.delete("/users") @inject -async def delete_user(email: str, db: db_dependency, log: log_dependency): +async def delete_user(email: str, db: db_dependency, log: log_dependency, response: Response): try: result = await uSvc.deleted_user(db, email) if result: - return True - + response.status_code = 200 + return + response.status_code = 400 except Exception as e: log.logger.error(e) -@router.get("/change-password") +@router.post("/user/change-password") @inject async def update_password(email: str, password: str, db: db_dependency, log: log_dependency, response: Response): try: result = await change_password(db, email, password) - if not result: - response.status_code = 400 + if result: + response.status_code = 200 + return + response.status_code = 400 except Exception as e: log.logger.error(e) -@router.post("/users/address", response_model_by_alias = False) +@router.post("/users/address") @inject async def create_address(email: str, address: Address, db: db_dependency, log: log_dependency): try: - result = await insert_address(db, email, address) - return result + new_address = await insert_address(email, address, db) + return new_address + except Exception as e: + log.logger.error(e) + +@router.get("/user/address") +@inject +async def get_addresses(email: str, db: db_dependency, log: log_dependency, response : Response) -> list[Address] | None: + try: + addresses = await get_address(db, email) + if addresses: + return addresses + response.status_code = 400 except Exception as e: log.logger.error(e) @router.put("/user/address") @inject -async def update_address(email: str, address: Address, db: db_dependency, log: log_dependency): +async def update_address(email: str, address: Address, db: db_dependency, log: log_dependency, response: Response): try: result = await uSvc.update_address(db, email, address) - return result + if result: + response.status_code = 200 + response.status_code = 404 except Exception as e: log.logger.error(e) \ No newline at end of file diff --git a/src/routers/users_router.py b/src/routers/users_router.py index a2b4736..8b41d92 100644 --- a/src/routers/users_router.py +++ b/src/routers/users_router.py @@ -9,14 +9,16 @@ from src.models.address_model import Address from src.dependency_injection.containers import Container from src.services.mongodb_service import MongoAsyncService -from src.services.user_service import change_password, insert_address +from src.services.user_service import change_password, insert_address, get_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)]) +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])] @@ -52,7 +54,7 @@ async def update_user(db: db_dependency, model: User, email: Annotated[str, Depe result = await uSvc.update_user(db.get_db(), model) return result -@router.get("/user/change-password") +@router.post("/user/change-password") @inject async def update_password(db: db_dependency, password: str, email: Annotated[str, Depends(oauth2_scheme)]): result = await change_password(db.get_db(), email, password) @@ -78,7 +80,13 @@ async def get_2f_code_verify(code: str, email: Annotated[str, Depends(oauth2_sch @router.post("/user/address") @inject async def create_address(db: db_dependency, address: Address, email: Annotated[str, Depends(oauth2_scheme)]): - result = await insert_address(db.get_db(), email, address) + result = await insert_address(email, address, db.get_db()) + return result + +@router.get("/user/address") +@inject +async def get_addresses(db: db_dependency, email: Annotated[str, Depends(oauth2_scheme)] ) -> list[Address] | None: + result = await get_address(db.get_db(), email) return result @router.put("/user/address") diff --git a/src/services/user_service.py b/src/services/user_service.py index add628a..b1a3629 100644 --- a/src/services/user_service.py +++ b/src/services/user_service.py @@ -206,9 +206,9 @@ async def change_password(db: AsyncDatabase, email: str, new_password: str, cryp return result @inject -async def insert_address(db: AsyncDatabase, email: str, address: Address, log = log_service) -> bool: +async def insert_address(email: str, address: Address, db: AsyncDatabase, log = log_service) -> Address | None: try: - result = False + result = None user_db = await db[users_collection].find_one({'email': email}) if user_db is not None: @@ -220,37 +220,42 @@ async def insert_address(db: AsyncDatabase, email: str, address: Address, log = updated_result = await db[users_collection].update_one(query_filter, update_op) if updated_result.modified_count > 0: - result = True + result = address except Exception as e: log.logger.error(e) finally: return result +@inject +async def get_address(db: AsyncDatabase, email: str, log = log_service) -> list[Address] | None: + try: + addresses = None + query = {'email': email} + projection = {"address": 1, "_id": 0} + result = await db[users_collection].find_one(query, projection) + addresses = result["address"] # type: ignore + + except Exception as e: + log.logger.error(e) + finally: + return addresses + @inject async def update_address(db: AsyncDatabase, email: str, address: Address, log = log_service) -> bool: try: result = False - user_db = await db[users_collection].find_one( - {"email": email }, - {"address": { "$elemMatch": { "_id": ObjectId(address.id) } } }) - - if user_db is not None: - user_db = user_db["address"] - m = address.model_dump(exclude={"id"}) - update_fields= {} - for key, value in m: - if key in user_db and user_db[key] != value: - update_fields[key]= value - elif key not in user_db: - update_fields[key]= value - - query_filter = {"email": email, "address.id": address.id } - update_op = {"$set" : {"address" : address.model_dump() }} - updated_result = await db[users_collection].update_one(query_filter, update_op) - - if updated_result.modified_count > 0: - result = True + updated_address = address.model_dump() + id = updated_address.pop("id") + updated_address.update({ "_id": id }) + + query_filter = {"email": email, "address._id": address.id } + update_op = {"$set" : {"address.$[elem]" : address.model_dump() }} + array_filter = [{"elem._id": address.id}] + updated_result = await db[users_collection].update_one(query_filter, update_op, array_filters = array_filter) + + if updated_result.matched_count > 0 or updated_result.modified_count > 0: + result = True except Exception as e: log.logger.error(e) From e2f6398d352d29098104d6fb1c07291b6ee99054 Mon Sep 17 00:00:00 2001 From: ablogo Date: Wed, 25 Feb 2026 20:26:22 -0600 Subject: [PATCH 3/6] Improve TOTP --- src/models/totp_model.py | 5 +++++ src/routers/admin/security_router.py | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 src/models/totp_model.py diff --git a/src/models/totp_model.py b/src/models/totp_model.py new file mode 100644 index 0000000..d0bbc14 --- /dev/null +++ b/src/models/totp_model.py @@ -0,0 +1,5 @@ +from enum import Enum + +class TOTPOptions(str, Enum): + ascii = "ascii" + hex = "hex" \ No newline at end of file diff --git a/src/routers/admin/security_router.py b/src/routers/admin/security_router.py index 9bc1f25..b67ac68 100644 --- a/src/routers/admin/security_router.py +++ b/src/routers/admin/security_router.py @@ -4,6 +4,7 @@ from src.dependency_injection.containers import Container from src.middlewares.auth_roles_jwt import JWTCustom +from src.models.totp_model import TOTPOptions import src.services.totp_service as securitySvc oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in") @@ -13,10 +14,11 @@ prefix="/security") totp_dependency = Annotated[securitySvc.TOTP, Depends(Provide[Container.totp])] -@router.get("/2fa-now") +@router.get("/2fa-now/{options}") @inject -async def get_2f_code(totp: totp_dependency, secret: Optional[str] = None): - totp_code = await totp.now(secret) +async def get_2f_code(totp: totp_dependency, options: TOTPOptions, secret: Optional[str] = None): + is_ascii = True if options is TOTPOptions.ascii else False + totp_code = await totp.now(secret, is_ascii) if totp_code: return Response(content=totp_code, media_type="plain/text") else: From 7971b55ccc510304f612628ef1e8cc3cac21c2dd Mon Sep 17 00:00:00 2001 From: ablogo Date: Wed, 25 Feb 2026 20:27:56 -0600 Subject: [PATCH 4/6] Improve oauth google --- src/dependency_injection/containers.py | 1 + src/routers/oauth2_router.py | 4 ++-- src/services/jwt_service.py | 2 +- src/services/oauth_google_service.py | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/dependency_injection/containers.py b/src/dependency_injection/containers.py index e997339..419c71c 100644 --- a/src/dependency_injection/containers.py +++ b/src/dependency_injection/containers.py @@ -17,6 +17,7 @@ class Container(containers.DeclarativeContainer): "src.routers.users_router", "src.routers.admin.users_router", "src.routers.admin.security_router", + "src.routers.oauth2_router", "src.services.user_service", "src.services.login_service", "src.services.jwt_service", diff --git a/src/routers/oauth2_router.py b/src/routers/oauth2_router.py index 89fc312..5c011b7 100644 --- a/src/routers/oauth2_router.py +++ b/src/routers/oauth2_router.py @@ -6,7 +6,7 @@ from src.middlewares.auth_jwt import JWTCustom from src.models.user_model import User -from src.services.jwt_service import get_token_unverify_signature_data +from src.services.jwt_service import get_token_claims_unverify_signature from src.services.mongodb_service import MongoAsyncService from src.dependency_injection.containers import Container from src.services.login_service import external_login @@ -54,7 +54,7 @@ async def get_google_response(db: db_dependency, log: log_dependency, request: R google_token = await get_auth_response(request.url.__str__()) if google_token: - token_data = await get_token_unverify_signature_data(google_token.id_token.__str__()) # type: ignore + token_data = await get_token_claims_unverify_signature(google_token.id_token.__str__()) # type: ignore user = await get_user(token_data['email'], db.get_db()) if user is None: diff --git a/src/services/jwt_service.py b/src/services/jwt_service.py index afde78a..dd0080f 100644 --- a/src/services/jwt_service.py +++ b/src/services/jwt_service.py @@ -96,7 +96,7 @@ async def get_email(token: str, crypto = crypto_service, log = log_service): raise e @inject -async def get_token_unverify_signature_data(token: str, crypto = crypto_service, log = log_service): +async def get_token_claims_unverify_signature(token: str, crypto = crypto_service, log = log_service): try: payload = jwt.decode(token, options = {"verify_signature" : False}) return payload diff --git a/src/services/oauth_google_service.py b/src/services/oauth_google_service.py index b51e8a6..00508e0 100644 --- a/src/services/oauth_google_service.py +++ b/src/services/oauth_google_service.py @@ -13,7 +13,7 @@ async def get_auth_url(log = log_service): try: flow = google_auth_oauthlib.flow.Flow.from_client_config( {"web":{"client_id":os.environ["GOOGLE_OAUTH_CLIENT"],"project_id":os.environ["GOOGLE_OAUTH_ID"],"auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":os.environ["GOOGLE_OAUTH_SECRET"],"javascript_origins":os.environ["GOOGLE_OAUTH_JS_ORIGINS"].split(',') if os.environ["GOOGLE_OAUTH_JS_ORIGINS"] else []}}, - scopes = ['https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'openid'] + scopes = os.environ["GOOGLE_OAUTH_SCOPES"].split(',') if os.environ["GOOGLE_OAUTH_SCOPES"] else [] ) flow.redirect_uri = os.environ["GOOGLE_OAUTH_REDIRECT_RESPONSE"] @@ -34,7 +34,7 @@ async def get_auth_response(url: str, log = log_service): credentials = None flow = google_auth_oauthlib.flow.Flow.from_client_config( {"web":{"client_id":os.environ["GOOGLE_OAUTH_CLIENT"],"project_id":os.environ["GOOGLE_OAUTH_ID"],"auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":os.environ["GOOGLE_OAUTH_SECRET"],"javascript_origins":os.environ["GOOGLE_OAUTH_JS_ORIGINS"].split(',') if os.environ["GOOGLE_OAUTH_JS_ORIGINS"] else []}}, - scopes = ['https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'openid'] + scopes = os.environ["GOOGLE_OAUTH_SCOPES"].split(',') if os.environ["GOOGLE_OAUTH_SCOPES"] else [] ) flow.redirect_uri = os.environ["GOOGLE_OAUTH_REDIRECT_RESPONSE"] From dcd84c0c96ce876edc54bec047ab1494be031b8e Mon Sep 17 00:00:00 2001 From: ablogo Date: Wed, 25 Feb 2026 20:47:03 -0600 Subject: [PATCH 5/6] Adding OAuth information --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dbd1ac0..f3c6f4a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ It is a microservice for user administration and authenication, using JWT tokens ## Requirements - Python 3.12+ - FastApi 0.124+ +- Google account and activate [Google cloud](https://console.cloud.google.com) to obtain OAuth2 config values (Optional) > [!IMPORTANT] > It is necessary to complete the configuration file(.env), and create the PEM files and place them in the root folder @@ -40,7 +41,7 @@ openssl rsa -in private_key.pem -pubout -out public_key.pem ``` 5. Set configuration file (.env) -The microservice uses mongoDB as its database, so the connection string and other configurations (mongodb, JWT, CORS, logs) must be included +The microservice uses mongoDB as its database, so the connection string and other configurations (mongodb, JWT, CORS, logs, Google OAuth2) must be included 6. Run local development server ```bash @@ -67,4 +68,4 @@ docker run -p 8000:80 --env-file .env auth-service:latest ``` > [!NOTE] -> Since the project is used for learning, it does not strictly follow the concept of microservices, where each microservice should have its own realm of responsability +> Since the project is used for learning, it does not strictly follow the concept of microservices, where each microservice should have its own realm of responsability and use different approaches. From e27e5eaeef9e39e77e3ab68fdc507c3ac3cc1551 Mon Sep 17 00:00:00 2001 From: ablogo Date: Fri, 10 Apr 2026 12:16:31 -0600 Subject: [PATCH 6/6] improves in user service and requirements file --- .env | 6 +-- .gitignore | 9 ++++- requirements.txt | 53 ++++++++++++++++++++------ src/main.py | 3 +- src/routers/admin/users_router.py | 20 +++++----- src/routers/users_router.py | 62 +++++++++++++++++++------------ src/services/otp_service.py | 2 +- 7 files changed, 104 insertions(+), 51 deletions(-) diff --git a/.env b/.env index 0f88d90..be25dfe 100644 --- a/.env +++ b/.env @@ -18,6 +18,6 @@ TOTP_TIME_STEP=30 GOOGLE_OAUTH_ID= GOOGLE_OAUTH_CLIENT= GOOGLE_OAUTH_SECRET= -GOOGLE_OAUTH_REDIRECT_RESPONSE= -GOOGLE_OAUTH_JS_ORIGINS= -GOOGLE_OAUTH_SCOPES= \ No newline at end of file +GOOGLE_OAUTH_REDIRECT_RESPONSE=https://127.0.0.1:8000/auth/google-response +GOOGLE_OAUTH_JS_ORIGINS="http://127.0.0.1:8000,http://localhost:8081" +GOOGLE_OAUTH_SCOPES="https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,openid" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 523b8d5..22ab278 100755 --- a/.gitignore +++ b/.gitignore @@ -40,12 +40,19 @@ __pycache__/ *py.cover #Js -*node_modules +package-lock.json #VSCode *.vs *.vscode +#Expo +.expo + +#npm +*node_modules +dist/ + # Visual Studio *appsettings.Development.json *.vspscc diff --git a/requirements.txt b/requirements.txt index ea18575..9a32ff9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,42 @@ -pip -fastapi -uvicorn[standard] -pymongo[srv] -log2mongo -python-dotenv -python-multipart -pyjwt +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.0 bcrypt==4.0.1 -passlib[bcrypt] -cryptography -dependency-injector -setuptools \ No newline at end of file +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +cryptography==46.0.3 +dependency-injector==4.48.3 +dnspython==2.8.0 +fastapi==0.127.0 +google-auth==2.48.0 +google-auth-oauthlib==1.2.4 +h11==0.16.0 +httptools==0.7.1 +idna==3.11 +log2mongo==0.1.0 +oauthlib==3.3.1 +passlib==1.7.4 +pyasn1==0.6.2 +pyasn1_modules==0.4.2 +pycparser==2.23 +pydantic==2.12.5 +pydantic_core==2.41.5 +PyJWT==2.10.1 +pymongo==4.15.5 +python-dotenv==1.2.1 +python-multipart==0.0.21 +PyYAML==6.0.3 +requests==2.32.5 +requests-oauthlib==2.0.0 +rsa==4.9.1 +setuptools==80.9.0 +starlette==0.50.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +uvicorn==0.40.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==15.0.1 diff --git a/src/main.py b/src/main.py index 944fd7a..808cb36 100644 --- a/src/main.py +++ b/src/main.py @@ -1,3 +1,4 @@ +from datetime import datetime from fastapi import FastAPI from contextlib import asynccontextmanager from fastapi.middleware.cors import CORSMiddleware @@ -47,4 +48,4 @@ async def shutdown(): #Root route @app.get("/") async def main(): - return { "message": "Learning python" } + return datetime.now() diff --git a/src/routers/admin/users_router.py b/src/routers/admin/users_router.py index 94c4421..2c78cd1 100644 --- a/src/routers/admin/users_router.py +++ b/src/routers/admin/users_router.py @@ -69,8 +69,7 @@ async def update_user(model: User, db: db_dependency, log: log_dependency, respo @inject async def delete_user(email: str, db: db_dependency, log: log_dependency, response: Response): try: - result = await uSvc.deleted_user(db, email) - if result: + if await uSvc.deleted_user(db, email): response.status_code = 200 return response.status_code = 400 @@ -81,8 +80,7 @@ async def delete_user(email: str, db: db_dependency, log: log_dependency, respon @inject async def update_password(email: str, password: str, db: db_dependency, log: log_dependency, response: Response): try: - result = await change_password(db, email, password) - if result: + if await change_password(db, email, password): response.status_code = 200 return response.status_code = 400 @@ -91,10 +89,12 @@ async def update_password(email: str, password: str, db: db_dependency, log: log @router.post("/users/address") @inject -async def create_address(email: str, address: Address, db: db_dependency, log: log_dependency): +async def create_address(email: str, address: Address, db: db_dependency, log: log_dependency, response: Response) -> Address | None: try: new_address = await insert_address(email, address, db) - return new_address + if new_address: + return new_address + response.status_code = 400 except Exception as e: log.logger.error(e) @@ -105,7 +105,7 @@ async def get_addresses(email: str, db: db_dependency, log: log_dependency, resp addresses = await get_address(db, email) if addresses: return addresses - response.status_code = 400 + response.status_code = 404 except Exception as e: log.logger.error(e) @@ -113,9 +113,9 @@ async def get_addresses(email: str, db: db_dependency, log: log_dependency, resp @inject async def update_address(email: str, address: Address, db: db_dependency, log: log_dependency, response: Response): try: - result = await uSvc.update_address(db, email, address) - if result: + if await uSvc.update_address(db, email, address): response.status_code = 200 - response.status_code = 404 + return + response.status_code = 400 except Exception as e: log.logger.error(e) \ No newline at end of file diff --git a/src/routers/users_router.py b/src/routers/users_router.py index 8b41d92..e076182 100644 --- a/src/routers/users_router.py +++ b/src/routers/users_router.py @@ -17,27 +17,30 @@ oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in") router = APIRouter( - tags=["users"], + tags=["user"], 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") @inject -async def get_user(db: db_dependency, email: Annotated[str, Depends(verify_token_from_requests)]) -> User | None: +async def get_user(db: db_dependency, email: Annotated[str, Depends(verify_token_from_requests)]): user = await uSvc.get_user(email, db.get_db()) - return user + if user: + return user + else: + return Response(status_code = status.HTTP_404_NOT_FOUND) @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(email, db.get_db(), file = file, content_type = file.content_type) if result: - return JSONResponse("", status.HTTP_200_OK) + return Response(status_code= status.HTTP_200_OK) else: - return JSONResponse(None, status.HTTP_400_BAD_REQUEST) + return Response(status_code= status.HTTP_400_BAD_REQUEST) @router.get("/user/img", response_model_by_alias = False) @inject @@ -46,19 +49,24 @@ async def get_user_image(db: db_dependency, email: Annotated[str, Depends(verify if result and result.picture: return StreamingResponse(io.BytesIO(result.picture), media_type= result.content_type) else: - return JSONResponse("", status.HTTP_400_BAD_REQUEST) + return Response(status_code= status.HTTP_400_BAD_REQUEST) @router.put("/user") @inject async def update_user(db: db_dependency, model: User, email: Annotated[str, Depends(oauth2_scheme)]): - result = await uSvc.update_user(db.get_db(), model) - return result + user = await uSvc.update_user(db.get_db(), model) + if user: + return user + else: + return Response(status_code=404) @router.post("/user/change-password") @inject async def update_password(db: db_dependency, password: str, email: Annotated[str, Depends(oauth2_scheme)]): - result = await change_password(db.get_db(), email, password) - return result + if await change_password(db.get_db(), email, password): + return Response(status_code=status.HTTP_200_OK) + else: + return Response(status_code=status.HTTP_400_BAD_REQUEST) @router.get("/user/2fa") @inject @@ -67,33 +75,41 @@ async def get_2f_code(email: Annotated[str, Depends(oauth2_scheme)], totp: totp_ if totp_code: return Response(content=totp_code, media_type="plain/text") else: - return Response(status_code=404) + return Response(status_code = status.HTTP_404_NOT_FOUND) @router.get("/user/2fa-verify") @inject -async def get_2f_code_verify(code: str, email: Annotated[str, Depends(oauth2_scheme)], totp: totp_dependency): +async def verify_2f_code(code: str, email: Annotated[str, Depends(oauth2_scheme)], totp: totp_dependency): if await totp.verify(code, email, is_value_ascii = True): - return Response(status_code=200) + return Response(status_code = status.HTTP_200_OK) else: - return Response(status_code=401) + return Response(status_code = status.HTTP_401_UNAUTHORIZED) @router.post("/user/address") @inject -async def create_address(db: db_dependency, address: Address, email: Annotated[str, Depends(oauth2_scheme)]): - result = await insert_address(email, address, db.get_db()) - return result +async def create_address(db: db_dependency, model: Address, email: Annotated[str, Depends(oauth2_scheme)]): + address = await insert_address(email, model, db.get_db()) + if address: + return address + else: + return Response(status_code=status.HTTP_400_BAD_REQUEST) @router.get("/user/address") @inject -async def get_addresses(db: db_dependency, email: Annotated[str, Depends(oauth2_scheme)] ) -> list[Address] | None: - result = await get_address(db.get_db(), email) - return result +async def get_addresses(db: db_dependency, email: Annotated[str, Depends(oauth2_scheme)]): + addresses = await get_address(db.get_db(), email) + if addresses: + return addresses + else: + return Response(status_code=404) @router.put("/user/address") @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 + if await uSvc.update_address(db.get_db(), email, address): + return Response(status_code=status.HTTP_200_OK) + else: + return Response(status_code=status.HTTP_400_BAD_REQUEST) @router.post("/user/change-status") @inject diff --git a/src/services/otp_service.py b/src/services/otp_service.py index 7a4b277..5171fe7 100644 --- a/src/services/otp_service.py +++ b/src/services/otp_service.py @@ -10,7 +10,7 @@ def get_hmac_sha(self, secret: bytes, moving_factor: bytes, digest: str): def to_bytes(self, value: str, is_value_ascii: bool = False, is_value_hex: bool = False): if is_value_ascii: - return value.encode('ascii') + return value.encode() elif is_value_hex: return bytes(bytearray.fromhex(value)) else: