From 1dae2945c307e15d2e3f128f23e5824a18b63b95 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Sun, 8 Feb 2026 22:21:36 +0900 Subject: [PATCH] feat: client-side password hashing and admin user auto-seeding - Hash passwords with SHA-256 on frontend before transmission to prevent raw password exposure in network traffic and server logs - Switch login endpoint from OAuth2 form-data to JSON body - Auto-create admin user on startup from ADMIN_USERNAME/ADMIN_PASSWORD env vars, solving login failure after registration was disabled - Update auth tests to match new SHA-256 + JSON login flow Co-Authored-By: Claude Opus 4.6 --- backend/app/api/auth.py | 9 ++++--- backend/app/core/config.py | 5 ++++ backend/app/core/security.py | 18 +++++++++----- backend/app/main.py | 37 +++++++++++++++++++++++++++++ backend/tests/e2e/test_auth_flow.py | 19 ++++++++++----- frontend/src/lib/api.ts | 16 +++++++++---- 6 files changed, 82 insertions(+), 22 deletions(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 4ef3db4..9fbf0b1 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -5,7 +5,6 @@ from datetime import timedelta from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.core.database import get_db @@ -25,13 +24,13 @@ settings = get_settings() @router.post("/login", response_model=Token) async def login( - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + login_data: LoginRequest, db: Annotated[Session, Depends(get_db)], ): - """Login and get access token.""" - user = db.query(User).filter(User.username == form_data.username).first() + """Login and get access token. Expects SHA-256 hashed password.""" + user = db.query(User).filter(User.username == login_data.username).first() - if not user or not verify_password(form_data.password, user.hashed_password): + if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ac6d36c..f53e367 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -21,6 +21,11 @@ class Settings(BaseSettings): # CORS cors_origins: str = "http://localhost:3000" + # Admin user (auto-created on startup if not exists) + admin_username: str = "" + admin_email: str = "" + admin_password: str = "" + # External APIs kis_app_key: str = "" kis_app_secret: str = "" diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 737bb7f..8603e85 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,6 +1,7 @@ """ Security utilities for authentication. """ +import hashlib from datetime import datetime, timedelta, timezone from typing import Optional @@ -15,14 +16,19 @@ settings = get_settings() pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against its hash.""" - return pwd_context.verify(plain_password, hashed_password) +def sha256_hash(password: str) -> str: + """SHA-256 hash a raw password (matches client-side hashing).""" + return hashlib.sha256(password.encode()).hexdigest() -def get_password_hash(password: str) -> str: - """Hash a password.""" - return pwd_context.hash(password) +def verify_password(sha256_password: str, hashed_password: str) -> bool: + """Verify a SHA-256 hashed password against its bcrypt hash.""" + return pwd_context.verify(sha256_password, hashed_password) + + +def get_password_hash(raw_password: str) -> str: + """Hash a raw password: SHA-256 then bcrypt.""" + return pwd_context.hash(sha256_hash(raw_password)) def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: diff --git a/backend/app/main.py b/backend/app/main.py index cbc0794..081b8f4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,12 +20,49 @@ logging.basicConfig( logger = logging.getLogger(__name__) +def _seed_admin_user() -> None: + """Create admin user from env vars if not exists.""" + from app.core.config import get_settings + from app.core.database import SessionLocal + from app.core.security import get_password_hash + from app.models.user import User + + settings = get_settings() + if not settings.admin_username or not settings.admin_password: + logger.info("ADMIN_USERNAME/ADMIN_PASSWORD not set, skipping admin seed") + return + + db = SessionLocal() + try: + existing = db.query(User).filter(User.username == settings.admin_username).first() + if existing: + logger.info(f"Admin user '{settings.admin_username}' already exists") + return + + user = User( + username=settings.admin_username, + email=settings.admin_email or f"{settings.admin_username}@local", + hashed_password=get_password_hash(settings.admin_password), + ) + db.add(user) + db.commit() + logger.info(f"Admin user '{settings.admin_username}' created") + except Exception as e: + db.rollback() + logger.error(f"Failed to seed admin user: {e}") + finally: + db.close() + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager.""" # Startup logger.info("Starting Galaxis-Po API...") + # Seed admin user + _seed_admin_user() + # Start scheduler (import here to avoid circular imports) try: from jobs.scheduler import start_scheduler diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 3d028b4..e9e0497 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -1,10 +1,17 @@ """ E2E tests for authentication flow. """ +import hashlib + import pytest from fastapi.testclient import TestClient +def _sha256(password: str) -> str: + """SHA-256 hash to match client-side hashing.""" + return hashlib.sha256(password.encode()).hexdigest() + + def test_health_check(client: TestClient): """Test health check endpoint.""" response = client.get("/health") @@ -16,9 +23,9 @@ def test_login_success(client: TestClient, test_user): """Test successful login.""" response = client.post( "/api/auth/login", - data={ + json={ "username": "testuser", - "password": "testpassword", + "password": _sha256("testpassword"), }, ) assert response.status_code == 200 @@ -31,9 +38,9 @@ def test_login_wrong_password(client: TestClient, test_user): """Test login with wrong password.""" response = client.post( "/api/auth/login", - data={ + json={ "username": "testuser", - "password": "wrongpassword", + "password": _sha256("wrongpassword"), }, ) assert response.status_code == 401 @@ -43,9 +50,9 @@ def test_login_nonexistent_user(client: TestClient): """Test login with nonexistent user.""" response = client.post( "/api/auth/login", - data={ + json={ "username": "nonexistent", - "password": "password", + "password": _sha256("password"), }, ) assert response.status_code == 401 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3d30634..2e51011 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,13 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || ''; +async function hashPassword(password: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(password); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} + class ApiClient { private baseUrl: string; private token: string | null = null; @@ -74,16 +82,14 @@ class ApiClient { } async login(username: string, password: string) { - const formData = new URLSearchParams(); - formData.append('username', username); - formData.append('password', password); + const hashedPassword = await hashPassword(password); const response = await fetch(`${this.baseUrl}/api/auth/login`, { method: 'POST', headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Type': 'application/json', }, - body: formData, + body: JSON.stringify({ username, password: hashedPassword }), }); if (!response.ok) {