Skip to content

Commit 24b80e8

Browse files
committed
add some endpoints and fix errors develop security and add password service
1 parent b81e527 commit 24b80e8

17 files changed

Lines changed: 682 additions & 119 deletions

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
repos:
2-
- repo: https://github.com/pre-commit/mirrors-isort
3-
rev: v5.10.1
4-
hooks:
5-
- id: isort
2+
# - repo: https://github.com/pre-commit/mirrors-isort
3+
# rev: v5.10.1
4+
# hooks:
5+
# - id: isort
66

77
- repo: https://github.com/psf/black
88
rev: 24.1.0

src/api/v1/user.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
from fastapi import APIRouter, Depends
1+
from fastapi import APIRouter, Body, Depends
22

33
from db.dependencies.auth import get_current_user, require_roles
44
from db.models.users import User, UserRole
55
from schemas.auth import (
6+
ForgotPasswordScheme,
67
LoginOutScheme,
78
LoginScheme,
9+
PasswordResetScheme,
10+
RefreshTokenScheme,
811
RegisterOutScheme,
912
RegistrationScheme,
13+
TokenScheme,
1014
)
1115
from schemas.user import UserCreateScheme
16+
from services.password_service import PasswordService
1217
from services.user_service import UserService
1318

1419
router = APIRouter()
@@ -50,4 +55,68 @@ async def login_user(
5055
return await user_service.login(data)
5156

5257

58+
@router.post("/logout")
59+
async def logout_user(data: RefreshTokenScheme, user_service: UserService = Depends()):
60+
"""
61+
Logout and invalidate refresh token
62+
"""
63+
return await user_service.logout(data.refresh_token)
64+
65+
66+
@router.post("/forgot-password")
67+
async def forgot_password(
68+
data: ForgotPasswordScheme, password_service: PasswordService = Depends()
69+
) -> dict[str, str]:
70+
return await password_service.forgot_password(data)
71+
72+
73+
@router.post("/reset-password")
74+
async def reset_password(
75+
data: PasswordResetScheme, password_service: PasswordService = Depends()
76+
):
77+
return await password_service.reset_password(data)
78+
79+
80+
@router.post("/refresh-token")
81+
async def refresh_token(
82+
data: RefreshTokenScheme = Body(...), user_service: UserService = Depends()
83+
):
84+
"""
85+
Refresh access token using refresh token
86+
"""
87+
return await user_service.refresh_access_token(data.refresh_token)
88+
89+
90+
@router.post("/verify-email")
91+
async def verify_email(
92+
data: TokenScheme = Body(...), user_service: UserService = Depends()
93+
):
94+
"""
95+
Verify user email via token
96+
"""
97+
return await user_service.verify_email(data.token)
98+
99+
100+
@router.post("/verify-phone")
101+
async def verify_phone(
102+
data: TokenScheme = Body(...), user_service: UserService = Depends()
103+
):
104+
"""
105+
Verify user phone via token
106+
"""
107+
return await user_service.verify_phone(data.token)
108+
109+
110+
@router.post("/resend-verification")
111+
async def resend_verification(
112+
user_id: int = Body(...),
113+
type_: str = Body(...),
114+
user_service: UserService = Depends(),
115+
):
116+
"""
117+
Resend email or phone verification token
118+
"""
119+
return await user_service.resend_verification(user_id, type_)
120+
121+
53122
__all__ = ("router",)

src/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class BaseAppSettings(BaseSettings):
2828
JWT_ALGORITHM: str = "HS256"
2929
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
3030
JWT_REFRESH_TOKEN_EXPIRES_DAYS: int = 7
31+
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 15
3132

3233
# DATABASE_URL: str
3334
DB_ENGINE: str = "sqlite"

src/core/security.py

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import uuid
2-
from datetime import datetime, timedelta
2+
from datetime import datetime, timedelta, timezone
33
from typing import Any, Dict, Optional, Union
44

5+
from fastapi import HTTPException
56
from jose import ExpiredSignatureError, JWTError, jwt
67
from passlib.context import CryptContext
78

@@ -22,7 +23,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
2223

2324
# helpers
2425
def _now() -> datetime:
25-
return datetime.utcnow()
26+
return datetime.now(timezone.utc)
2627

2728

2829
def _jti() -> str:
@@ -36,31 +37,35 @@ def create_access_token(
3637
expires_delta: timedelta | None = None,
3738
) -> str:
3839
"""
39-
Create access token.
40+
Create access token (JWT) with proper iat and exp timestamps.
4041
"""
4142

4243
if isinstance(subject, dict):
43-
payload_access: Dict[str, Any] = subject.copy()
44+
payload: Dict[str, Any] = subject.copy()
4445
else:
45-
payload_access: Dict[str, Any] = {"sub": str(subject)} # type: ignore
46+
payload: Dict[str, Any] = {"sub": str(subject)} # type: ignore
4647

47-
payload_access.setdefault("type", "access")
48-
payload_access.setdefault("jti", _jti())
49-
payload_access.setdefault("iat", int(_now().timestamp()))
48+
payload.setdefault("type", "access")
49+
payload.setdefault("jti", _jti())
5050

51-
if extra:
52-
payload_access.update(extra)
53-
54-
if expires_delta:
55-
exp = _now() + expires_delta
56-
else:
57-
exp = _now() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES or 15)
58-
59-
payload_access["exp"] = int(exp.timestamp())
51+
now_ts = int(_now().timestamp())
52+
payload["iat"] = now_ts
6053

61-
return jwt.encode(
62-
payload_access, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM
54+
if extra:
55+
payload.update(extra)
56+
57+
exp_ts = int(
58+
(
59+
_now()
60+
+ (
61+
expires_delta
62+
or timedelta(minutes=settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES)
63+
)
64+
).timestamp()
6365
)
66+
payload["exp"] = exp_ts
67+
68+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
6469

6570

6671
def create_refresh_token(
@@ -69,41 +74,46 @@ def create_refresh_token(
6974
expires_delta: timedelta | None = None,
7075
) -> str:
7176
"""
72-
Create refresh token.
77+
Create refresh token (JWT) with proper iat and exp timestamps.
7378
"""
7479

7580
if isinstance(subject, dict):
76-
payload_refresh: Dict[str, Any] = subject.copy()
81+
payload: Dict[str, Any] = subject.copy()
7782
else:
78-
payload_refresh: Dict[str, Any] = {"sub": str(subject)} # type: ignore
83+
payload: Dict[str, Any] = {"sub": str(subject)} # type: ignore
7984

80-
payload_refresh.setdefault("type", "refresh")
81-
payload_refresh.setdefault("jti", _jti())
82-
payload_refresh.setdefault("iat", int(_now().timestamp()))
85+
payload.setdefault("type", "refresh")
86+
payload.setdefault("jti", _jti())
87+
payload["iat"] = int(_now().timestamp())
8388

8489
if extra:
85-
payload_refresh.update(extra)
90+
payload.update(extra)
8691

87-
if expires_delta:
88-
exp = _now() + expires_delta
89-
else:
90-
exp = _now() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRES_DAYS or 30)
91-
92-
payload_refresh["exp"] = int(exp.timestamp())
93-
94-
return jwt.encode(
95-
payload_refresh, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM
92+
exp_ts = int(
93+
(
94+
_now()
95+
+ (expires_delta or timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRES_DAYS))
96+
).timestamp()
9697
)
98+
payload["exp"] = exp_ts
99+
100+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
97101

98102

99103
def decode_token(token: str) -> dict:
104+
"""
105+
Decode JWT token. By default, disables exp verification for internal inspection.
106+
Use jose.decode(token, ..., options={"verify_exp": True}) when verifying token lifetime.
107+
"""
100108
try:
101109
payload = jwt.decode(
102-
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
110+
token,
111+
settings.SECRET_KEY,
112+
algorithms=[settings.JWT_ALGORITHM],
113+
options={"verify_exp": True},
103114
)
104115
return payload
105116
except ExpiredSignatureError:
106-
raise ExpiredSignatureError("Token has expired")
107-
117+
raise HTTPException(status_code=400, detail="Token has expired")
108118
except JWTError:
109-
raise JWTError("Invalid token")
119+
raise HTTPException(status_code=400, detail="Invalid token")

src/db/crud/user.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from sqlalchemy import select
33
from sqlalchemy.ext.asyncio import AsyncSession
44

5+
from core.security import hash_password
56
from db.dependencies.sessions import get_db_session
67
from db.models.users import User
78
from schemas.auth import RegistrationScheme
@@ -49,3 +50,24 @@ async def get_by_id(self, user_id: int) -> User | None:
4950
stmt = select(User).where(User.id == user_id)
5051
result = await self.session.execute(stmt)
5152
return result.scalars().first()
53+
54+
async def update_password(self, user: User, new_password) -> User:
55+
user.hashed_password = hash_password(new_password)
56+
self.session.add(user)
57+
await self.session.commit()
58+
await self.session.refresh(user)
59+
return user
60+
61+
async def mark_email_verified(self, user: User) -> User:
62+
user.is_email_verified = True
63+
self.session.add(user)
64+
await self.session.commit()
65+
await self.session.refresh(user)
66+
return user
67+
68+
async def mark_phone_verified(self, user: User) -> User:
69+
user.is_phone_verified = True
70+
self.session.add(user)
71+
await self.session.commit()
72+
await self.session.refresh(user)
73+
return user

0 commit comments

Comments
 (0)