feat: implement refresh token functionality; update authentication and token models; add tests for refresh endpoint
Test / test (push) Successful in 13s

This commit is contained in:
k1nq
2025-11-28 13:56:04 +05:00
parent a8bdf18e38
commit 6db1e865f6
7 changed files with 165 additions and 16 deletions
+3
View File
@@ -118,6 +118,9 @@ async def get_current_user(
sub = payload.get("sub")
if sub is None:
raise credentials_exception
scope = payload.get("scope", "access")
if scope != "access":
raise credentials_exception
user_id = int(sub)
except (jwt.PyJWTError, TypeError, ValueError):
raise credentials_exception from None
+16 -5
View File
@@ -9,10 +9,10 @@ from app.api.deps import get_auth_service, get_user_repository
from app.core.security import password_hasher
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember, OrganizationRole
from app.models.token import LoginRequest, TokenResponse
from app.models.token import LoginRequest, RefreshRequest, TokenResponse
from app.models.user import UserCreate
from app.repositories.user_repo import UserRepository
from app.services.auth_service import AuthService, InvalidCredentialsError
from app.services.auth_service import AuthService, InvalidCredentialsError, InvalidRefreshTokenError
class RegisterRequest(BaseModel):
@@ -61,7 +61,7 @@ async def register_user(
) from exc
await repo.session.refresh(user)
return auth_service.create_access_token(user)
return auth_service.issue_tokens(user)
@router.post("/login", response_model=TokenResponse)
@@ -74,7 +74,7 @@ async def login(
user = await service.authenticate(credentials.email, credentials.password)
except InvalidCredentialsError as exc: # pragma: no cover - thin API
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return service.create_access_token(user)
return service.issue_tokens(user)
@router.post("/token", response_model=TokenResponse)
@@ -86,4 +86,15 @@ async def login_for_access_token(
user = await service.authenticate(credentials.email, credentials.password)
except InvalidCredentialsError as exc: # pragma: no cover - thin API
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return service.create_access_token(user)
return service.issue_tokens(user)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_tokens(
payload: RefreshRequest,
service: AuthService = Depends(get_auth_service),
) -> TokenResponse:
try:
return await service.refresh_tokens(payload.refresh_token)
except InvalidRefreshTokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
+1
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
jwt_secret_key: SecretStr = Field(default=SecretStr("change-me"))
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 7
settings = Settings()
+6
View File
@@ -14,10 +14,16 @@ class TokenPayload(BaseModel):
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
refresh_expires_in: int
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
+49 -6
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
import jwt
from app.core.config import settings
from app.core.security import JWTService, PasswordHasher
@@ -14,6 +17,10 @@ class InvalidCredentialsError(Exception):
"""Raised when user authentication fails."""
class InvalidRefreshTokenError(Exception):
"""Raised when refresh token validation fails."""
class AuthService:
"""Handles authentication flows and token issuance."""
@@ -33,11 +40,47 @@ class AuthService:
raise InvalidCredentialsError("Invalid email or password")
return user
def create_access_token(self, user: User) -> TokenResponse:
expires_delta = timedelta(minutes=settings.access_token_expire_minutes)
token = self._jwt_service.create_access_token(
def issue_tokens(self, user: User) -> TokenResponse:
access_expires = timedelta(minutes=settings.access_token_expire_minutes)
refresh_expires = timedelta(days=settings.refresh_token_expire_days)
access_token = self._jwt_service.create_access_token(
subject=str(user.id),
expires_delta=expires_delta,
claims={"email": user.email},
expires_delta=access_expires,
claims={"email": user.email, "scope": "access"},
)
return TokenResponse(access_token=token, expires_in=int(expires_delta.total_seconds()))
refresh_token = self._jwt_service.create_access_token(
subject=str(user.id),
expires_delta=refresh_expires,
claims={"scope": "refresh"},
)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=int(access_expires.total_seconds()),
refresh_expires_in=int(refresh_expires.total_seconds()),
)
async def refresh_tokens(self, refresh_token: str) -> TokenResponse:
payload = self._decode_refresh_token(refresh_token)
sub = payload.get("sub")
if sub is None:
raise InvalidRefreshTokenError("Invalid refresh token")
try:
user_id = int(sub)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
raise InvalidRefreshTokenError("Invalid refresh token") from exc
user = await self._user_repository.get_by_id(user_id)
if user is None:
raise InvalidRefreshTokenError("Invalid refresh token")
return self.issue_tokens(user)
def _decode_refresh_token(self, token: str) -> dict[str, Any]:
try:
payload = self._jwt_service.decode(token)
except jwt.PyJWTError as exc:
raise InvalidRefreshTokenError("Invalid refresh token") from exc
if payload.get("scope") != "refresh":
raise InvalidRefreshTokenError("Invalid refresh token")
return payload