Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5

- name: Setup Python
if: matrix.language == 'python'
uses: actions/setup-python@v6
with:
python-version: '3.13'

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
Expand Down
58 changes: 58 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg
*.egg-info/
dist/
build/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.whl
.installed.cfg

# Virtual environments
.venv/
venv/
env/
ENV/

# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/

# IDEs
.idea/
.vscode/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Logs
*.log

# Environment variables
.env
.env.*
!.env.example

# Node
node_modules/
npm-debug.log*

# Distribution
*.tar.gz
*.zip
84 changes: 65 additions & 19 deletions backend/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,84 @@
import os
import bcrypt
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional

from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

logger = logging.getLogger(__name__)


def _resolve_secret_key() -> tuple[str, bool]:
configured = str(os.getenv("SECRET_KEY") or "").strip()
if configured:
return configured, False

fallback_path = Path(
os.getenv(
"SECRET_KEY_FILE",
str(Path(os.getenv("TEMP") or "/tmp") / "codeai_jwt_secret.key"),
)
)
try:
fallback_path.parent.mkdir(parents=True, exist_ok=True)
if fallback_path.exists():
cached_secret = fallback_path.read_text(encoding="utf-8").strip()
if cached_secret:
configured_file = str(os.getenv("SECRET_KEY_FILE") or "").strip()
if configured_file:
fallback_path = Path(configured_file).expanduser()
try:
if fallback_path.exists():
if not fallback_path.is_file():
raise RuntimeError(
f"SECRET_KEY_FILE is configured but is not a file: {fallback_path}"
)
cached_secret = fallback_path.read_text(encoding="utf-8").strip()
if not cached_secret:
raise RuntimeError(
f"SECRET_KEY_FILE is configured but empty: {fallback_path}"
)
return cached_secret, True
Comment on lines +26 to 35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Creating the secret file with O_EXCL after an existence check introduces a race that now hard-fails startup.

The new SECRET_KEY_FILE logic correctly fails on initialization errors, but the exists() + os.open(..., O_EXCL) pattern is racy: if another process creates the file between these calls, os.open raises FileExistsError, which the broad except currently turns into a fatal RuntimeError even though the file now exists and is usable.

If multi-process startup or external creation of the secret is possible, handle FileExistsError explicitly (e.g., reopen the file read-only and use its contents) instead of treating it as a hard failure.

generated_secret = secrets.token_urlsafe(48)
fallback_path.write_text(generated_secret, encoding="utf-8")
return generated_secret, True
except Exception:
return secrets.token_urlsafe(48), True

fallback_path.parent.mkdir(parents=True, exist_ok=True)
generated_secret = secrets.token_urlsafe(48)
fd = os.open(str(fallback_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as secret_file:
secret_file.write(generated_secret)
secret_file.write("\n")
except Exception:
try:
fallback_path.unlink()
except OSError:
logger.exception(
"Failed to clean up partially written SECRET_KEY_FILE at %s.",
str(fallback_path),
)
raise

try:
os.chmod(fallback_path, 0o600)
except OSError:
logger.exception(
"Failed to set permissions on SECRET_KEY_FILE at %s.",
str(fallback_path),
)
raise RuntimeError(
f"Failed to set secure permissions on SECRET_KEY_FILE: {fallback_path}"
)

logger.warning(
"SECRET_KEY_FILE did not exist; generated and stored a new persistent secret at %s with mode 0600.",
str(fallback_path),
)
return generated_secret, True
except Exception as exc:
logger.exception(
"Failed to initialize SECRET_KEY_FILE at %s. Refusing to start with an ephemeral runtime secret.",
str(fallback_path),
)
raise RuntimeError(
f"Failed to initialize SECRET_KEY from SECRET_KEY_FILE: {fallback_path}"
) from exc
else:
logger.error(
"SECRET_KEY/SECRET_KEY_FILE is not configured; generating ephemeral runtime secret that invalidates tokens on restart."
)
return secrets.token_urlsafe(48), True


SECRET_KEY, SECRET_KEY_IS_RUNTIME_FALLBACK = _resolve_secret_key()
Expand All @@ -42,7 +89,6 @@ def _resolve_secret_key() -> tuple[str, bool]:
)

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
logger = logging.getLogger(__name__)


def get_password_hash(password: str) -> str:
Expand All @@ -68,7 +114,7 @@ def create_access_token(
) -> str:
to_encode = data.copy()
if not no_expiry:
expire = datetime.utcnow() + (
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
Expand Down
25 changes: 12 additions & 13 deletions backend/auth_router.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta

from secrets import compare_digest, randbelow, token_urlsafe
import base64
import os
Expand All @@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from typing import Optional, cast
from pydantic import BaseModel, EmailStr

from sqlalchemy.orm import Session
from webauthn import (
generate_authentication_options,
Expand Down Expand Up @@ -82,8 +82,7 @@ class UserResponse(BaseModel):
business_registration_number: Optional[str] = None
representative_name: Optional[str] = None

class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

Comment on lines 82 to 86
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback


class Token(BaseModel):
Expand Down Expand Up @@ -254,7 +253,7 @@ class PasswordRecoveryResetResponse(BaseModel):


def _issue_recovery_token(prefix: str) -> tuple[str, datetime]:
expires_at = datetime.utcnow() + timedelta(minutes=10)

return f"{prefix}_{token_urlsafe(24)}", expires_at

Comment on lines 255 to 258
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback


Expand All @@ -273,7 +272,7 @@ def _normalize_password_recovery_scope(scope: str) -> str:


def _purge_expired_password_recovery_sessions() -> None:
now = datetime.utcnow()

expired_tokens = [
session_token
for session_token, session_state in _password_recovery_store.items()
Expand Down Expand Up @@ -380,7 +379,7 @@ def start_passkey_registration(
"user_id": int(user.id),
"challenge": _to_base64url(options.challenge),
"device_label": str(payload.device_label or "이 기기 패스키").strip() or "이 기기 패스키",
"expires_at": datetime.utcnow() + timedelta(minutes=5),
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=5),
"user_handle": user_handle,
"rp_id": rp_id,
"expected_origin": expected_origin,
Expand All @@ -403,7 +402,7 @@ def finish_passkey_registration(
raise HTTPException(status_code=404, detail="패스키 등록 세션을 찾을 수 없습니다")

expires_at = state.get("expires_at")
if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow():
if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc):
_passkey_registration_store.pop(payload.registration_token, None)
raise HTTPException(status_code=410, detail="패스키 등록 세션이 만료되었습니다")

Expand All @@ -430,7 +429,7 @@ def finish_passkey_registration(
user.passkey_public_key = _to_base64url(verification.credential_public_key)
user.passkey_device_label = str(state.get("device_label") or "이 기기 패스키")
user.passkey_sign_count = int(verification.sign_count)
user.passkey_registered_at = datetime.utcnow()
user.passkey_registered_at = datetime.now(timezone.utc)
db.add(user)
db.commit()
_passkey_registration_store.pop(payload.registration_token, None)
Expand Down Expand Up @@ -465,7 +464,7 @@ def start_passkey_login(
)
_passkey_login_store[str(user.email)] = {
"challenge": _to_base64url(options.challenge),
"expires_at": datetime.utcnow() + timedelta(minutes=5),
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=5),
"credential_id": str(user.passkey_credential_id),
"rp_id": rp_id,
"expected_origin": expected_origin,
Expand All @@ -492,7 +491,7 @@ def finish_passkey_login(
raise HTTPException(status_code=404, detail="패스키 로그인 세션을 찾을 수 없습니다")

expires_at = state.get("expires_at")
if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow():
if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc):
_passkey_login_store.pop(str(user.email), None)
raise HTTPException(status_code=410, detail="패스키 로그인 세션이 만료되었습니다")

Expand Down Expand Up @@ -581,7 +580,7 @@ def verify_password_recovery_identity(payload: PasswordRecoveryVerifyIdentityReq
raise HTTPException(status_code=404, detail="복구 세션을 찾을 수 없습니다")

expires_at = session_state.get("expires_at")
if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow():
if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc):
_password_recovery_store.pop(payload.recovery_session_token, None)
raise HTTPException(status_code=410, detail="복구 세션이 만료되었습니다")

Expand Down Expand Up @@ -638,7 +637,7 @@ def reset_password_via_recovery(
raise HTTPException(status_code=403, detail="본인확인 검증이 완료되지 않았습니다")

reset_expires_at = session_state.get("reset_expires_at")
if not isinstance(reset_expires_at, datetime) or reset_expires_at <= datetime.utcnow():
if not isinstance(reset_expires_at, datetime) or reset_expires_at <= datetime.now(timezone.utc):
_password_recovery_store.pop(session_token, None)
raise HTTPException(status_code=410, detail="재설정 토큰이 만료되었습니다")

Expand Down
65 changes: 54 additions & 11 deletions backend/llm/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2684,6 +2684,7 @@ class OrchestrationAcceptedResponse(BaseModel):


_ORCHESTRATION_PROGRESS_STORE: Dict[str, Dict[str, Any]] = {}
_ORCHESTRATION_PROGRESS_FILE_LOCK = threading.Lock()


def _runtime_progress_root() -> Path:
Expand All @@ -2694,9 +2695,13 @@ def _runtime_progress_root() -> Path:
return progress_root


def _orchestration_progress_path(run_id: str) -> Path:
safe_run_id = re.sub(r"[^a-zA-Z0-9_.-]+", "-", str(run_id or "unknown")).strip("-") or "unknown"
return _runtime_progress_root() / f"{safe_run_id}.json"
def _orchestration_progress_file_path(run_id: str) -> Path:
normalized_run_id = str(run_id if run_id is not None else "unknown")
if normalized_run_id == "":
normalized_run_id = "unknown"
runtime_root = _runtime_progress_root().resolve()
file_name = f"{hashlib.sha256(normalized_run_id.encode('utf-8')).hexdigest()}.json"
return runtime_root / file_name


def _build_progress_poll_url(run_id: str) -> str:
Expand All @@ -2712,23 +2717,61 @@ def _save_orchestration_progress(run_id: str, payload: Dict[str, Any]) -> Dict[s
normalized["run_id"] = str(run_id or normalized.get("run_id") or "")
normalized.setdefault("updated_at", datetime.utcnow().isoformat() + "Z")
_ORCHESTRATION_PROGRESS_STORE[normalized["run_id"]] = normalized
progress_path = _orchestration_progress_path(normalized["run_id"])
progress_path.write_text(json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8")
progress_path = _orchestration_progress_file_path(normalized["run_id"])
trusted_temp_prefix = (
f"progress-{hashlib.sha256(normalized['run_id'].encode('utf-8')).hexdigest()}."
if normalized["run_id"]
else "progress-unknown."
)
with _ORCHESTRATION_PROGRESS_FILE_LOCK:
temp_path: Optional[Path] = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(progress_path.parent),
prefix=trusted_temp_prefix,
suffix=".tmp",
delete=False,
) as temp_file:
temp_file.write(json.dumps(normalized, ensure_ascii=False, indent=2))
temp_file.flush()
os.fsync(temp_file.fileno())
temp_path = Path(temp_file.name)
os.replace(temp_path, progress_path)
except Exception:
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except Exception:
logger.warning(
"Failed to remove temporary orchestration progress file %s",
str(temp_path),
exc_info=True,
)
logger.warning(
"Failed to write orchestration progress file at %s",
str(progress_path),
exc_info=True,
)
return dict(_ORCHESTRATION_PROGRESS_STORE.get(normalized["run_id"], {}))
return normalized


def _load_orchestration_progress(run_id: str) -> Dict[str, Any]:
cached = _ORCHESTRATION_PROGRESS_STORE.get(str(run_id or ""))
if isinstance(cached, dict) and cached:
return dict(cached)
progress_path = _orchestration_progress_path(run_id)
progress_path = _orchestration_progress_file_path(run_id)
try:
if progress_path.exists() and progress_path.is_file():
payload = json.loads(progress_path.read_text(encoding="utf-8"))
if isinstance(payload, dict):
_ORCHESTRATION_PROGRESS_STORE[str(run_id or "")] = dict(payload)
return dict(payload)
with _ORCHESTRATION_PROGRESS_FILE_LOCK:
if progress_path.exists() and progress_path.is_file():
payload = json.loads(progress_path.read_text(encoding="utf-8"))
if isinstance(payload, dict):
_ORCHESTRATION_PROGRESS_STORE[str(run_id or "")] = dict(payload)
return dict(payload)
except Exception:
logger.error("Failed to load orchestration progress for run_id=%s", str(run_id or ""), exc_info=True)
return {}
return {}

Expand Down
Loading
Loading