From a7c2319f136ef77b1e31919a1dbc28f95379399f Mon Sep 17 00:00:00 2001 From: xds Date: Tue, 10 Feb 2026 14:06:37 +0300 Subject: [PATCH] feat: Implement external generation import API secured by HMAC-SHA256 signature verification. --- .env | 3 +- .vscode/launch.json | 4 +- .../generation_router.cpython-313.pyc | Bin 8079 -> 10511 bytes api/endpoints/generation_router.py | 65 ++++++++++- api/models/ExternalGenerationDTO.py | 37 ++++++ .../generation_service.cpython-313.pyc | Bin 21826 -> 26126 bytes api/service/generation_service.py | 110 ++++++++++++++++-- tests/test_external_import.py | 63 ++++++++++ utils/external_auth.py | 46 ++++++++ 9 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 api/models/ExternalGenerationDTO.py create mode 100755 tests/test_external_import.py create mode 100644 utils/external_auth.py diff --git a/.env b/.env index ff9fabb..11d172a 100644 --- a/.env +++ b/.env @@ -7,4 +7,5 @@ MINIO_ENDPOINT=http://31.59.58.220:9000 MINIO_ACCESS_KEY=admin MINIO_SECRET_KEY=SuperSecretPassword123! MINIO_BUCKET=ai-char -MODE=production \ No newline at end of file +MODE=production +EXTERNAL_API_SECRET=Gt9TyQ8OAYhcELh2YCbKjdHLflZGufKHJZcG338MQDW \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index bb2aa23..4af4fe0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,9 @@ "main:app", "--reload", "--port", - "8090" + "8090", + "--host", + "0.0.0.0" ], "jinja": true, "justMyCode": true diff --git a/api/endpoints/__pycache__/generation_router.cpython-313.pyc b/api/endpoints/__pycache__/generation_router.cpython-313.pyc index 1249725b942613f5cca2253d266ab200fcb51a60..93dae8db56dba4cd73ad30ba37422f73811cf5ee 100644 GIT binary patch delta 4817 zcmb6cS!^4}b(VW@c~d-0i4?7+EL&n^Nw!7VmX9Q|b&@{0qT!TX5KaP-aKA<>QdbPz-BWe7%!P$&%WDo+1^Nfbh*8=809e^HDQy0L?qHN zbJT(@I?lwbQ5&}DI2*G^9oV7cT+A75zztCsc17LTt<#J#Pt=ROI?l&7MSa*8ZN!by zCfpSDV}CS&13J$XYmNqSP{+-&&CwQox<#K@VnQ^8LppAawc^%fYkZhkyMDByWLqCq zq2aAyur0PN+K$@=lw?|wXcrwll<4d?;ErTRTvShb+$S57e$mx$NLpZTLv61mO7^`>5bUMt!pjf5yCs_0oj)HJub|TG=mY_la1?+V4ZR}5FZ%O zvDN_4zq1w+HQ{j_4L{xnIO->o+v^Szsl3>^LFz6dm0TY6C$#y+M;zm0JX(99)_DV0 zt80VK-dZGM-4FGGCs#}C5qI<(z=xXTo#0Ewhg}=wML^z*#0NH<48^AjHq`srG*}ax z2q#81$lNn!4fm7&@1hXA9YUZsGTgjdC}_O${3~ zZWhav*>f6Gkfzkn*>RsWJ%_QJE2QQW*vVnJFo$z#W?_g6Q8wnZt(t2n!KjMy6>6*c zg)zL#ON6m_GkkCh0RjOb0Ge}3&dFFR$eGkE&d?VVK0#Iy;D?{`O8|dI z=h2E;y~clJ-wIQW%g#tsvXW-hH%zZMm;~iUquUY6u?aSAQZI9EHEeEVxdaops8o|1 z9DmLnrk+-h8k=m!(+&`!kAzYEwK?HxgGrdj+u(!S3FuH8EPX!RdL9#nxQhURfRK91 z(t5BHPz6#63Dto?J<=K~zN99>rIcEN52!zNb$Hhw zZcYE9&r5Arzi>5CBMT;XhaptgJ8v6Er)5PEGIA~}XDSwOc$*scw(r$75aD44i$sS& zfPQOe2!O)Cltf(*Ii4U)0!}Tw>%B(psq0wdn?d)j5?Zd}!Ky=r_LmdeO3RyL6(om>asjkmQ}P>Wa%-m=J?Gh!N&t6Z)q+b zBs;_Q+RoZHXG*Y8T0J{Z!Zb+jrgCQ3Ma8#}U0vqds;@=BdYatkiVtM3HQr7lwzEnj zlG+fDj@=5O)1+$Y!-%9_s4bR_u_-zd7CY)S)U>UOkW*|pO|Gmy5_QkX`l`2voMpf= zNll`IbPJlKKx^u8T5IwuUp3`ni>^o5z>Dtx&xSqpBo+3~zi1xQ@3kNas|80OHBFFm zf_$zZDOv~Noxcr^DES41kO4wN$cNOwo6-#);M8ncJk%boB>+XP*vGT<+T7IM47A<=0ke!6$`1FJ!^`ikd~&W z<&5SYIyF8NKQ?eAHE?1qB@P{q4~@^aK7=Ub@&zHC&rN2hAcZs86$+twzo@%Yr&oNC zG0tPXV9oCs%N5T;u4h*L5hn9^H9~8mSE&@)%S3R)SZ>3%d3LTa*|m3m^RSc!!-Ya# zn3b?13%KH~a3-I5d7qGdlWgBq+YZbkiA9w=zioAQMcueI2E88dh3WYtI zA+Koexk7eY>8?kV93TERoR20*v)S&Md`1TL+A}G_6Ap(mIDQ$Y0L{sKBuwMc`2i4Tt-fVe9csENc2Lj`75qG?3l3&6+xa^ zw=0#Io0)~@3yN6v#YhtD3<1Q2ihVL*8mGu`{PtFXGF#%_Zeq{y@g*@yJJczY_%tStBqMtG5*J7{6O3vQfOr*>qSJT_gmz&ki zcPuUE2Vrh^UV8T8vnAj5C0p0|;lEfK?(_Cb){EAXxBoW3@4h{7l`ch2EZR?&_>-Sn z0#^pFp1ot)y=d7@*4Z!GOTMw&d=%Cd$--=%&J5x*k?j=Xh1?HY_$D(iVwFrO<*0PmlPf&LQU3UVbi-FNw%}araJKl*4=KGG& zqT|3j(K~JZi*5Z&ZTn$X;twqEL{{O;l~!c+K0pRgLTo7#qkVnXq-)|@+jk%NP%NE{ z|9RrpXep9dv`>`yiGP<_vg@m5E86r7_2p+44-s=h7o$r#_@4A0NEx`zAJQXmEq^0@ zD|CJG{ZpmLu|@mw5`SEmaf14fl7Z-dzkis|Qh%TZ8Rm}z+lP7TPvMS14qbP7hIn*+ z8#Tl+*V~%`zd;*-dxJ9%(ew>x_u!NCjVD>aZ_+f-Z*ml@xyhS{S^B1{dl=ClAQtcs zSi*(A=rHI?)9P;V&_ih)rafo<#OZNhy$|ggPP3DA)oV%>^V3b`<39YT;0@ zl`v>niOFr&>|{?yo|SXZdZk~+b|R59#MnvLCM?h9mF|p`#|>oOye98Q)UW&vUKd&G zCcr}g=?U;ARS2|&eSm5W)grN4urc{dp_%HY;U;w^&`NDn-wZU4_z920`8jw~GzL@` zAR)8qg5LgDIyKTjrbFu6&7qM`Bl}ASjx0rvE*m+j_v&}c z2tMz>q<`-A4wh+{LIQX1Dic0nA|+8P1b;vy!vIw_5VA}CytyNOFW6Sv+P4(ky=<~k zy?33BSKOCvWg75LoSrwITXYI#7A7Dh*j+Xfj7PpenJg}w5$9R407k?f0 veCFPcJ*B;)OFPDvStI4V+FM5Ox!dz}nTGEt;jR@zCNg8xvH?*1I>i2e)F)5V delta 2726 zcmai0%TF6;5a0E#pZIMI215J*6E+VZ#VDacp?T0G>m~#i@?6PS_J%kCg863Vn{VbfkMDzf zllMmbt3IEH!S9D}J}!OV`oJG!Cm-zEvzTBcM&hzA(G6~s=d&KM37Smap7n}8@R__L z>lXtM5Q7jDLl81`&TLqWK*Z!-*=8{cQLzPD#8zk(V-OSDpv~NKXWPXN=rDOtc8Ayr zohILuUEC?gA#MuZYyuK;V)7`V-29~&IW@#uI_N^czHGOchIE3F`4l7hr9eL`yU{AW zZq?69!C?-1HGfr3qnBAnhWaQe5Yl&}>J#kT;| zM<-*GcFRINa|+;+_G}3-uuXuy>(w-q`u{f6^WSI#zkRkDkF;+Kko^cmX&8nzRubg+ zQC#R;)j@>ROX1|QUtfJ-CYKZ+)1veZZ zKXqqjnc;QPkvAC8KMCv!5L^(~$>7rCz+bkR!z7Oqd4tFiBmxV^Y4L3o#)!<7%Ga@9 zg{yhU7c>iL51lZp^d@m9h>Q_AN#qofLwZ-FH(GD~CQLJtXop$CKxIK~Ob90N=-ATz zNUtqf$M=r!NTHypY67RQq!cZr4(QgOMt2=Fqf$T|Hi_cWoSDH44hv<@7d6dn8Jwd{ zBIlM4wtUSVsK;oifz7Oe)eMJ~)kcz5etJ9>Z~xEK$bVMH{H&T^BAG~De>e7(U$6oF zrGyS71)F|9xyae3ZF)F0S2WAdH`F~;ROS>-Y3R0DSAp9|({NfgMU_K`^(18qVG?9i z!cLR;EAP>NPff8y`e;{w(ePi%t6IKN>Z@Q|=T&f{nGr@6U{p=%1La0lQ8g7jw85bz zG!f^gGxay&ZSbNJ=m`KnZM#9imi86%3lPwM>Pkg}v=t%}CPEhsn)OI`w-80v2+c2G z4r<>C>s`Q^26~1JcO40|>NDM`MY^d*n1bVLR9PsMHMK8a)viK}HXN`}#nCi)nkVxW zu^LDB5hloj!xIKyEEP2CBG8~&<3Q~(99AWb2;H~bYXh0wnep7Q@iS9nXQrr@gUlDq zDjG7@)pv~`?&_R!7^i3 z)cJnrav=He=8yfS9^=o&ndSVAm5Z=^LA~3#%xX_B9^Sc>n+a diff --git a/api/endpoints/generation_router.py b/api/endpoints/generation_router.py index 1352c92..85c4f61 100644 --- a/api/endpoints/generation_router.py +++ b/api/endpoints/generation_router.py @@ -1,6 +1,6 @@ from typing import List, Optional -from fastapi import APIRouter, UploadFile, File, Form +from fastapi import APIRouter, UploadFile, File, Form, Header, HTTPException from fastapi.params import Depends from starlette.requests import Request @@ -20,13 +20,14 @@ logger = logging.getLogger(__name__) from api.endpoints.auth import get_current_user -router = APIRouter(prefix='/api/generations', tags=["Generation"], dependencies=[Depends(get_current_user)]) +router = APIRouter(prefix='/api/generations', tags=["Generation"]) @router.post("/prompt-assistant", response_model=PromptResponse) async def ask_prompt_assistant(prompt_request: PromptRequest, request: Request, generation_service: GenerationService = Depends( - get_generation_service)) -> PromptResponse: + get_generation_service), + current_user: dict = Depends(get_current_user)) -> PromptResponse: logger.info(f"ask_prompt_assistant called with prompt length: {len(prompt_request.prompt)}. Linked assets: {len(prompt_request.linked_assets) if prompt_request.linked_assets else 0}") generated_prompt = await generation_service.ask_prompt_assistant(prompt_request.prompt, prompt_request.linked_assets) return PromptResponse(prompt=generated_prompt) @@ -36,7 +37,8 @@ async def ask_prompt_assistant(prompt_request: PromptRequest, request: Request, async def prompt_from_image( prompt: Optional[str] = Form(None), images: List[UploadFile] = File(...), - generation_service: GenerationService = Depends(get_generation_service) + generation_service: GenerationService = Depends(get_generation_service), + current_user: dict = Depends(get_current_user) ) -> PromptResponse: logger.info(f"prompt_from_image called. Images count: {len(images)}. Prompt provided: {bool(prompt)}") images_bytes = [] @@ -111,8 +113,59 @@ async def get_running_generations(request: Request, return await generation_service.get_running_generations(user_id=user_id_filter, project_id=project_id) -@router.delete("/{generation_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_user)]) -async def delete_generation(generation_id: str, generation_service: GenerationService = Depends(get_generation_service)): + + +@router.post("/import", response_model=GenerationResponse) +async def import_external_generation( + request: Request, + generation_service: GenerationService = Depends(get_generation_service), + x_signature: str = Header(..., alias="X-Signature") +) -> GenerationResponse: + """ + Import a generation from an external source. + Requires server-to-server authentication via HMAC signature. + """ + import os + from utils.external_auth import verify_signature + from api.models.ExternalGenerationDTO import ExternalGenerationRequest + + logger.info("import_external_generation called") + + # Get raw request body for signature verification + body = await request.body() + + # Verify signature + secret = os.getenv("EXTERNAL_API_SECRET") + if not secret: + logger.error("EXTERNAL_API_SECRET not configured") + raise HTTPException(status_code=500, detail="Server configuration error") + + if not verify_signature(body, x_signature, secret): + logger.warning("Invalid signature for external generation import") + raise HTTPException(status_code=401, detail="Invalid signature") + + # Parse request body + import json + try: + data = json.loads(body.decode('utf-8')) + external_gen = ExternalGenerationRequest(**data) + except Exception as e: + logger.error(f"Failed to parse request body: {e}") + raise HTTPException(status_code=400, detail=f"Invalid request body: {str(e)}") + + # Import generation + try: + generation = await generation_service.import_external_generation(external_gen) + return GenerationResponse(**generation.model_dump()) + except Exception as e: + logger.error(f"Failed to import external generation: {e}") + raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}") + + +@router.delete("/{generation_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_generation(generation_id: str, + generation_service: GenerationService = Depends(get_generation_service), + current_user: dict = Depends(get_current_user)): logger.info(f"delete_generation called for ID: {generation_id}") deleted = await generation_service.delete_generation(generation_id) if not deleted: diff --git a/api/models/ExternalGenerationDTO.py b/api/models/ExternalGenerationDTO.py new file mode 100644 index 0000000..b9a2c00 --- /dev/null +++ b/api/models/ExternalGenerationDTO.py @@ -0,0 +1,37 @@ +from typing import Optional +from pydantic import BaseModel, Field +from models.enums import AspectRatios, Quality + + +class ExternalGenerationRequest(BaseModel): + """Request model for importing external generations.""" + + prompt: str + tech_prompt: Optional[str] = None + + # Image can be provided as base64 string OR URL (one must be provided) + image_data: Optional[str] = Field(None, description="Base64-encoded image data") + image_url: Optional[str] = Field(None, description="URL to download image from") + + # Generation metadata + aspect_ratio: AspectRatios = AspectRatios.NINESIXTEEN + quality: Quality = Quality.ONEK + + # Optional linking + linked_character_id: Optional[str] = None + created_by: str = Field(..., description="User ID from external system") + project_id: Optional[str] = None + + # Performance metrics + execution_time_seconds: Optional[float] = None + api_execution_time_seconds: Optional[float] = None + token_usage: Optional[int] = None + input_token_usage: Optional[int] = None + output_token_usage: Optional[int] = None + + def validate_image_source(self): + """Ensure at least one image source is provided.""" + if not self.image_data and not self.image_url: + raise ValueError("Either image_data or image_url must be provided") + if self.image_data and self.image_url: + raise ValueError("Only one of image_data or image_url should be provided") diff --git a/api/service/__pycache__/generation_service.cpython-313.pyc b/api/service/__pycache__/generation_service.cpython-313.pyc index f088e87fd844f59b834dec1d341cb99203e25eb6..ea28bd274ebb81da074b762951a1c195ff315d58 100644 GIT binary patch delta 5699 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 delta 2062 zcmYjSYiwIr9Y6p3@*}bH@FTYK@U@*A*N$T+aT>C;&E}?QnroVs+y=4eR_={+m%1c& zcI=KVn((IzDvi=`=q6e|z=x?~h-vE;ngobJrw%bX)=aRnw8Kdyr14?Mj0uns66fAB zie>-K@ArS7bMBQd-hvx1gZpnTmlM&?!bcZMi-)(|0a)IOi*+W?$}DDO4s#+>kizD9 zS-^s9!#3HD?Xm+qWG8l7J}%!Px8hcd^Ldx-#%_xX`8K&7w_Dtn?~prjr^W4gkL<-> zi#zf@*^m8l7w(b+IAGbF`QW@9!XddEcgsDv$1=9$d*wddCx>y^(ye(>j^KzK#Zfti zW0uX8@0TSkS=^l;kmEQmA{7=+kT0})C)C7#Fn5X&PAYBcpwg}iYKo^?=};p|ry5l} zs#o!<_Rs99Qyo+tWCMJ@p#X{^#UDaS*M7z-f^gcn0xeb`u=w4uoPzM3+fpHbVZLc94_ODx+=Cl;j%2*maYO%z9|J5MKHcL@O=hsB$1H7P99HmfrG3j-T)_wCo|-gWGh)tie!hb z7wNh??;x)yW8fxtlh@dGrGuOwoQ2K@wLD~ZR-_H>q%Gxhc|W@tH&T3LBo%4%KRjk? z3lJ@pWrIqHyqF3@H!Tq)w^LW3$N2J4H}JiSur@;)qesbh`aIjW2;{Ypm!o-M@>C|s zi;J*ulsq-y<0F(EGZ{q58`Kk{`V!S%^4H9Hw*O(GM7D;z_yNnUNgh7lhLnBe=J3B9 zi7cZeA7PY1dPIIVeKC4DL_5*(lp*r*$N@;3sgN;pqji8b^^p0m> zlpG&V)fb$iD4M@%tZOx0T+wS@lti)S>f6>B;cOJ%WIr1zh;5*~mu zJu^Qe9+^?57v@f!oO^tJQr!HGS?_^+;oO>DowW3jI9IOetMuW~#o8JDLEXApI9?UC zjSZc)DHnCItkuq8ZEfvY+OJlzLanZJv}zHTHq2)){ojg5E5&nFu~HV-N~>pTrSfV@ ztm)6td_5&<LEsOAk{vUDN;D&fF1k)A;)McL5Sanuw-1UD6ZMy0lV|j!DNQ zWd{nSk646CZE=J)x%5VPb<<&;N`}$|IX97n=Z)`89ArA5H{WG1g<Gcy4wEZ;`)D zegYTB-hmysNPc$kx55Q8J9RYLWVlG6X-C1>oj_tckhtBIY$AS; zZ#o>k_S^Pg(`%fW`j)L8SoXn<5G=*H8{N4W6>m<#Qj&Y~(VXeI*#%2O+|59)pNh8* z!qTwt)}c7%zuK3}(&g7Z>7{Y>wiA}J+}ka=J}Q2bl&IrYJ6{N*TRtfGxm$kD>_ zf2R{PUU zWK+lL?7cA;^?V42`GT8m2vBec4V%s6Zk{?C9=0%s8h)T#Xav255w&~3Pwn?zJhj~Kq%Dmf%^qX2*Ug(Z%!h+rFO~66OgU+SX2P>3 zykf#vOz1b^hh(3egO|uzIRX2PZ_8s~oI3R}z!j1&41}+mA)E<6Fu^MD|GAe61(-LQ zg>ir$64y?`HS(M`+xij>-r<%jl{NfF@;fanJVp)Gy5U+r3B6xp_!W3H`6;qU^J&h{ zKl1UN6`Zvd#1LAzGkvNA=0f6#Q#n?{TD*j oA5& Generation: + """ + Import a generation from an external source. + + Args: + external_gen: ExternalGenerationRequest with generation data and image + + Returns: + Created Generation object + """ + from api.models.ExternalGenerationDTO import ExternalGenerationRequest + + # Validate image source + external_gen.validate_image_source() + + logger.info(f"Importing external generation for user: {external_gen.created_by}") + + # 1. Process image (download or decode) + image_bytes = None + + if external_gen.image_url: + # Download image from URL + logger.info(f"Downloading image from URL: {external_gen.image_url}") + async with httpx.AsyncClient() as client: + response = await client.get(external_gen.image_url, timeout=30.0) + response.raise_for_status() + image_bytes = response.content + elif external_gen.image_data: + # Decode base64 image + logger.info("Decoding base64 image data") + image_bytes = base64.b64decode(external_gen.image_data) + + if not image_bytes: + raise ValueError("Failed to process image data") + + # 2. Generate thumbnail + from utils.image_utils import create_thumbnail + thumbnail_bytes = await asyncio.to_thread(create_thumbnail, image_bytes) + + # 3. Save to S3 + filename = f"external/{external_gen.created_by}/{datetime.now().strftime('%Y%m%d_%H%M%S')}_{random.randint(1000, 9999)}.png" + await self.s3_adapter.upload_file(filename, image_bytes, content_type="image/png") + + # 4. Create Asset + new_asset = Asset( + name=f"External_Generated_{external_gen.linked_character_id or 'no_char'}", + type=AssetType.GENERATED, + content_type=AssetContentType.IMAGE, + linked_char_id=external_gen.linked_character_id, + data=None, # Not storing bytes in DB + minio_object_name=filename, + minio_bucket=self.s3_adapter.bucket_name, + thumbnail=thumbnail_bytes, + created_by=external_gen.created_by, + project_id=external_gen.project_id + ) + + asset_id = await self.dao.assets.create_asset(new_asset) + new_asset.id = str(asset_id) + + logger.info(f"Created asset {asset_id} for external generation") + + # 5. Create Generation record + generation = Generation( + status=GenerationStatus.DONE, + linked_character_id=external_gen.linked_character_id, + aspect_ratio=external_gen.aspect_ratio, + quality=external_gen.quality, + prompt=external_gen.prompt, + tech_prompt=external_gen.tech_prompt, + result_list=[new_asset.id], + result=new_asset.id, + progress=100, + execution_time_seconds=external_gen.execution_time_seconds, + api_execution_time_seconds=external_gen.api_execution_time_seconds, + token_usage=external_gen.token_usage, + input_token_usage=external_gen.input_token_usage, + output_token_usage=external_gen.output_token_usage, + created_by=external_gen.created_by, + project_id=external_gen.project_id, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC) + ) + + gen_id = await self.dao.generations.create_generation(generation) + generation.id = gen_id + + logger.info(f"Created generation {gen_id} from external source") + + return generation + async def delete_generation(self, generation_id: str) -> bool: """ Soft delete generation by marking it as deleted. diff --git a/tests/test_external_import.py b/tests/test_external_import.py new file mode 100755 index 0000000..7c96c7b --- /dev/null +++ b/tests/test_external_import.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Test script for external generation import API. +This script demonstrates how to call the import endpoint with proper HMAC signature. +""" + +import hmac +import hashlib +import json +import requests +import base64 +import os +from dotenv import load_dotenv + +load_dotenv() + +# Configuration +API_URL = "http://localhost:8090/api/generations/import" +SECRET = os.getenv("EXTERNAL_API_SECRET", "your_super_secret_key_change_this_in_production") + +# Sample generation data +generation_data = { + "prompt": "A beautiful sunset over mountains", + "tech_prompt": "High quality landscape photography", + "image_url": "https://picsum.photos/512/512", # Sample image URL + # OR use base64: + # "image_data": "base64_encoded_image_string_here", + "aspect_ratio": "9:16", + "quality": "1k", + "created_by": "external_user_123", + "execution_time_seconds": 5.2, + "token_usage": 1000, + "input_token_usage": 200, + "output_token_usage": 800 +} + +# Convert to JSON +body = json.dumps(generation_data).encode('utf-8') + +# Compute HMAC signature +signature = hmac.new( + SECRET.encode('utf-8'), + body, + hashlib.sha256 +).hexdigest() + +# Make request +headers = { + "Content-Type": "application/json", + "X-Signature": signature +} + +print(f"Sending request to {API_URL}") +print(f"Signature: {signature}") + +try: + response = requests.post(API_URL, data=body, headers=headers) + print(f"\nStatus Code: {response.status_code}") + print(f"Response: {json.dumps(response.json(), indent=2)}") +except Exception as e: + print(f"Error: {e}") + if hasattr(e, 'response'): + print(f"Response text: {e.response.text}") diff --git a/utils/external_auth.py b/utils/external_auth.py new file mode 100644 index 0000000..1757c8a --- /dev/null +++ b/utils/external_auth.py @@ -0,0 +1,46 @@ +import hmac +import hashlib +import os +from fastapi import Header, HTTPException +from typing import Optional + +def verify_signature(body: bytes, signature: str, secret: str) -> bool: + """ + Verify HMAC-SHA256 signature. + + Args: + body: Raw request body bytes + signature: Signature from X-Signature header + secret: Shared secret key + + Returns: + True if signature is valid, False otherwise + """ + expected_signature = hmac.new( + secret.encode('utf-8'), + body, + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected_signature) + + +async def verify_external_signature( + x_signature: Optional[str] = Header(None, alias="X-Signature") +): + """ + FastAPI dependency to verify external API signature. + + Raises: + HTTPException: If signature is missing or invalid + """ + if not x_signature: + raise HTTPException( + status_code=401, + detail="Missing X-Signature header" + ) + + # Note: We'll need to access the raw request body in the endpoint + # This dependency just validates the header exists + # Actual signature verification happens in the endpoint + return x_signature