diff --git a/app/api/routes/users.py b/app/api/routes/users.py new file mode 100644 index 0000000..c8973a4 --- /dev/null +++ b/app/api/routes/users.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.auth.dependencies import get_current_user +from app.db.models import User + +router = APIRouter(prefix="/users", tags=["users"]) + + +class MeResponse(BaseModel): + username: str + address: str + balance_sats: int + + +@router.get("/me", response_model=MeResponse) +async def me(user: User = Depends(get_current_user)) -> MeResponse: + return MeResponse(username=user.username, address=user.address, balance_sats=user.cached_balance_sats) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py new file mode 100644 index 0000000..18de63e --- /dev/null +++ b/app/auth/dependencies.py @@ -0,0 +1,25 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.security import decode_access_token +from app.db.models import User +from app.db.session import get_session + +_bearer = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(_bearer), + session: AsyncSession = Depends(get_session), +) -> User: + try: + user_id = decode_access_token(credentials.credentials) + except Exception as exc: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token") from exc + + user = await session.scalar(select(User).where(User.id == user_id)) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found") + return user diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..6cfcc6a --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.security import create_access_token, hash_password, verify_password +from app.db.models import User +from app.db.session import get_session +from app.wallet.hd import derive_user_address + +router = APIRouter(prefix="/auth", tags=["auth"]) + +_MAX_REGISTER_RETRIES = 5 + + +class RegisterRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + address: str + + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register( + body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session) +) -> TokenResponse: + existing = await session.scalar(select(User).where(User.username == body.username)) + if existing is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "username already taken") + + password_hash = hash_password(body.password) + + for _ in range(_MAX_REGISTER_RETRIES): + max_index = await session.scalar(select(func.max(User.derivation_index))) + next_index = 0 if max_index is None else max_index + 1 + address = derive_user_address(next_index) + user = User( + username=body.username, + password_hash=password_hash, + derivation_index=next_index, + address=address, + ) + session.add(user) + try: + await session.commit() + except IntegrityError: + await session.rollback() + continue + await session.refresh(user) + request.app.state.electrum_listener.address_for_new_user(user.id, user.address) + return TokenResponse(access_token=create_access_token(user.id), address=user.address) + + raise HTTPException(status.HTTP_409_CONFLICT, "could not allocate a derivation index, retry") + + +class LoginRequest(BaseModel): + username: str + password: str + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse: + user = await session.scalar(select(User).where(User.username == body.username)) + if user is None or not verify_password(body.password, user.password_hash): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials") + return TokenResponse(access_token=create_access_token(user.id), address=user.address) diff --git a/app/auth/security.py b/app/auth/security.py new file mode 100644 index 0000000..947bcfc --- /dev/null +++ b/app/auth/security.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +from app.config import settings + +_hasher = PasswordHasher() + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except VerifyMismatchError: + return False + + +def create_access_token(user_id: int) -> str: + expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": str(user_id), "exp": expires_at} + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> int: + payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + return int(payload["sub"]) diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..b0ded21 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,13 @@ +from app.auth import security + + +def test_password_hash_roundtrip(): + hashed = security.hash_password("s3cret!") + assert security.verify_password("s3cret!", hashed) + assert not security.verify_password("wrong", hashed) + + +def test_jwt_roundtrip(monkeypatch): + monkeypatch.setattr(security.settings, "jwt_secret", "test-secret") + token = security.create_access_token(user_id=42) + assert security.decode_access_token(token) == 42