ar07xd commited on
Commit
26f3f24
·
verified ·
1 Parent(s): 8bc3bda

Sync from GitHub via hub-sync

Browse files
api/v1/auth.py CHANGED
@@ -1,6 +1,6 @@
1
  from __future__ import annotations
2
 
3
- from fastapi import APIRouter, Depends, HTTPException, status
4
  from loguru import logger
5
  from sqlalchemy.exc import IntegrityError
6
  from sqlalchemy.orm import Session
@@ -11,6 +11,7 @@ from db.database import get_db
11
  from db.models import User
12
  from schemas.auth import LoginBody, RegisterBody, TokenResponse, UserOut
13
  from services.auth_service import authenticate, create_access_token, register_user
 
14
 
15
  router = APIRouter(prefix="/auth", tags=["auth"])
16
 
@@ -23,23 +24,31 @@ def _token_response(user: User) -> TokenResponse:
23
  )
24
 
25
 
 
26
  @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
27
- def register(body: RegisterBody, db: Session = Depends(get_db)) -> TokenResponse:
28
  try:
29
  user = register_user(db, body.email, body.password, body.name)
30
  except IntegrityError:
31
  db.rollback()
 
 
32
  raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
33
- logger.info(f"Registered user id={user.id} email={user.email}")
 
34
  return _token_response(user)
35
 
36
 
 
37
  @router.post("/login", response_model=TokenResponse)
38
- def login(body: LoginBody, db: Session = Depends(get_db)) -> TokenResponse:
39
  user = authenticate(db, body.email, body.password)
40
  if not user:
 
 
41
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
42
- logger.info(f"Login user id={user.id} email={user.email}")
 
43
  return _token_response(user)
44
 
45
 
 
1
  from __future__ import annotations
2
 
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
4
  from loguru import logger
5
  from sqlalchemy.exc import IntegrityError
6
  from sqlalchemy.orm import Session
 
11
  from db.models import User
12
  from schemas.auth import LoginBody, RegisterBody, TokenResponse, UserOut
13
  from services.auth_service import authenticate, create_access_token, register_user
14
+ from services.rate_limit import ANON_AUTH_LOGIN, ANON_AUTH_REGISTER, limiter
15
 
16
  router = APIRouter(prefix="/auth", tags=["auth"])
17
 
 
24
  )
25
 
26
 
27
+ @limiter.limit(ANON_AUTH_REGISTER)
28
  @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
29
+ def register(body: RegisterBody, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
30
  try:
31
  user = register_user(db, body.email, body.password, body.name)
32
  except IntegrityError:
33
  db.rollback()
34
+ client_host = request.client.host if request.client else "unknown"
35
+ logger.warning(f"Registration rejected email={body.email} ip={client_host}")
36
  raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
37
+ client_host = request.client.host if request.client else "unknown"
38
+ logger.info(f"Registered user id={user.id} email={user.email} ip={client_host}")
39
  return _token_response(user)
40
 
41
 
42
+ @limiter.limit(ANON_AUTH_LOGIN)
43
  @router.post("/login", response_model=TokenResponse)
44
+ def login(body: LoginBody, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
45
  user = authenticate(db, body.email, body.password)
46
  if not user:
47
+ client_host = request.client.host if request.client else "unknown"
48
+ logger.warning(f"Login failed email={body.email} ip={client_host}")
49
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
50
+ client_host = request.client.host if request.client else "unknown"
51
+ logger.info(f"Login user id={user.id} email={user.email} ip={client_host}")
52
  return _token_response(user)
53
 
54
 
api/v1/report.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from pathlib import Path
4
 
5
- from fastapi import APIRouter, Depends, HTTPException, Request, status
6
  from fastapi.responses import FileResponse
7
  from loguru import logger
8
  from sqlalchemy.orm import Session
 
2
 
3
  from pathlib import Path
4
 
5
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
6
  from fastapi.responses import FileResponse
7
  from loguru import logger
8
  from sqlalchemy.orm import Session
api/v1/stats.py CHANGED
@@ -3,13 +3,15 @@ from fastapi import APIRouter, Depends
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy import func
5
 
 
6
  from db.database import get_db
7
  from db.models import AnalysisRecord
 
8
 
9
  router = APIRouter(prefix="/stats", tags=["stats"])
10
 
11
  @router.get("/recent")
12
- def get_recent_stats(db: Session = Depends(get_db)):
13
  """Phase 20.4 — Live Engagement Counter."""
14
  twenty_four_hours_ago = datetime.utcnow() - timedelta(hours=24)
15
  count = db.query(func.count(AnalysisRecord.id)).filter(AnalysisRecord.created_at >= twenty_four_hours_ago).scalar()
 
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy import func
5
 
6
+ from api.deps import get_current_user
7
  from db.database import get_db
8
  from db.models import AnalysisRecord
9
+ from db.models import User
10
 
11
  router = APIRouter(prefix="/stats", tags=["stats"])
12
 
13
  @router.get("/recent")
14
+ def get_recent_stats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
15
  """Phase 20.4 — Live Engagement Counter."""
16
  twenty_four_hours_ago = datetime.utcnow() - timedelta(hours=24)
17
  count = db.query(func.count(AnalysisRecord.id)).filter(AnalysisRecord.created_at >= twenty_four_hours_ago).scalar()
config.py CHANGED
@@ -1,4 +1,5 @@
1
  import json
 
2
  from urllib.parse import parse_qsl, urlencode
3
  from typing import Any
4
  from pydantic import field_validator, model_validator
@@ -193,10 +194,24 @@ class Settings(BaseSettings):
193
  EXIFTOOL_PATH: str = "" # full path to ExifTool binary; empty = metadata write disabled
194
 
195
  # Auth
196
- JWT_SECRET_KEY: str = "change-me-in-production"
 
197
  JWT_ALGORITHM: str = "HS256"
198
  JWT_EXPIRATION_MINUTES: int = 1440
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  @field_validator("ALLOWED_IMAGE_TYPES", mode="before")
201
  @classmethod
202
  def assemble_allowed_image_types(cls, v: Any) -> list[str]:
 
1
  import json
2
+ import secrets
3
  from urllib.parse import parse_qsl, urlencode
4
  from typing import Any
5
  from pydantic import field_validator, model_validator
 
194
  EXIFTOOL_PATH: str = "" # full path to ExifTool binary; empty = metadata write disabled
195
 
196
  # Auth
197
+ JWT_SECRET_KEY: str = ""
198
+ JWT_SECRET_KEY_GENERATED: bool = False
199
  JWT_ALGORITHM: str = "HS256"
200
  JWT_EXPIRATION_MINUTES: int = 1440
201
 
202
+ @model_validator(mode="after")
203
+ def ensure_jwt_secret(self):
204
+ if not self.JWT_SECRET_KEY:
205
+ if self.DEBUG:
206
+ self.JWT_SECRET_KEY = secrets.token_urlsafe(48)
207
+ self.JWT_SECRET_KEY_GENERATED = True
208
+ else:
209
+ self.JWT_SECRET_KEY = secrets.token_urlsafe(48)
210
+ self.JWT_SECRET_KEY_GENERATED = True
211
+ else:
212
+ self.JWT_SECRET_KEY_GENERATED = False
213
+ return self
214
+
215
  @field_validator("ALLOWED_IMAGE_TYPES", mode="before")
216
  @classmethod
217
  def assemble_allowed_image_types(cls, v: Any) -> list[str]:
db/database.py CHANGED
@@ -47,15 +47,13 @@ def init_db():
47
  insp = inspect(engine)
48
  if "analyses" in insp.get_table_names():
49
  existing = {c["name"] for c in insp.get_columns("analyses")}
50
- additions = {
51
- "media_hash": "VARCHAR(64)",
52
- "media_path": "VARCHAR(512)",
53
- "thumbnail_url": "VARCHAR(512)",
54
- }
55
  with engine.begin() as conn:
56
- for col, ddl in additions.items():
57
- if col not in existing:
58
- conn.execute(text(f"ALTER TABLE analyses ADD COLUMN {col} {ddl}"))
 
 
 
59
  # Indices (CREATE INDEX IF NOT EXISTS is SQLite+Postgres safe)
60
  for ddl in (
61
  "CREATE INDEX IF NOT EXISTS ix_analyses_media_hash ON analyses (media_hash)",
 
47
  insp = inspect(engine)
48
  if "analyses" in insp.get_table_names():
49
  existing = {c["name"] for c in insp.get_columns("analyses")}
 
 
 
 
 
50
  with engine.begin() as conn:
51
+ if "media_hash" not in existing:
52
+ conn.execute(text("ALTER TABLE analyses ADD COLUMN media_hash VARCHAR(64)"))
53
+ if "media_path" not in existing:
54
+ conn.execute(text("ALTER TABLE analyses ADD COLUMN media_path VARCHAR(512)"))
55
+ if "thumbnail_url" not in existing:
56
+ conn.execute(text("ALTER TABLE analyses ADD COLUMN thumbnail_url VARCHAR(512)"))
57
  # Indices (CREATE INDEX IF NOT EXISTS is SQLite+Postgres safe)
58
  for ddl in (
59
  "CREATE INDEX IF NOT EXISTS ix_analyses_media_hash ON analyses (media_hash)",
deepshield.db-shm DELETED
Binary file (32.8 kB)
 
deepshield.db-wal DELETED
Binary file (86.6 kB)
 
logs/deepshield.log CHANGED
@@ -949,3 +949,12 @@ TypeError: int() argument must be a string, a bytes-like object or a real number
949
  2026-04-26 22:09:45.469 | INFO | services.report_service:generate_report:120 - Report generated id=43 path=temp_reports\deepshield_43_262befa5.pdf size=15602B
950
  2026-04-26 23:15:58.262 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
951
  2026-04-27 01:22:14.691 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
 
 
 
 
 
 
 
 
 
 
949
  2026-04-26 22:09:45.469 | INFO | services.report_service:generate_report:120 - Report generated id=43 path=temp_reports\deepshield_43_262befa5.pdf size=15602B
950
  2026-04-26 23:15:58.262 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
951
  2026-04-27 01:22:14.691 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
952
+ 2026-04-28 21:11:45.835 | INFO | main:lifespan:108 - Starting DeepShield backend
953
+ 2026-04-28 21:11:45.871 | INFO | main:lifespan:110 - Database initialized
954
+ 2026-04-28 21:11:45.871 | INFO | models.model_loader:load_image_model:48 - Loading image model: prithivMLmods/Deep-Fake-Detector-v2-Model
955
+ 2026-04-28 21:11:51.313 | INFO | models.model_loader:load_image_model:56 - Image model loaded
956
+ 2026-04-28 21:34:27.293 | INFO | main:lifespan:118 - Shutting down DeepShield backend
957
+ 2026-04-28 21:34:41.654 | INFO | main:lifespan:108 - Starting DeepShield backend
958
+ 2026-04-28 21:34:41.668 | INFO | main:lifespan:110 - Database initialized
959
+ 2026-04-28 21:34:41.668 | INFO | models.model_loader:load_image_model:48 - Loading image model: prithivMLmods/Deep-Fake-Detector-v2-Model
960
+ 2026-04-28 21:34:44.266 | INFO | models.model_loader:load_image_model:56 - Image model loaded
main.py CHANGED
@@ -11,7 +11,7 @@ from slowapi import _rate_limit_exceeded_handler
11
  from slowapi.errors import RateLimitExceeded
12
 
13
  from starlette.middleware.base import BaseHTTPMiddleware
14
- from starlette.responses import JSONResponse
15
 
16
  from api.router import api_router
17
  from config import settings
@@ -39,6 +39,21 @@ class ContentLengthLimitMiddleware(BaseHTTPMiddleware):
39
  return await call_next(request)
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  # === Phase 15.3 — JWT / CORS / logging hardening ===
43
 
44
  _DEFAULT_JWT_SECRET = "change-me-in-production"
@@ -46,7 +61,7 @@ _DEFAULT_JWT_SECRET = "change-me-in-production"
46
 
47
  def _enforce_production_hardening() -> None:
48
  """Refuse to start in production with unsafe defaults (Phase 15.3)."""
49
- if settings.JWT_SECRET_KEY == _DEFAULT_JWT_SECRET or not settings.JWT_SECRET_KEY:
50
  example = secrets.token_urlsafe(48)
51
  if settings.DEBUG:
52
  logger.warning(
@@ -131,6 +146,8 @@ app.state.limiter = limiter
131
 
132
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
133
  app.add_middleware(RateLimitContextMiddleware)
 
 
134
  # Phase 15.3 — reject oversized uploads before reading body
135
  app.add_middleware(ContentLengthLimitMiddleware, max_bytes=settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024)
136
 
 
11
  from slowapi.errors import RateLimitExceeded
12
 
13
  from starlette.middleware.base import BaseHTTPMiddleware
14
+ from starlette.responses import JSONResponse, RedirectResponse
15
 
16
  from api.router import api_router
17
  from config import settings
 
39
  return await call_next(request)
40
 
41
 
42
+ class HTTPSRedirectAndHSTSMiddleware(BaseHTTPMiddleware):
43
+ async def dispatch(self, request, call_next):
44
+ if not settings.DEBUG:
45
+ forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
46
+ host = request.headers.get("host", "").split(":", 1)[0].lower()
47
+ if forwarded_proto != "https" and request.url.scheme != "https" and host not in {"127.0.0.1", "localhost", ""}:
48
+ https_url = request.url.replace(scheme="https")
49
+ return RedirectResponse(str(https_url), status_code=308)
50
+
51
+ response = await call_next(request)
52
+ if not settings.DEBUG:
53
+ response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
54
+ return response
55
+
56
+
57
  # === Phase 15.3 — JWT / CORS / logging hardening ===
58
 
59
  _DEFAULT_JWT_SECRET = "change-me-in-production"
 
61
 
62
  def _enforce_production_hardening() -> None:
63
  """Refuse to start in production with unsafe defaults (Phase 15.3)."""
64
+ if settings.JWT_SECRET_KEY == _DEFAULT_JWT_SECRET or not settings.JWT_SECRET_KEY or settings.JWT_SECRET_KEY_GENERATED:
65
  example = secrets.token_urlsafe(48)
66
  if settings.DEBUG:
67
  logger.warning(
 
146
 
147
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
148
  app.add_middleware(RateLimitContextMiddleware)
149
+ # Phase 15.3 — enforce HTTPS in production and add HSTS
150
+ app.add_middleware(HTTPSRedirectAndHSTSMiddleware)
151
  # Phase 15.3 — reject oversized uploads before reading body
152
  app.add_middleware(ContentLengthLimitMiddleware, max_bytes=settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024)
153
 
schemas/auth.py CHANGED
@@ -1,8 +1,9 @@
1
  from __future__ import annotations
2
 
 
3
  from datetime import datetime
4
 
5
- from pydantic import BaseModel, EmailStr, Field
6
 
7
 
8
  class RegisterBody(BaseModel):
@@ -10,6 +11,21 @@ class RegisterBody(BaseModel):
10
  password: str = Field(min_length=6, max_length=128)
11
  name: str | None = Field(default=None, max_length=255)
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  class LoginBody(BaseModel):
15
  email: EmailStr
 
1
  from __future__ import annotations
2
 
3
+ import re
4
  from datetime import datetime
5
 
6
+ from pydantic import BaseModel, EmailStr, Field, field_validator
7
 
8
 
9
  class RegisterBody(BaseModel):
 
11
  password: str = Field(min_length=6, max_length=128)
12
  name: str | None = Field(default=None, max_length=255)
13
 
14
+ @field_validator("password")
15
+ @classmethod
16
+ def validate_password_strength(cls, value: str) -> str:
17
+ if len(value) < 8:
18
+ raise ValueError("Password must be at least 8 characters long")
19
+ if not re.search(r"[a-z]", value):
20
+ raise ValueError("Password must include a lowercase letter")
21
+ if not re.search(r"[A-Z]", value):
22
+ raise ValueError("Password must include an uppercase letter")
23
+ if not re.search(r"\d", value):
24
+ raise ValueError("Password must include a digit")
25
+ if not re.search(r"[^A-Za-z0-9]", value):
26
+ raise ValueError("Password must include a symbol")
27
+ return value
28
+
29
 
30
  class LoginBody(BaseModel):
31
  email: EmailStr
services/auth_service.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any
5
 
6
  import bcrypt
7
  from jose import JWTError, jwt
 
8
  from sqlalchemy.orm import Session
9
 
10
  from config import settings
@@ -24,7 +25,8 @@ def hash_password(plain: str) -> str:
24
  def verify_password(plain: str, hashed: str) -> bool:
25
  try:
26
  return bcrypt.checkpw(_encode_pw(plain), hashed.encode("utf-8"))
27
- except Exception:
 
28
  return False
29
 
30
 
 
5
 
6
  import bcrypt
7
  from jose import JWTError, jwt
8
+ from loguru import logger
9
  from sqlalchemy.orm import Session
10
 
11
  from config import settings
 
25
  def verify_password(plain: str, hashed: str) -> bool:
26
  try:
27
  return bcrypt.checkpw(_encode_pw(plain), hashed.encode("utf-8"))
28
+ except Exception as exc:
29
+ logger.warning(f"Password verification failed due to malformed hash: {exc}")
30
  return False
31
 
32
 
services/rate_limit.py CHANGED
@@ -80,6 +80,8 @@ ANON_ANALYZE = "5/hour"
80
  AUTH_ANALYZE = "50/hour"
81
  ANON_REPORT = "2/hour"
82
  AUTH_REPORT = "20/hour"
 
 
83
 
84
  limiter = Limiter(
85
  key_func=request_key,
 
80
  AUTH_ANALYZE = "50/hour"
81
  ANON_REPORT = "2/hour"
82
  AUTH_REPORT = "20/hour"
83
+ ANON_AUTH_REGISTER = "5/hour"
84
+ ANON_AUTH_LOGIN = "10/minute"
85
 
86
  limiter = Limiter(
87
  key_func=request_key,
utils/scoring.py CHANGED
@@ -11,6 +11,12 @@ TRUST_SCALE = [
11
  ]
12
 
13
 
 
 
 
 
 
 
14
  def compute_authenticity_score(fake_probability: float, label: str = "") -> int:
15
  """Map a fake probability [0.0, 1.0] to a 0-100 authenticity score.
16
 
@@ -62,8 +68,10 @@ def compute_video_authenticity_score(
62
  visual_score = (1.0 - float(mean_suspicious_prob)) * 100.0
63
  temporal_sc = float(temporal_score) if temporal_score is not None else visual_score
64
  if has_audio and audio_authenticity_score is not None:
 
65
  combined = 0.50 * visual_score + 0.30 * temporal_sc + 0.20 * float(audio_authenticity_score)
66
  else:
 
67
  combined = 0.70 * visual_score + 0.30 * temporal_sc
68
  score = int(round(max(0.0, min(100.0, combined))))
69
  label, severity = get_verdict_label(score)
 
11
  ]
12
 
13
 
14
+ def _validate_weight_total(weights: list[float], context: str) -> None:
15
+ total = sum(weights)
16
+ if total > 1.000001:
17
+ raise ValueError(f"{context} weights must not sum above 1.0 (got {total:.3f})")
18
+
19
+
20
  def compute_authenticity_score(fake_probability: float, label: str = "") -> int:
21
  """Map a fake probability [0.0, 1.0] to a 0-100 authenticity score.
22
 
 
68
  visual_score = (1.0 - float(mean_suspicious_prob)) * 100.0
69
  temporal_sc = float(temporal_score) if temporal_score is not None else visual_score
70
  if has_audio and audio_authenticity_score is not None:
71
+ _validate_weight_total([0.50, 0.30, 0.20], "video audio+temporal fusion")
72
  combined = 0.50 * visual_score + 0.30 * temporal_sc + 0.20 * float(audio_authenticity_score)
73
  else:
74
+ _validate_weight_total([0.70, 0.30], "video visual+temporal fusion")
75
  combined = 0.70 * visual_score + 0.30 * temporal_sc
76
  score = int(round(max(0.0, min(100.0, combined))))
77
  label, severity = get_verdict_label(score)