Skip to content

Commit 8732889

Browse files
committed
feat:refreshtoken
1 parent 21b426d commit 8732889

24 files changed

Lines changed: 628 additions & 17 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/todo_db
55
SECRET_KEY=your-super-secret-production-key-here
66

77
ALGORITHM=HS256
8-
ACCESS_TOKEN_EXPIRE_MINUTES=30
8+
ACCESS_TOKEN_EXPIRE_MINUTES=30
9+
REFRESH_TOKEN_EXPIRE_MINUTES=10080

alembic/env.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
from alembic import context
1010
from src.core.config.setting import settings
1111
from src.modules.todo.infrastructure.models.todo_model import TodoModel # noqa: F401
12+
from src.modules.user.infrastructure.models.refresh_token_model import (
13+
RefreshTokenModel, # noqa: F401
14+
)
1215
from src.modules.user.infrastructure.models.user_model import UserModel # noqa: F401
1316
from src.shared.database.model import Base
1417

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""add refresh_tokens table
2+
3+
Revision ID: ab661f0b6986
4+
Revises: 579bfc3a008b
5+
Create Date: 2026-06-17 05:25:07.910091
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'ab661f0b6986'
16+
down_revision: Union[str, Sequence[str], None] = '579bfc3a008b'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table('refresh_tokens',
25+
sa.Column('id', sa.Uuid(), nullable=False),
26+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
27+
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
28+
sa.Column('deleted_at', sa.DateTime(), nullable=True),
29+
sa.Column('user_id', sa.Uuid(), nullable=False),
30+
sa.Column('token_hash', sa.String(length=255), nullable=False),
31+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
32+
sa.Column('is_revoked', sa.Boolean(), nullable=False),
33+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
34+
sa.PrimaryKeyConstraint('id')
35+
)
36+
op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True)
37+
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
38+
# ### end Alembic commands ###
39+
40+
41+
def downgrade() -> None:
42+
"""Downgrade schema."""
43+
# ### commands auto generated by Alembic - please adjust! ###
44+
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
45+
op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
46+
op.drop_table('refresh_tokens')
47+
# ### end Alembic commands ###

src/core/config/setting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class Settings(BaseSettings):
77
SECRET_KEY: str = "super-secret-key-change-in-production"
88
ALGORITHM: str = "HS256"
99
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
10-
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 7
10+
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080
1111

1212
class Config:
1313
env_file = ".env"

src/core/di.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
SQLAlchemyUserRepository,
1010
)
1111

12-
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
12+
oauth2_scheme = OAuth2PasswordBearer(
13+
tokenUrl="/api/v1/auth/login",
14+
refreshUrl="/api/v1/auth/refresh",
15+
)
1316

1417

1518
def get_db_session(db: AsyncSession = Depends(get_db)) -> AsyncSession:

src/core/middleware/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"/openapi.json",
1717
"/api/v1/auth/login",
1818
"/api/v1/auth/register",
19+
"/api/v1/auth/refresh",
1920
}
2021
)
2122

src/core/security/jwt.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ def create_access_token(data: dict) -> str:
1616
to_encode.update({"exp": expire})
1717
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
1818

19+
@staticmethod
20+
def create_refresh_token(data: dict) -> str:
21+
to_encode = data.copy()
22+
expire = datetime.now(timezone.utc) + timedelta(
23+
minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES,
24+
)
25+
to_encode.update({"exp": expire})
26+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
27+
1928
@staticmethod
2029
def decode_token(token: str) -> dict:
2130
try:
Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
1+
import hashlib
2+
from datetime import datetime, timedelta, timezone
3+
4+
from src.core.config.setting import settings
15
from src.core.security.jwt import JWTService
26
from src.core.security.password import PasswordSerrvice
37
from src.modules.user.application.login_user.command import LoginUserCommand
8+
from src.modules.user.domain.entities.refresh_token import RefreshToken
49
from src.modules.user.domain.exceptions.user_exception import UserNotFoundError
10+
from src.modules.user.domain.repositories.refresh_token_repository import (
11+
RefreshTokenRepository,
12+
)
513
from src.modules.user.domain.repositories.user_repository import UserRepository
614
from src.shared.exceptions.credential_exception import InvalidCredentialsError
715

816

917
class LoginUserCommandHandler:
10-
def __init__(self, user_repository: UserRepository):
18+
def __init__(
19+
self,
20+
user_repository: UserRepository,
21+
refresh_token_repository: RefreshTokenRepository,
22+
):
1123
self._user_repository = user_repository
24+
self._refresh_token_repository = refresh_token_repository
1225

1326
async def execute(self, command: LoginUserCommand) -> dict[str, str]:
1427
user = await self._user_repository.get_by_email(command.username)
@@ -20,12 +33,20 @@ async def execute(self, command: LoginUserCommand) -> dict[str, str]:
2033
):
2134
raise InvalidCredentialsError("Incorrect email or password")
2235

23-
access_token = JWTService.create_access_token(
24-
data={
25-
"fullname": user.fullname,
26-
"email": user.email,
27-
"sub": str(user.id),
28-
}
36+
access_token = JWTService.create_access_token(data={"sub": str(user.id)})
37+
38+
refresh_token_str = JWTService.create_refresh_token(data={"sub": str(user.id)})
39+
token_hash = hashlib.sha256(refresh_token_str.encode()).hexdigest()
40+
expires_at = datetime.now(timezone.utc) + timedelta(
41+
minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES
42+
)
43+
44+
new_rt = RefreshToken.create(
45+
user_id=user.id, token_hash=token_hash, expires_at=expires_at
2946
)
47+
await self._refresh_token_repository.save(new_rt)
3048

31-
return {"access_token": access_token}
49+
return {
50+
"access_token": access_token,
51+
"refresh_token": refresh_token_str,
52+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pydantic import BaseModel
2+
3+
4+
class RefreshTokenCommand(BaseModel):
5+
token: str
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import hashlib
2+
from datetime import datetime, timedelta, timezone
3+
4+
from src.core.config.setting import settings
5+
from src.core.security.jwt import JWTService
6+
from src.modules.user.application.refresh_token.command import RefreshTokenCommand
7+
from src.modules.user.domain.entities.refresh_token import RefreshToken
8+
from src.modules.user.domain.repositories.refresh_token_repository import (
9+
RefreshTokenRepository,
10+
)
11+
from src.shared.exceptions.credential_exception import InvalidRefreshTokenError
12+
13+
14+
class RefreshTokenCommandHandler:
15+
def __init__(self, refresh_token_repo: RefreshTokenRepository):
16+
self._refresh_token_repo = refresh_token_repo
17+
18+
def _hash_token(self, token: str) -> str:
19+
return hashlib.sha256(token.encode()).hexdigest()
20+
21+
async def execute(self, command: RefreshTokenCommand) -> dict:
22+
token_hash = self._hash_token(command.token)
23+
stored_token = await self._refresh_token_repo.get_by_token_hash(token_hash)
24+
25+
if not stored_token:
26+
raise InvalidRefreshTokenError("Invalid refresh token")
27+
if stored_token.is_revoked:
28+
raise InvalidRefreshTokenError("Refresh token has been revoked")
29+
if stored_token.expires_at < datetime.now(timezone.utc):
30+
raise InvalidRefreshTokenError("Refresh token has expired")
31+
32+
stored_token.revoke()
33+
await self._refresh_token_repo.save(stored_token)
34+
35+
new_access_token = JWTService.create_access_token(
36+
data={"sub": str(stored_token.user_id)}
37+
)
38+
39+
new_refresh_token_str = JWTService.create_refresh_token(
40+
data={"sub": str(stored_token.user_id)}
41+
)
42+
43+
new_token_hash = self._hash_token(new_refresh_token_str)
44+
45+
expires_at = datetime.now(timezone.utc) + timedelta(
46+
minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES,
47+
)
48+
49+
new_refresh_token_entity = RefreshToken.create(
50+
user_id=stored_token.user_id,
51+
token_hash=new_token_hash,
52+
expires_at=expires_at,
53+
)
54+
55+
await self._refresh_token_repo.save(new_refresh_token_entity)
56+
57+
return {
58+
"access_token": new_access_token,
59+
"refresh_token": new_refresh_token_str,
60+
"token_type": "bearer",
61+
}

0 commit comments

Comments
 (0)