Kyle Pope fbc452a004 Implement Stage 6 Track A: PIN → Username/Password auth migration
- New User model (username, argon2id password_hash, totp fields, lockout)
- New UserSession model (DB-backed revocation, replaces in-memory set)
- New services/auth.py: Argon2id hashing, bcrypt→Argon2id upgrade path, URLSafeTimedSerializer session/MFA tokens
- New schemas/auth.py: SetupRequest, LoginRequest, ChangePasswordRequest with OWASP password strength validation
- Full rewrite of routers/auth.py: setup/login/logout/status/change-password with account lockout (10 failures → 30-min, HTTP 423), IP rate limiting retained as outer layer, get_current_user + get_current_settings dependencies replacing get_current_session
- Settings model: drop pin_hash, add user_id FK (nullable for migration)
- Schemas/settings.py: remove SettingsCreate, ChangePinRequest, _validate_pin_length
- Settings router: rewrite to use get_current_user + get_current_settings, preserve ntfy test endpoint
- All 11 consumer routers updated: auth-gate-only routers use get_current_user, routers reading Settings fields use get_current_settings
- config.py: add SESSION_MAX_AGE_DAYS, MFA_TOKEN_MAX_AGE_SECONDS, TOTP_ISSUER
- main.py: import User and UserSession models for Alembic discovery
- requirements.txt: add argon2-cffi>=23.1.0
- Migration 023: create users + user_sessions tables, migrate pin_hash → User row (admin), backfill settings.user_id, drop pin_hash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:12:37 +08:00

136 lines
4.6 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
import asyncio
import urllib.request
import urllib.parse
import urllib.error
import json
from app.database import get_db
from app.models.settings import Settings
from app.config import settings as app_settings
from app.routers.auth import get_current_user, get_current_settings
from app.models.user import User
router = APIRouter()
_cache: dict = {}
class GeoSearchResult(BaseModel):
name: str
state: str
country: str
lat: float
lon: float
def _fetch_json(url: str) -> dict:
with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode())
@router.get("/search", response_model=list[GeoSearchResult])
async def search_locations(
q: str = Query(..., min_length=1, max_length=100),
current_user: User = Depends(get_current_user)
):
api_key = app_settings.OPENWEATHERMAP_API_KEY
if not api_key:
raise HTTPException(status_code=500, detail="Weather API key not configured")
try:
loop = asyncio.get_running_loop()
url = f"https://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}"
results = await loop.run_in_executor(None, _fetch_json, url)
return [
{
"name": r.get("name", ""),
"state": r.get("state", ""),
"country": r.get("country", ""),
"lat": r.get("lat"),
"lon": r.get("lon"),
}
for r in results
]
except urllib.error.URLError:
raise HTTPException(status_code=502, detail="Geocoding service unavailable")
except (KeyError, json.JSONDecodeError):
raise HTTPException(status_code=502, detail="Invalid geocoding data")
@router.get("/")
async def get_weather(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
):
city = current_user.weather_city
lat = current_user.weather_lat
lon = current_user.weather_lon
if not city and (lat is None or lon is None):
raise HTTPException(status_code=400, detail="No weather location configured")
# Build cache key from coordinates or city
use_coords = lat is not None and lon is not None
cache_key = f"{lat},{lon}" if use_coords else city
# Check cache
now = datetime.now()
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key:
return _cache["data"]
api_key = app_settings.OPENWEATHERMAP_API_KEY
if not api_key:
raise HTTPException(status_code=500, detail="Weather API key not configured")
try:
loop = asyncio.get_running_loop()
# Build query params based on coordinates or city name
if use_coords:
location_params = f"lat={lat}&lon={lon}"
else:
location_params = f"q={urllib.parse.quote(city)}"
current_url = f"https://api.openweathermap.org/data/2.5/weather?{location_params}&units=metric&appid={api_key}"
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?{location_params}&units=metric&cnt=8&appid={api_key}"
current_data, forecast_data = await asyncio.gather(
loop.run_in_executor(None, _fetch_json, current_url),
loop.run_in_executor(None, _fetch_json, forecast_url),
)
rain_chance = 0
for item in forecast_data.get("list", []):
pop = item.get("pop", 0)
if pop > rain_chance:
rain_chance = pop
weather_result = {
"temp": round(current_data["main"]["temp"]),
"temp_min": round(current_data["main"]["temp_min"]),
"temp_max": round(current_data["main"]["temp_max"]),
"description": current_data["weather"][0]["description"],
"rain_chance": round(rain_chance * 100),
"sunrise": current_data["sys"]["sunrise"],
"sunset": current_data["sys"]["sunset"],
"city": current_data["name"],
}
# Cache for 1 hour
_cache["data"] = weather_result
_cache["expires_at"] = now + timedelta(hours=1)
_cache["cache_key"] = cache_key
return weather_result
except urllib.error.URLError:
raise HTTPException(status_code=502, detail="Weather service unavailable")
except (KeyError, json.JSONDecodeError):
raise HTTPException(status_code=502, detail="Invalid weather data")