Spaces:
Sleeping
Sleeping
'code'
Browse files- README.md +50 -1
- alembic/__pycache__/env.cpython-312.pyc +0 -0
- alembic/env.py +1 -0
- alembic/versions/20251222_add_refresh_token_table.py +35 -0
- alembic/versions/__pycache__/20251222_add_refresh_token_table.cpython-312.pyc +0 -0
- alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc +0 -0
- alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc +0 -0
- src/models/__pycache__/refresh_token.cpython-312.pyc +0 -0
- src/models/refresh_token.py +17 -0
- src/routers/auth.py +130 -37
README.md
CHANGED
|
@@ -19,4 +19,53 @@ If your backend is hosted on a different domain (e.g., Hugging Face Spaces) than
|
|
| 19 |
- Frontend fetch calls that rely on cookies must use `credentials: 'include'` (e.g. `fetch(url, { method, credentials: 'include' })`).
|
| 20 |
- Backend CORS must have `allow_credentials=True` and the exact frontend origin (not `*`) in allowed origins. This project reads `FRONTEND_URL` for that purpose.
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- Frontend fetch calls that rely on cookies must use `credentials: 'include'` (e.g. `fetch(url, { method, credentials: 'include' })`).
|
| 20 |
- Backend CORS must have `allow_credentials=True` and the exact frontend origin (not `*`) in allowed origins. This project reads `FRONTEND_URL` for that purpose.
|
| 21 |
|
| 22 |
+
### Refresh token flow (implemented)
|
| 23 |
+
- On login the backend now returns a **short-lived access token** (in the response) and sets a **HttpOnly refresh cookie** (`/api/auth` path).
|
| 24 |
+
- To get a new access token, call `POST /api/auth/refresh` with `credentials: 'include'`. The server validates and rotates the refresh token and returns a new access token in the response body.
|
| 25 |
+
- On logout the server revokes the refresh token and clears the refresh cookie.
|
| 26 |
+
|
| 27 |
+
Client example (login + using refresh) — Vercel frontend:
|
| 28 |
+
|
| 29 |
+
1) Login (receives access token in response body and a HttpOnly refresh cookie):
|
| 30 |
+
|
| 31 |
+
```js
|
| 32 |
+
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/login`, {
|
| 33 |
+
method: 'POST',
|
| 34 |
+
headers: { 'Content-Type': 'application/json' },
|
| 35 |
+
credentials: 'include',
|
| 36 |
+
body: JSON.stringify({ email, password }),
|
| 37 |
+
})
|
| 38 |
+
const data = await res.json()
|
| 39 |
+
const accessToken = data.access_token
|
| 40 |
+
// Store accessToken in memory (React state)
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
2) Use access token for protected calls (in memory, sent in Authorization header):
|
| 44 |
+
|
| 45 |
+
```js
|
| 46 |
+
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/tasks`, {
|
| 47 |
+
headers: { 'Authorization': `Bearer ${accessToken}` }
|
| 48 |
+
})
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
3) Obtain a fresh access token when it expires:
|
| 52 |
+
|
| 53 |
+
```js
|
| 54 |
+
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh`, {
|
| 55 |
+
method: 'POST',
|
| 56 |
+
credentials: 'include', // sends HttpOnly refresh cookie
|
| 57 |
+
})
|
| 58 |
+
const data = await res.json()
|
| 59 |
+
const accessToken = data.access_token
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
If cookies are still not sent/received, open DevTools network tab and inspect the `Set-Cookie` response header and browser cookie/journal to see why it was blocked (e.g., SameSite/Secure/Domain issues).
|
| 63 |
+
|
| 64 |
+
Database migration note:
|
| 65 |
+
- After pulling these changes, run Alembic migrations to create the `refreshtoken` table:
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
alembic upgrade head
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
(If you deploy to Spaces, ensure your deployment runs the migration step or create the table manually.)
|
alembic/__pycache__/env.cpython-312.pyc
CHANGED
|
Binary files a/alembic/__pycache__/env.cpython-312.pyc and b/alembic/__pycache__/env.cpython-312.pyc differ
|
|
|
alembic/env.py
CHANGED
|
@@ -14,6 +14,7 @@ sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
|
| 14 |
from src.models.user import User # Import your models
|
| 15 |
from src.models.task import Task # Import your models
|
| 16 |
from src.models.project import Project # Import your models
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
# this is the Alembic Config object, which provides
|
|
|
|
| 14 |
from src.models.user import User # Import your models
|
| 15 |
from src.models.task import Task # Import your models
|
| 16 |
from src.models.project import Project # Import your models
|
| 17 |
+
from src.models.refresh_token import RefreshToken # Add RefreshToken model for migrations
|
| 18 |
|
| 19 |
|
| 20 |
# this is the Alembic Config object, which provides
|
alembic/versions/20251222_add_refresh_token_table.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add refresh token table
|
| 2 |
+
|
| 3 |
+
Revision ID: 20251222_add_refresh_token_table
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2025-12-22 00:00:00.000000
|
| 6 |
+
"""
|
| 7 |
+
from alembic import op
|
| 8 |
+
import sqlalchemy as sa
|
| 9 |
+
from sqlalchemy.dialects import postgresql
|
| 10 |
+
|
| 11 |
+
# revision identifiers, used by Alembic.
|
| 12 |
+
revision = '20251222_add_refresh_token_table'
|
| 13 |
+
down_revision = '4ac448e3f100' # depends on last migration in the main chain
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
op.create_table(
|
| 20 |
+
'refreshtoken',
|
| 21 |
+
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
|
| 22 |
+
sa.Column('token_hash', sa.String(length=128), nullable=False),
|
| 23 |
+
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('user.id'), nullable=True),
|
| 24 |
+
sa.Column('revoked', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
| 25 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 26 |
+
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
| 27 |
+
)
|
| 28 |
+
# create index separately (SQLAlchemy/Alembic prefers explicit index creation)
|
| 29 |
+
op.create_index('ix_refreshtoken_token_hash', 'refreshtoken', ['token_hash'])
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def downgrade() -> None:
|
| 33 |
+
# drop index then table
|
| 34 |
+
op.drop_index('ix_refreshtoken_token_hash', table_name='refreshtoken')
|
| 35 |
+
op.drop_table('refreshtoken')
|
alembic/versions/__pycache__/20251222_add_refresh_token_table.cpython-312.pyc
ADDED
|
Binary file (2.13 kB). View file
|
|
|
alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc
CHANGED
|
Binary files a/alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc and b/alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc differ
|
|
|
alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc
CHANGED
|
Binary files a/alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc and b/alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc differ
|
|
|
src/models/__pycache__/refresh_token.cpython-312.pyc
ADDED
|
Binary file (1.41 kB). View file
|
|
|
src/models/refresh_token.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from sqlalchemy import Column, DateTime, Boolean, String, ForeignKey
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class RefreshToken(SQLModel, table=True):
|
| 9 |
+
id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 10 |
+
# Use a plain sa_column without `index=True` (SQLModel does not allow index=True when sa_column is provided)
|
| 11 |
+
token_hash: str = Field(sa_column=Column(String(length=128)))
|
| 12 |
+
user_id: Optional[uuid.UUID] = Field(foreign_key="user.id")
|
| 13 |
+
revoked: bool = Field(default=False, sa_column=Column(Boolean, default=False))
|
| 14 |
+
created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow))
|
| 15 |
+
expires_at: datetime = Field(sa_column=Column(DateTime))
|
| 16 |
+
|
| 17 |
+
# Relationship back to user is optional and not required for our use-case
|
src/routers/auth.py
CHANGED
|
@@ -4,8 +4,10 @@ from typing import Annotated
|
|
| 4 |
from datetime import datetime, timedelta
|
| 5 |
from uuid import uuid4
|
| 6 |
import secrets
|
|
|
|
| 7 |
|
| 8 |
from ..models.user import User, UserCreate, UserRead
|
|
|
|
| 9 |
from ..schemas.auth import RegisterRequest, RegisterResponse, LoginRequest, LoginResponse, ForgotPasswordRequest, ResetPasswordRequest
|
| 10 |
from ..utils.security import hash_password, create_access_token, verify_password
|
| 11 |
from ..utils.deps import get_current_user
|
|
@@ -15,6 +17,10 @@ from ..config import settings
|
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
# NOTE: For cross-site cookie auth to work (backend on a different domain than the frontend):
|
| 19 |
# - The frontend must call login/register endpoints with `fetch(..., credentials: 'include')`
|
| 20 |
# - The backend must have CORS allow_credentials=True and the exact frontend origin in allow_origins
|
|
@@ -24,7 +30,10 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
| 24 |
|
| 25 |
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
| 26 |
def register(user_data: RegisterRequest, response: Response, session: Session = Depends(get_session_dep)):
|
| 27 |
-
"""Register a new user with email and password.
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# Check if user already exists
|
| 30 |
existing_user = session.exec(select(User).where(User.email == user_data.email)).first()
|
|
@@ -54,21 +63,29 @@ def register(user_data: RegisterRequest, response: Response, session: Session =
|
|
| 54 |
session.commit()
|
| 55 |
session.refresh(user)
|
| 56 |
|
| 57 |
-
#
|
| 58 |
-
access_token = create_access_token(data={"sub": str(user.id)})
|
| 59 |
-
|
| 60 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
response.set_cookie(
|
| 62 |
-
key=
|
| 63 |
-
value=
|
| 64 |
httponly=True,
|
| 65 |
-
secure=settings.JWT_COOKIE_SECURE,
|
| 66 |
-
samesite="none",
|
| 67 |
-
max_age=
|
| 68 |
-
path="/"
|
| 69 |
)
|
| 70 |
|
| 71 |
-
# Return response
|
| 72 |
return RegisterResponse(
|
| 73 |
id=user.id,
|
| 74 |
email=user.email,
|
|
@@ -78,7 +95,11 @@ def register(user_data: RegisterRequest, response: Response, session: Session =
|
|
| 78 |
|
| 79 |
@router.post("/login", response_model=LoginResponse)
|
| 80 |
def login(login_data: LoginRequest, response: Response, session: Session = Depends(get_session_dep)):
|
| 81 |
-
"""Authenticate user with email and password
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
# Find user by email
|
| 84 |
user = session.exec(select(User).where(User.email == login_data.email)).first()
|
|
@@ -90,24 +111,32 @@ def login(login_data: LoginRequest, response: Response, session: Session = Depen
|
|
| 90 |
headers={"WWW-Authenticate": "Bearer"},
|
| 91 |
)
|
| 92 |
|
| 93 |
-
# Create access token
|
| 94 |
-
access_token = create_access_token(data={"sub": str(user.id)})
|
| 95 |
-
|
| 96 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
response.set_cookie(
|
| 98 |
-
key=
|
| 99 |
-
value=
|
| 100 |
httponly=True,
|
| 101 |
-
secure=settings.JWT_COOKIE_SECURE,
|
| 102 |
-
samesite="none",
|
| 103 |
-
max_age=
|
| 104 |
-
path="/"
|
| 105 |
)
|
| 106 |
-
|
| 107 |
-
# Debug: Print cookie attributes (do NOT log token value in production)
|
| 108 |
-
print(f"Cookie set (httponly={True}, secure={settings.JWT_COOKIE_SECURE}, samesite=none, max_age={settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60})")
|
| 109 |
|
| 110 |
-
|
|
|
|
|
|
|
| 111 |
return LoginResponse(
|
| 112 |
access_token=access_token,
|
| 113 |
token_type="bearer",
|
|
@@ -119,29 +148,93 @@ def login(login_data: LoginRequest, response: Response, session: Session = Depen
|
|
| 119 |
)
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
@router.post("/logout")
|
| 123 |
-
def logout(response: Response):
|
| 124 |
-
"""Logout user by
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
response.set_cookie(
|
| 127 |
-
key=
|
| 128 |
value="",
|
| 129 |
httponly=True,
|
| 130 |
secure=settings.JWT_COOKIE_SECURE,
|
| 131 |
-
samesite="none",
|
| 132 |
max_age=0, # Expire immediately
|
| 133 |
-
path="/"
|
| 134 |
)
|
| 135 |
-
|
| 136 |
return {"message": "Logged out successfully"}
|
| 137 |
|
| 138 |
|
| 139 |
@router.get("/me", response_model=RegisterResponse)
|
| 140 |
def get_current_user_profile(request: Request, current_user: User = Depends(get_current_user)):
|
| 141 |
"""Get the current authenticated user's profile."""
|
| 142 |
-
# Debug: Print the cookies received
|
| 143 |
-
print(f"Received cookies: {request.cookies}")
|
| 144 |
-
print(f"Access token cookie: {request.cookies.get('access_token')}")
|
| 145 |
|
| 146 |
return RegisterResponse(
|
| 147 |
id=current_user.id,
|
|
|
|
| 4 |
from datetime import datetime, timedelta
|
| 5 |
from uuid import uuid4
|
| 6 |
import secrets
|
| 7 |
+
import hashlib
|
| 8 |
|
| 9 |
from ..models.user import User, UserCreate, UserRead
|
| 10 |
+
from ..models.refresh_token import RefreshToken
|
| 11 |
from ..schemas.auth import RegisterRequest, RegisterResponse, LoginRequest, LoginResponse, ForgotPasswordRequest, ResetPasswordRequest
|
| 12 |
from ..utils.security import hash_password, create_access_token, verify_password
|
| 13 |
from ..utils.deps import get_current_user
|
|
|
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 19 |
|
| 20 |
+
# Refresh token settings
|
| 21 |
+
REFRESH_TOKEN_EXPIRE_DAYS = 30
|
| 22 |
+
REFRESH_TOKEN_COOKIE_NAME = "refresh_token"
|
| 23 |
+
|
| 24 |
# NOTE: For cross-site cookie auth to work (backend on a different domain than the frontend):
|
| 25 |
# - The frontend must call login/register endpoints with `fetch(..., credentials: 'include')`
|
| 26 |
# - The backend must have CORS allow_credentials=True and the exact frontend origin in allow_origins
|
|
|
|
| 30 |
|
| 31 |
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
| 32 |
def register(user_data: RegisterRequest, response: Response, session: Session = Depends(get_session_dep)):
|
| 33 |
+
"""Register a new user with email and password.
|
| 34 |
+
|
| 35 |
+
This returns a short-lived access token in the response and sets a HttpOnly refresh cookie.
|
| 36 |
+
"""
|
| 37 |
|
| 38 |
# Check if user already exists
|
| 39 |
existing_user = session.exec(select(User).where(User.email == user_data.email)).first()
|
|
|
|
| 63 |
session.commit()
|
| 64 |
session.refresh(user)
|
| 65 |
|
| 66 |
+
# Issue short-lived access token and refresh token
|
| 67 |
+
access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=15))
|
| 68 |
+
|
| 69 |
+
# Create refresh token, store hashed token in DB
|
| 70 |
+
raw_refresh = secrets.token_urlsafe(64)
|
| 71 |
+
token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
|
| 72 |
+
expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
| 73 |
+
|
| 74 |
+
refresh_record = RefreshToken(token_hash=token_hash, user_id=user.id, expires_at=expires_at)
|
| 75 |
+
session.add(refresh_record)
|
| 76 |
+
session.commit()
|
| 77 |
+
|
| 78 |
+
# Set HttpOnly refresh cookie
|
| 79 |
response.set_cookie(
|
| 80 |
+
key=REFRESH_TOKEN_COOKIE_NAME,
|
| 81 |
+
value=raw_refresh,
|
| 82 |
httponly=True,
|
| 83 |
+
secure=settings.JWT_COOKIE_SECURE,
|
| 84 |
+
samesite="none",
|
| 85 |
+
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
|
| 86 |
+
path="/api/auth"
|
| 87 |
)
|
| 88 |
|
|
|
|
| 89 |
return RegisterResponse(
|
| 90 |
id=user.id,
|
| 91 |
email=user.email,
|
|
|
|
| 95 |
|
| 96 |
@router.post("/login", response_model=LoginResponse)
|
| 97 |
def login(login_data: LoginRequest, response: Response, session: Session = Depends(get_session_dep)):
|
| 98 |
+
"""Authenticate user with email and password.
|
| 99 |
+
|
| 100 |
+
Returns a short-lived access token in the response body and sets a HttpOnly refresh cookie.
|
| 101 |
+
Frontend should store access token in memory and call protected APIs with Authorization header, or let frontend call `/api/auth/refresh` (with `credentials: 'include'`) to obtain a new access token.
|
| 102 |
+
"""
|
| 103 |
|
| 104 |
# Find user by email
|
| 105 |
user = session.exec(select(User).where(User.email == login_data.email)).first()
|
|
|
|
| 111 |
headers={"WWW-Authenticate": "Bearer"},
|
| 112 |
)
|
| 113 |
|
| 114 |
+
# Create short-lived access token (e.g., 15 minutes)
|
| 115 |
+
access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=15))
|
| 116 |
+
|
| 117 |
+
# Create refresh token, store hashed token in DB
|
| 118 |
+
raw_refresh = secrets.token_urlsafe(64)
|
| 119 |
+
token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
|
| 120 |
+
expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
| 121 |
+
|
| 122 |
+
refresh_record = RefreshToken(token_hash=token_hash, user_id=user.id, expires_at=expires_at)
|
| 123 |
+
session.add(refresh_record)
|
| 124 |
+
session.commit()
|
| 125 |
+
|
| 126 |
+
# Set HttpOnly refresh cookie (sent to frontend with credentials: 'include')
|
| 127 |
response.set_cookie(
|
| 128 |
+
key=REFRESH_TOKEN_COOKIE_NAME,
|
| 129 |
+
value=raw_refresh,
|
| 130 |
httponly=True,
|
| 131 |
+
secure=settings.JWT_COOKIE_SECURE,
|
| 132 |
+
samesite="none",
|
| 133 |
+
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
|
| 134 |
+
path="/api/auth"
|
| 135 |
)
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
+
print(f"Refresh cookie set (httponly={True}, secure={settings.JWT_COOKIE_SECURE}, samesite=none, max_age={REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60})")
|
| 138 |
+
|
| 139 |
+
# Return short-lived access token in response body for immediate use
|
| 140 |
return LoginResponse(
|
| 141 |
access_token=access_token,
|
| 142 |
token_type="bearer",
|
|
|
|
| 148 |
)
|
| 149 |
|
| 150 |
|
| 151 |
+
@router.post("/refresh")
|
| 152 |
+
def refresh_token(request: Request, response: Response, session: Session = Depends(get_session_dep)):
|
| 153 |
+
"""Rotate refresh token and return a new short-lived access token.
|
| 154 |
+
|
| 155 |
+
This endpoint reads the HttpOnly refresh cookie, validates it, rotates it (revokes old),
|
| 156 |
+
and sets a new refresh cookie (cookie rotation) and returns a fresh access token.
|
| 157 |
+
Frontend must call this with `credentials: 'include'`.
|
| 158 |
+
"""
|
| 159 |
+
raw_refresh = request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)
|
| 160 |
+
if not raw_refresh:
|
| 161 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token provided")
|
| 162 |
+
|
| 163 |
+
token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
|
| 164 |
+
token_record = session.exec(select(RefreshToken).where(RefreshToken.token_hash == token_hash)).first()
|
| 165 |
+
|
| 166 |
+
if not token_record or token_record.revoked:
|
| 167 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
| 168 |
+
|
| 169 |
+
if token_record.expires_at < datetime.utcnow():
|
| 170 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expired")
|
| 171 |
+
|
| 172 |
+
# Get user
|
| 173 |
+
user = session.get(User, token_record.user_id)
|
| 174 |
+
if not user:
|
| 175 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
| 176 |
+
|
| 177 |
+
# Revoke old token and rotate
|
| 178 |
+
token_record.revoked = True
|
| 179 |
+
session.add(token_record)
|
| 180 |
+
|
| 181 |
+
# Create new refresh token
|
| 182 |
+
new_raw_refresh = secrets.token_urlsafe(64)
|
| 183 |
+
new_token_hash = hashlib.sha256(new_raw_refresh.encode()).hexdigest()
|
| 184 |
+
new_expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
| 185 |
+
|
| 186 |
+
new_refresh_record = RefreshToken(token_hash=new_token_hash, user_id=user.id, expires_at=new_expires_at)
|
| 187 |
+
session.add(new_refresh_record)
|
| 188 |
+
session.commit()
|
| 189 |
+
|
| 190 |
+
# Set new refresh cookie
|
| 191 |
+
response.set_cookie(
|
| 192 |
+
key=REFRESH_TOKEN_COOKIE_NAME,
|
| 193 |
+
value=new_raw_refresh,
|
| 194 |
+
httponly=True,
|
| 195 |
+
secure=settings.JWT_COOKIE_SECURE,
|
| 196 |
+
samesite="none",
|
| 197 |
+
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
|
| 198 |
+
path="/api/auth"
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# Create new short-lived access token and return it
|
| 202 |
+
access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=15))
|
| 203 |
+
|
| 204 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
@router.post("/logout")
|
| 208 |
+
def logout(request: Request, response: Response, session: Session = Depends(get_session_dep)):
|
| 209 |
+
"""Logout user by revoking the refresh token cookie and clearing the cookie."""
|
| 210 |
+
raw_refresh = request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)
|
| 211 |
+
if raw_refresh:
|
| 212 |
+
token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
|
| 213 |
+
token_record = session.exec(select(RefreshToken).where(RefreshToken.token_hash == token_hash)).first()
|
| 214 |
+
if token_record:
|
| 215 |
+
token_record.revoked = True
|
| 216 |
+
session.add(token_record)
|
| 217 |
+
session.commit()
|
| 218 |
+
|
| 219 |
+
# Clear the refresh cookie
|
| 220 |
response.set_cookie(
|
| 221 |
+
key=REFRESH_TOKEN_COOKIE_NAME,
|
| 222 |
value="",
|
| 223 |
httponly=True,
|
| 224 |
secure=settings.JWT_COOKIE_SECURE,
|
| 225 |
+
samesite="none",
|
| 226 |
max_age=0, # Expire immediately
|
| 227 |
+
path="/api/auth"
|
| 228 |
)
|
| 229 |
+
|
| 230 |
return {"message": "Logged out successfully"}
|
| 231 |
|
| 232 |
|
| 233 |
@router.get("/me", response_model=RegisterResponse)
|
| 234 |
def get_current_user_profile(request: Request, current_user: User = Depends(get_current_user)):
|
| 235 |
"""Get the current authenticated user's profile."""
|
| 236 |
+
# Debug: Print the cookies received (do not print token values)
|
| 237 |
+
print(f"Received cookies: { {k: '***' for k in request.cookies.keys()} }")
|
|
|
|
| 238 |
|
| 239 |
return RegisterResponse(
|
| 240 |
id=current_user.id,
|