claude-code-templates 1.14.13 → 1.14.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/templates/common/.claude/commands/git-workflow.md +0 -239
- package/templates/common/.claude/commands/project-setup.md +0 -316
- package/templates/common/.mcp.json +0 -41
- package/templates/common/CLAUDE.md +0 -109
- package/templates/common/README.md +0 -96
- package/templates/go/.mcp.json +0 -78
- package/templates/go/README.md +0 -25
- package/templates/javascript-typescript/.claude/commands/api-endpoint.md +0 -51
- package/templates/javascript-typescript/.claude/commands/debug.md +0 -52
- package/templates/javascript-typescript/.claude/commands/lint.md +0 -48
- package/templates/javascript-typescript/.claude/commands/npm-scripts.md +0 -48
- package/templates/javascript-typescript/.claude/commands/refactor.md +0 -55
- package/templates/javascript-typescript/.claude/commands/test.md +0 -61
- package/templates/javascript-typescript/.claude/commands/typescript-migrate.md +0 -51
- package/templates/javascript-typescript/.claude/settings.json +0 -142
- package/templates/javascript-typescript/.mcp.json +0 -80
- package/templates/javascript-typescript/CLAUDE.md +0 -185
- package/templates/javascript-typescript/README.md +0 -259
- package/templates/javascript-typescript/examples/angular-app/.claude/commands/components.md +0 -63
- package/templates/javascript-typescript/examples/angular-app/.claude/commands/services.md +0 -62
- package/templates/javascript-typescript/examples/node-api/.claude/commands/api-endpoint.md +0 -46
- package/templates/javascript-typescript/examples/node-api/.claude/commands/database.md +0 -56
- package/templates/javascript-typescript/examples/node-api/.claude/commands/middleware.md +0 -61
- package/templates/javascript-typescript/examples/node-api/.claude/commands/route.md +0 -57
- package/templates/javascript-typescript/examples/node-api/CLAUDE.md +0 -102
- package/templates/javascript-typescript/examples/react-app/.claude/commands/component.md +0 -29
- package/templates/javascript-typescript/examples/react-app/.claude/commands/hooks.md +0 -44
- package/templates/javascript-typescript/examples/react-app/.claude/commands/state-management.md +0 -45
- package/templates/javascript-typescript/examples/react-app/CLAUDE.md +0 -81
- package/templates/javascript-typescript/examples/react-app/agents/react-performance-optimization.md +0 -530
- package/templates/javascript-typescript/examples/react-app/agents/react-state-management.md +0 -295
- package/templates/javascript-typescript/examples/vue-app/.claude/commands/components.md +0 -46
- package/templates/javascript-typescript/examples/vue-app/.claude/commands/composables.md +0 -51
- package/templates/python/.claude/commands/lint.md +0 -111
- package/templates/python/.claude/commands/test.md +0 -73
- package/templates/python/.claude/settings.json +0 -153
- package/templates/python/.mcp.json +0 -78
- package/templates/python/CLAUDE.md +0 -276
- package/templates/python/examples/django-app/.claude/commands/admin.md +0 -264
- package/templates/python/examples/django-app/.claude/commands/django-model.md +0 -124
- package/templates/python/examples/django-app/.claude/commands/views.md +0 -222
- package/templates/python/examples/django-app/CLAUDE.md +0 -313
- package/templates/python/examples/fastapi-app/.claude/commands/api-endpoints.md +0 -513
- package/templates/python/examples/fastapi-app/.claude/commands/auth.md +0 -775
- package/templates/python/examples/fastapi-app/.claude/commands/database.md +0 -657
- package/templates/python/examples/fastapi-app/.claude/commands/deployment.md +0 -160
- package/templates/python/examples/fastapi-app/.claude/commands/testing.md +0 -927
- package/templates/python/examples/fastapi-app/CLAUDE.md +0 -229
- package/templates/python/examples/flask-app/.claude/commands/app-factory.md +0 -384
- package/templates/python/examples/flask-app/.claude/commands/blueprint.md +0 -243
- package/templates/python/examples/flask-app/.claude/commands/database.md +0 -410
- package/templates/python/examples/flask-app/.claude/commands/deployment.md +0 -620
- package/templates/python/examples/flask-app/.claude/commands/flask-route.md +0 -217
- package/templates/python/examples/flask-app/.claude/commands/testing.md +0 -559
- package/templates/python/examples/flask-app/CLAUDE.md +0 -391
- package/templates/ruby/.claude/commands/model.md +0 -360
- package/templates/ruby/.claude/commands/test.md +0 -480
- package/templates/ruby/.claude/settings.json +0 -146
- package/templates/ruby/.mcp.json +0 -83
- package/templates/ruby/CLAUDE.md +0 -284
- package/templates/ruby/examples/rails-app/.claude/commands/authentication.md +0 -490
- package/templates/ruby/examples/rails-app/CLAUDE.md +0 -376
- package/templates/rust/.mcp.json +0 -78
- package/templates/rust/README.md +0 -26
|
@@ -1,775 +0,0 @@
|
|
|
1
|
-
# FastAPI Authentication & Authorization
|
|
2
|
-
|
|
3
|
-
Complete authentication system with JWT tokens, OAuth2, and role-based access control.
|
|
4
|
-
|
|
5
|
-
## Usage
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
# Install auth dependencies
|
|
9
|
-
pip install python-jose[cryptography] passlib[bcrypt] python-multipart
|
|
10
|
-
|
|
11
|
-
# Generate secret key
|
|
12
|
-
openssl rand -hex 32
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## JWT Configuration
|
|
16
|
-
|
|
17
|
-
```python
|
|
18
|
-
# app/core/security.py
|
|
19
|
-
from datetime import datetime, timedelta
|
|
20
|
-
from typing import Optional, Union, Any
|
|
21
|
-
from jose import JWTError, jwt
|
|
22
|
-
from passlib.context import CryptContext
|
|
23
|
-
from app.core.config import settings
|
|
24
|
-
|
|
25
|
-
# Password hashing
|
|
26
|
-
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
27
|
-
|
|
28
|
-
# JWT settings
|
|
29
|
-
SECRET_KEY = settings.SECRET_KEY
|
|
30
|
-
ALGORITHM = "HS256"
|
|
31
|
-
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
32
|
-
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
|
33
|
-
|
|
34
|
-
def create_access_token(
|
|
35
|
-
subject: Union[str, Any],
|
|
36
|
-
expires_delta: Optional[timedelta] = None
|
|
37
|
-
) -> str:
|
|
38
|
-
"""Create JWT access token."""
|
|
39
|
-
if expires_delta:
|
|
40
|
-
expire = datetime.utcnow() + expires_delta
|
|
41
|
-
else:
|
|
42
|
-
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
43
|
-
|
|
44
|
-
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
|
45
|
-
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
46
|
-
return encoded_jwt
|
|
47
|
-
|
|
48
|
-
def create_refresh_token(subject: Union[str, Any]) -> str:
|
|
49
|
-
"""Create JWT refresh token."""
|
|
50
|
-
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
|
51
|
-
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
|
52
|
-
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
53
|
-
return encoded_jwt
|
|
54
|
-
|
|
55
|
-
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
56
|
-
"""Verify password against hash."""
|
|
57
|
-
return pwd_context.verify(plain_password, hashed_password)
|
|
58
|
-
|
|
59
|
-
def get_password_hash(password: str) -> str:
|
|
60
|
-
"""Generate password hash."""
|
|
61
|
-
return pwd_context.hash(password)
|
|
62
|
-
|
|
63
|
-
def decode_token(token: str) -> Optional[dict]:
|
|
64
|
-
"""Decode and verify JWT token."""
|
|
65
|
-
try:
|
|
66
|
-
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
67
|
-
return payload
|
|
68
|
-
except JWTError:
|
|
69
|
-
return None
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
## Authentication Dependencies
|
|
73
|
-
|
|
74
|
-
```python
|
|
75
|
-
# app/api/dependencies/auth.py
|
|
76
|
-
from typing import Optional
|
|
77
|
-
from fastapi import Depends, HTTPException, status
|
|
78
|
-
from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials
|
|
79
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
80
|
-
from app.core.security import decode_token
|
|
81
|
-
from app.db.database import get_db
|
|
82
|
-
from app.models.user import User
|
|
83
|
-
from app.repositories.user import UserRepository
|
|
84
|
-
|
|
85
|
-
# OAuth2 scheme
|
|
86
|
-
oauth2_scheme = OAuth2PasswordBearer(
|
|
87
|
-
tokenUrl="/api/v1/auth/login",
|
|
88
|
-
scheme_name="JWT"
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
# Bearer token scheme
|
|
92
|
-
security = HTTPBearer()
|
|
93
|
-
|
|
94
|
-
async def get_current_user(
|
|
95
|
-
token: str = Depends(oauth2_scheme),
|
|
96
|
-
db: AsyncSession = Depends(get_db)
|
|
97
|
-
) -> User:
|
|
98
|
-
"""Get current authenticated user."""
|
|
99
|
-
credentials_exception = HTTPException(
|
|
100
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
101
|
-
detail="Could not validate credentials",
|
|
102
|
-
headers={"WWW-Authenticate": "Bearer"},
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
payload = decode_token(token)
|
|
106
|
-
if payload is None:
|
|
107
|
-
raise credentials_exception
|
|
108
|
-
|
|
109
|
-
user_id: str = payload.get("sub")
|
|
110
|
-
token_type: str = payload.get("type")
|
|
111
|
-
|
|
112
|
-
if user_id is None or token_type != "access":
|
|
113
|
-
raise credentials_exception
|
|
114
|
-
|
|
115
|
-
user_repo = UserRepository(User, db)
|
|
116
|
-
user = await user_repo.get(int(user_id))
|
|
117
|
-
|
|
118
|
-
if user is None:
|
|
119
|
-
raise credentials_exception
|
|
120
|
-
|
|
121
|
-
if not user.is_active:
|
|
122
|
-
raise HTTPException(
|
|
123
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
124
|
-
detail="Inactive user"
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
return user
|
|
128
|
-
|
|
129
|
-
async def get_current_active_user(
|
|
130
|
-
current_user: User = Depends(get_current_user)
|
|
131
|
-
) -> User:
|
|
132
|
-
"""Get current active user."""
|
|
133
|
-
if not current_user.is_active:
|
|
134
|
-
raise HTTPException(
|
|
135
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
136
|
-
detail="Inactive user"
|
|
137
|
-
)
|
|
138
|
-
return current_user
|
|
139
|
-
|
|
140
|
-
async def get_current_superuser(
|
|
141
|
-
current_user: User = Depends(get_current_user)
|
|
142
|
-
) -> User:
|
|
143
|
-
"""Get current superuser."""
|
|
144
|
-
if not current_user.is_superuser:
|
|
145
|
-
raise HTTPException(
|
|
146
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
147
|
-
detail="Not enough permissions"
|
|
148
|
-
)
|
|
149
|
-
return current_user
|
|
150
|
-
|
|
151
|
-
def require_permissions(*permissions: str):
|
|
152
|
-
"""Decorator for permission-based access control."""
|
|
153
|
-
async def permission_checker(
|
|
154
|
-
current_user: User = Depends(get_current_active_user)
|
|
155
|
-
) -> User:
|
|
156
|
-
# Check if user has required permissions
|
|
157
|
-
user_permissions = set(current_user.permissions or [])
|
|
158
|
-
required_permissions = set(permissions)
|
|
159
|
-
|
|
160
|
-
if not required_permissions.issubset(user_permissions) and not current_user.is_superuser:
|
|
161
|
-
raise HTTPException(
|
|
162
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
163
|
-
detail="Insufficient permissions"
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
return current_user
|
|
167
|
-
|
|
168
|
-
return permission_checker
|
|
169
|
-
|
|
170
|
-
def require_roles(*roles: str):
|
|
171
|
-
"""Decorator for role-based access control."""
|
|
172
|
-
async def role_checker(
|
|
173
|
-
current_user: User = Depends(get_current_active_user)
|
|
174
|
-
) -> User:
|
|
175
|
-
user_roles = set(role.name for role in current_user.roles or [])
|
|
176
|
-
required_roles = set(roles)
|
|
177
|
-
|
|
178
|
-
if not required_roles.issubset(user_roles) and not current_user.is_superuser:
|
|
179
|
-
raise HTTPException(
|
|
180
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
181
|
-
detail="Insufficient role permissions"
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
return current_user
|
|
185
|
-
|
|
186
|
-
return role_checker
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
## Authentication Schemas
|
|
190
|
-
|
|
191
|
-
```python
|
|
192
|
-
# app/schemas/auth.py
|
|
193
|
-
from pydantic import BaseModel, EmailStr
|
|
194
|
-
from typing import Optional
|
|
195
|
-
|
|
196
|
-
class Token(BaseModel):
|
|
197
|
-
"""Token response schema."""
|
|
198
|
-
access_token: str
|
|
199
|
-
refresh_token: str
|
|
200
|
-
token_type: str = "bearer"
|
|
201
|
-
expires_in: int
|
|
202
|
-
|
|
203
|
-
class TokenPayload(BaseModel):
|
|
204
|
-
"""Token payload schema."""
|
|
205
|
-
sub: Optional[int] = None
|
|
206
|
-
exp: Optional[int] = None
|
|
207
|
-
type: Optional[str] = None
|
|
208
|
-
|
|
209
|
-
class UserLogin(BaseModel):
|
|
210
|
-
"""User login schema."""
|
|
211
|
-
username: str
|
|
212
|
-
password: str
|
|
213
|
-
|
|
214
|
-
class UserRegister(BaseModel):
|
|
215
|
-
"""User registration schema."""
|
|
216
|
-
username: str
|
|
217
|
-
email: EmailStr
|
|
218
|
-
password: str
|
|
219
|
-
first_name: str
|
|
220
|
-
last_name: str
|
|
221
|
-
|
|
222
|
-
class PasswordReset(BaseModel):
|
|
223
|
-
"""Password reset schema."""
|
|
224
|
-
email: EmailStr
|
|
225
|
-
|
|
226
|
-
class PasswordResetConfirm(BaseModel):
|
|
227
|
-
"""Password reset confirmation schema."""
|
|
228
|
-
token: str
|
|
229
|
-
new_password: str
|
|
230
|
-
|
|
231
|
-
class ChangePassword(BaseModel):
|
|
232
|
-
"""Change password schema."""
|
|
233
|
-
current_password: str
|
|
234
|
-
new_password: str
|
|
235
|
-
|
|
236
|
-
class RefreshToken(BaseModel):
|
|
237
|
-
"""Refresh token schema."""
|
|
238
|
-
refresh_token: str
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
## Authentication Endpoints
|
|
242
|
-
|
|
243
|
-
```python
|
|
244
|
-
# app/api/v1/auth.py
|
|
245
|
-
from datetime import timedelta
|
|
246
|
-
from typing import Any
|
|
247
|
-
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
|
248
|
-
from fastapi.security import OAuth2PasswordRequestForm
|
|
249
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
250
|
-
from app.db.database import get_db
|
|
251
|
-
from app.models.user import User
|
|
252
|
-
from app.repositories.user import UserRepository
|
|
253
|
-
from app.schemas.auth import (
|
|
254
|
-
Token, UserLogin, UserRegister, PasswordReset,
|
|
255
|
-
PasswordResetConfirm, ChangePassword, RefreshToken
|
|
256
|
-
)
|
|
257
|
-
from app.schemas.user import UserCreate, UserResponse
|
|
258
|
-
from app.core.security import (
|
|
259
|
-
create_access_token, create_refresh_token, verify_password,
|
|
260
|
-
decode_token, ACCESS_TOKEN_EXPIRE_MINUTES
|
|
261
|
-
)
|
|
262
|
-
from app.api.dependencies.auth import get_current_user, get_current_active_user
|
|
263
|
-
from app.services.email import send_password_reset_email
|
|
264
|
-
from app.services.auth import AuthService
|
|
265
|
-
|
|
266
|
-
router = APIRouter()
|
|
267
|
-
|
|
268
|
-
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
269
|
-
async def register(
|
|
270
|
-
user_data: UserRegister,
|
|
271
|
-
db: AsyncSession = Depends(get_db)
|
|
272
|
-
) -> Any:
|
|
273
|
-
"""Register new user."""
|
|
274
|
-
user_repo = UserRepository(User, db)
|
|
275
|
-
auth_service = AuthService(user_repo)
|
|
276
|
-
|
|
277
|
-
# Check if user already exists
|
|
278
|
-
if await user_repo.get_by_email(user_data.email):
|
|
279
|
-
raise HTTPException(
|
|
280
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
281
|
-
detail="Email already registered"
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
if await user_repo.get_by_username(user_data.username):
|
|
285
|
-
raise HTTPException(
|
|
286
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
287
|
-
detail="Username already taken"
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
# Create user
|
|
291
|
-
user = await auth_service.create_user(user_data.dict())
|
|
292
|
-
return user
|
|
293
|
-
|
|
294
|
-
@router.post("/login", response_model=Token)
|
|
295
|
-
async def login(
|
|
296
|
-
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
297
|
-
db: AsyncSession = Depends(get_db)
|
|
298
|
-
) -> Any:
|
|
299
|
-
"""OAuth2 compatible token login."""
|
|
300
|
-
user_repo = UserRepository(User, db)
|
|
301
|
-
auth_service = AuthService(user_repo)
|
|
302
|
-
|
|
303
|
-
user = await auth_service.authenticate_user(
|
|
304
|
-
form_data.username,
|
|
305
|
-
form_data.password
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
if not user:
|
|
309
|
-
raise HTTPException(
|
|
310
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
311
|
-
detail="Incorrect username or password",
|
|
312
|
-
headers={"WWW-Authenticate": "Bearer"},
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
if not user.is_active:
|
|
316
|
-
raise HTTPException(
|
|
317
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
318
|
-
detail="Inactive user"
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
# Create tokens
|
|
322
|
-
access_token = create_access_token(subject=user.id)
|
|
323
|
-
refresh_token = create_refresh_token(subject=user.id)
|
|
324
|
-
|
|
325
|
-
return {
|
|
326
|
-
"access_token": access_token,
|
|
327
|
-
"refresh_token": refresh_token,
|
|
328
|
-
"token_type": "bearer",
|
|
329
|
-
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
@router.post("/refresh", response_model=Token)
|
|
333
|
-
async def refresh_token(
|
|
334
|
-
refresh_data: RefreshToken,
|
|
335
|
-
db: AsyncSession = Depends(get_db)
|
|
336
|
-
) -> Any:
|
|
337
|
-
"""Refresh access token."""
|
|
338
|
-
payload = decode_token(refresh_data.refresh_token)
|
|
339
|
-
|
|
340
|
-
if payload is None or payload.get("type") != "refresh":
|
|
341
|
-
raise HTTPException(
|
|
342
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
343
|
-
detail="Invalid refresh token"
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
user_id = payload.get("sub")
|
|
347
|
-
if user_id is None:
|
|
348
|
-
raise HTTPException(
|
|
349
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
350
|
-
detail="Invalid refresh token"
|
|
351
|
-
)
|
|
352
|
-
|
|
353
|
-
user_repo = UserRepository(User, db)
|
|
354
|
-
user = await user_repo.get(int(user_id))
|
|
355
|
-
|
|
356
|
-
if user is None or not user.is_active:
|
|
357
|
-
raise HTTPException(
|
|
358
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
359
|
-
detail="Invalid refresh token"
|
|
360
|
-
)
|
|
361
|
-
|
|
362
|
-
# Create new tokens
|
|
363
|
-
access_token = create_access_token(subject=user.id)
|
|
364
|
-
new_refresh_token = create_refresh_token(subject=user.id)
|
|
365
|
-
|
|
366
|
-
return {
|
|
367
|
-
"access_token": access_token,
|
|
368
|
-
"refresh_token": new_refresh_token,
|
|
369
|
-
"token_type": "bearer",
|
|
370
|
-
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
@router.get("/me", response_model=UserResponse)
|
|
374
|
-
async def read_users_me(
|
|
375
|
-
current_user: User = Depends(get_current_active_user)
|
|
376
|
-
) -> Any:
|
|
377
|
-
"""Get current user."""
|
|
378
|
-
return current_user
|
|
379
|
-
|
|
380
|
-
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
|
381
|
-
async def change_password(
|
|
382
|
-
password_data: ChangePassword,
|
|
383
|
-
current_user: User = Depends(get_current_active_user),
|
|
384
|
-
db: AsyncSession = Depends(get_db)
|
|
385
|
-
) -> Any:
|
|
386
|
-
"""Change user password."""
|
|
387
|
-
if not verify_password(password_data.current_password, current_user.hashed_password):
|
|
388
|
-
raise HTTPException(
|
|
389
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
390
|
-
detail="Incorrect current password"
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
user_repo = UserRepository(User, db)
|
|
394
|
-
auth_service = AuthService(user_repo)
|
|
395
|
-
|
|
396
|
-
await auth_service.change_password(current_user.id, password_data.new_password)
|
|
397
|
-
|
|
398
|
-
return {"message": "Password changed successfully"}
|
|
399
|
-
|
|
400
|
-
@router.post("/password-reset", status_code=status.HTTP_200_OK)
|
|
401
|
-
async def password_reset(
|
|
402
|
-
reset_data: PasswordReset,
|
|
403
|
-
background_tasks: BackgroundTasks,
|
|
404
|
-
db: AsyncSession = Depends(get_db)
|
|
405
|
-
) -> Any:
|
|
406
|
-
"""Request password reset."""
|
|
407
|
-
user_repo = UserRepository(User, db)
|
|
408
|
-
user = await user_repo.get_by_email(reset_data.email)
|
|
409
|
-
|
|
410
|
-
if user:
|
|
411
|
-
# Generate reset token
|
|
412
|
-
reset_token = create_access_token(
|
|
413
|
-
subject=user.id,
|
|
414
|
-
expires_delta=timedelta(hours=1) # 1 hour expiry
|
|
415
|
-
)
|
|
416
|
-
|
|
417
|
-
# Send email with reset token
|
|
418
|
-
background_tasks.add_task(
|
|
419
|
-
send_password_reset_email,
|
|
420
|
-
email=user.email,
|
|
421
|
-
username=user.username,
|
|
422
|
-
token=reset_token
|
|
423
|
-
)
|
|
424
|
-
|
|
425
|
-
# Always return success to prevent email enumeration
|
|
426
|
-
return {"message": "Password reset email sent if account exists"}
|
|
427
|
-
|
|
428
|
-
@router.post("/password-reset-confirm", status_code=status.HTTP_200_OK)
|
|
429
|
-
async def password_reset_confirm(
|
|
430
|
-
reset_data: PasswordResetConfirm,
|
|
431
|
-
db: AsyncSession = Depends(get_db)
|
|
432
|
-
) -> Any:
|
|
433
|
-
"""Confirm password reset."""
|
|
434
|
-
payload = decode_token(reset_data.token)
|
|
435
|
-
|
|
436
|
-
if payload is None:
|
|
437
|
-
raise HTTPException(
|
|
438
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
439
|
-
detail="Invalid or expired reset token"
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
user_id = payload.get("sub")
|
|
443
|
-
if user_id is None:
|
|
444
|
-
raise HTTPException(
|
|
445
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
446
|
-
detail="Invalid reset token"
|
|
447
|
-
)
|
|
448
|
-
|
|
449
|
-
user_repo = UserRepository(User, db)
|
|
450
|
-
auth_service = AuthService(user_repo)
|
|
451
|
-
|
|
452
|
-
user = await user_repo.get(int(user_id))
|
|
453
|
-
if user is None:
|
|
454
|
-
raise HTTPException(
|
|
455
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
456
|
-
detail="Invalid reset token"
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
await auth_service.change_password(user.id, reset_data.new_password)
|
|
460
|
-
|
|
461
|
-
return {"message": "Password reset successful"}
|
|
462
|
-
|
|
463
|
-
@router.post("/logout", status_code=status.HTTP_200_OK)
|
|
464
|
-
async def logout(
|
|
465
|
-
current_user: User = Depends(get_current_user)
|
|
466
|
-
) -> Any:
|
|
467
|
-
"""Logout user (invalidate token on client side)."""
|
|
468
|
-
# In a more sophisticated setup, you might want to blacklist the token
|
|
469
|
-
return {"message": "Successfully logged out"}
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
## Authentication Service
|
|
473
|
-
|
|
474
|
-
```python
|
|
475
|
-
# app/services/auth.py
|
|
476
|
-
from typing import Optional
|
|
477
|
-
from app.models.user import User
|
|
478
|
-
from app.repositories.user import UserRepository
|
|
479
|
-
from app.core.security import verify_password, get_password_hash
|
|
480
|
-
|
|
481
|
-
class AuthService:
|
|
482
|
-
"""Authentication service."""
|
|
483
|
-
|
|
484
|
-
def __init__(self, user_repository: UserRepository):
|
|
485
|
-
self.user_repo = user_repository
|
|
486
|
-
|
|
487
|
-
async def authenticate_user(
|
|
488
|
-
self,
|
|
489
|
-
username_or_email: str,
|
|
490
|
-
password: str
|
|
491
|
-
) -> Optional[User]:
|
|
492
|
-
"""Authenticate user by username/email and password."""
|
|
493
|
-
# Try to get user by username first, then by email
|
|
494
|
-
user = await self.user_repo.get_by_username(username_or_email)
|
|
495
|
-
if not user:
|
|
496
|
-
user = await self.user_repo.get_by_email(username_or_email)
|
|
497
|
-
|
|
498
|
-
if not user:
|
|
499
|
-
return None
|
|
500
|
-
|
|
501
|
-
if not verify_password(password, user.hashed_password):
|
|
502
|
-
return None
|
|
503
|
-
|
|
504
|
-
return user
|
|
505
|
-
|
|
506
|
-
async def create_user(self, user_data: dict) -> User:
|
|
507
|
-
"""Create new user."""
|
|
508
|
-
# Hash password
|
|
509
|
-
password = user_data.pop('password')
|
|
510
|
-
hashed_password = get_password_hash(password)
|
|
511
|
-
|
|
512
|
-
# Create user data
|
|
513
|
-
user_create_data = {
|
|
514
|
-
**user_data,
|
|
515
|
-
'hashed_password': hashed_password,
|
|
516
|
-
'is_active': True,
|
|
517
|
-
'is_superuser': False
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
return await self.user_repo.create(user_create_data)
|
|
521
|
-
|
|
522
|
-
async def change_password(self, user_id: int, new_password: str) -> bool:
|
|
523
|
-
"""Change user password."""
|
|
524
|
-
hashed_password = get_password_hash(new_password)
|
|
525
|
-
|
|
526
|
-
result = await self.user_repo.update(user_id, {
|
|
527
|
-
'hashed_password': hashed_password
|
|
528
|
-
})
|
|
529
|
-
|
|
530
|
-
return result is not None
|
|
531
|
-
|
|
532
|
-
async def activate_user(self, user_id: int) -> bool:
|
|
533
|
-
"""Activate user account."""
|
|
534
|
-
result = await self.user_repo.update(user_id, {'is_active': True})
|
|
535
|
-
return result is not None
|
|
536
|
-
|
|
537
|
-
async def deactivate_user(self, user_id: int) -> bool:
|
|
538
|
-
"""Deactivate user account."""
|
|
539
|
-
result = await self.user_repo.update(user_id, {'is_active': False})
|
|
540
|
-
return result is not None
|
|
541
|
-
```
|
|
542
|
-
|
|
543
|
-
## Role-Based Access Control
|
|
544
|
-
|
|
545
|
-
```python
|
|
546
|
-
# app/models/rbac.py
|
|
547
|
-
from sqlalchemy import Column, String, Text, ForeignKey, Table
|
|
548
|
-
from sqlalchemy.orm import relationship
|
|
549
|
-
from app.models.base import BaseModel
|
|
550
|
-
|
|
551
|
-
# Association tables for many-to-many relationships
|
|
552
|
-
user_roles = Table(
|
|
553
|
-
'user_roles',
|
|
554
|
-
Base.metadata,
|
|
555
|
-
Column('user_id', Integer, ForeignKey('users.id')),
|
|
556
|
-
Column('role_id', Integer, ForeignKey('roles.id'))
|
|
557
|
-
)
|
|
558
|
-
|
|
559
|
-
role_permissions = Table(
|
|
560
|
-
'role_permissions',
|
|
561
|
-
Base.metadata,
|
|
562
|
-
Column('role_id', Integer, ForeignKey('roles.id')),
|
|
563
|
-
Column('permission_id', Integer, ForeignKey('permissions.id'))
|
|
564
|
-
)
|
|
565
|
-
|
|
566
|
-
class Role(BaseModel):
|
|
567
|
-
"""Role model for RBAC."""
|
|
568
|
-
__tablename__ = "roles"
|
|
569
|
-
|
|
570
|
-
name = Column(String(50), unique=True, nullable=False, index=True)
|
|
571
|
-
description = Column(Text)
|
|
572
|
-
|
|
573
|
-
# Relationships
|
|
574
|
-
users = relationship("User", secondary=user_roles, back_populates="roles")
|
|
575
|
-
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
|
576
|
-
|
|
577
|
-
class Permission(BaseModel):
|
|
578
|
-
"""Permission model for RBAC."""
|
|
579
|
-
__tablename__ = "permissions"
|
|
580
|
-
|
|
581
|
-
name = Column(String(100), unique=True, nullable=False, index=True)
|
|
582
|
-
description = Column(Text)
|
|
583
|
-
resource = Column(String(50), nullable=False) # e.g., 'users', 'posts'
|
|
584
|
-
action = Column(String(50), nullable=False) # e.g., 'create', 'read', 'update', 'delete'
|
|
585
|
-
|
|
586
|
-
# Relationships
|
|
587
|
-
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
|
588
|
-
|
|
589
|
-
# Update User model to include roles
|
|
590
|
-
class User(BaseModel):
|
|
591
|
-
# ... existing fields ...
|
|
592
|
-
|
|
593
|
-
# Relationships
|
|
594
|
-
roles = relationship("Role", secondary=user_roles, back_populates="users")
|
|
595
|
-
|
|
596
|
-
@property
|
|
597
|
-
def permissions(self) -> list[str]:
|
|
598
|
-
"""Get all permissions for user."""
|
|
599
|
-
perms = set()
|
|
600
|
-
for role in self.roles:
|
|
601
|
-
for permission in role.permissions:
|
|
602
|
-
perms.add(f"{permission.resource}:{permission.action}")
|
|
603
|
-
return list(perms)
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
## OAuth2 Integration
|
|
607
|
-
|
|
608
|
-
```python
|
|
609
|
-
# app/api/v1/oauth.py
|
|
610
|
-
from fastapi import APIRouter, Depends, HTTPException, status
|
|
611
|
-
from fastapi.security.utils import get_authorization_scheme_param
|
|
612
|
-
from starlette.requests import Request
|
|
613
|
-
from authlib.integrations.starlette_client import OAuth
|
|
614
|
-
from app.core.config import settings
|
|
615
|
-
|
|
616
|
-
router = APIRouter()
|
|
617
|
-
|
|
618
|
-
# OAuth configuration
|
|
619
|
-
oauth = OAuth()
|
|
620
|
-
|
|
621
|
-
# Google OAuth
|
|
622
|
-
google = oauth.register(
|
|
623
|
-
name='google',
|
|
624
|
-
client_id=settings.GOOGLE_CLIENT_ID,
|
|
625
|
-
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
|
626
|
-
server_metadata_url='https://accounts.google.com/.well-known/openid_configuration',
|
|
627
|
-
client_kwargs={
|
|
628
|
-
'scope': 'openid email profile'
|
|
629
|
-
}
|
|
630
|
-
)
|
|
631
|
-
|
|
632
|
-
# GitHub OAuth
|
|
633
|
-
github = oauth.register(
|
|
634
|
-
name='github',
|
|
635
|
-
client_id=settings.GITHUB_CLIENT_ID,
|
|
636
|
-
client_secret=settings.GITHUB_CLIENT_SECRET,
|
|
637
|
-
access_token_url='https://github.com/login/oauth/access_token',
|
|
638
|
-
access_token_params=None,
|
|
639
|
-
authorize_url='https://github.com/login/oauth/authorize',
|
|
640
|
-
authorize_params=None,
|
|
641
|
-
api_base_url='https://api.github.com/',
|
|
642
|
-
client_kwargs={'scope': 'user:email'},
|
|
643
|
-
)
|
|
644
|
-
|
|
645
|
-
@router.get('/google')
|
|
646
|
-
async def google_login(request: Request):
|
|
647
|
-
"""Initiate Google OAuth login."""
|
|
648
|
-
redirect_uri = request.url_for('google_callback')
|
|
649
|
-
return await google.authorize_redirect(request, redirect_uri)
|
|
650
|
-
|
|
651
|
-
@router.get('/google/callback')
|
|
652
|
-
async def google_callback(request: Request):
|
|
653
|
-
"""Handle Google OAuth callback."""
|
|
654
|
-
token = await google.authorize_access_token(request)
|
|
655
|
-
user_info = token.get('userinfo')
|
|
656
|
-
|
|
657
|
-
if user_info:
|
|
658
|
-
# Create or get user
|
|
659
|
-
# Generate JWT token
|
|
660
|
-
# Return token
|
|
661
|
-
pass
|
|
662
|
-
|
|
663
|
-
raise HTTPException(
|
|
664
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
665
|
-
detail="OAuth authentication failed"
|
|
666
|
-
)
|
|
667
|
-
```
|
|
668
|
-
|
|
669
|
-
## API Key Authentication
|
|
670
|
-
|
|
671
|
-
```python
|
|
672
|
-
# app/models/api_key.py
|
|
673
|
-
from sqlalchemy import Column, String, Boolean, ForeignKey, DateTime
|
|
674
|
-
from sqlalchemy.orm import relationship
|
|
675
|
-
from app.models.base import BaseModel
|
|
676
|
-
import secrets
|
|
677
|
-
|
|
678
|
-
class APIKey(BaseModel):
|
|
679
|
-
"""API Key model."""
|
|
680
|
-
__tablename__ = "api_keys"
|
|
681
|
-
|
|
682
|
-
name = Column(String(100), nullable=False)
|
|
683
|
-
key_hash = Column(String(255), unique=True, nullable=False, index=True)
|
|
684
|
-
prefix = Column(String(10), nullable=False, index=True)
|
|
685
|
-
is_active = Column(Boolean, default=True, nullable=False)
|
|
686
|
-
expires_at = Column(DateTime)
|
|
687
|
-
last_used_at = Column(DateTime)
|
|
688
|
-
|
|
689
|
-
# Foreign key
|
|
690
|
-
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
691
|
-
|
|
692
|
-
# Relationships
|
|
693
|
-
user = relationship("User", back_populates="api_keys")
|
|
694
|
-
|
|
695
|
-
@classmethod
|
|
696
|
-
def generate_key(cls) -> tuple[str, str]:
|
|
697
|
-
"""Generate API key and return (key, hash)."""
|
|
698
|
-
key = secrets.token_urlsafe(32)
|
|
699
|
-
prefix = key[:8]
|
|
700
|
-
key_hash = get_password_hash(key)
|
|
701
|
-
return key, prefix, key_hash
|
|
702
|
-
|
|
703
|
-
def verify_key(self, key: str) -> bool:
|
|
704
|
-
"""Verify API key."""
|
|
705
|
-
return verify_password(key, self.key_hash)
|
|
706
|
-
|
|
707
|
-
# Add to User model
|
|
708
|
-
class User(BaseModel):
|
|
709
|
-
# ... existing fields ...
|
|
710
|
-
|
|
711
|
-
# Relationships
|
|
712
|
-
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
|
713
|
-
```
|
|
714
|
-
|
|
715
|
-
## Testing Authentication
|
|
716
|
-
|
|
717
|
-
```python
|
|
718
|
-
# tests/test_auth.py
|
|
719
|
-
import pytest
|
|
720
|
-
from httpx import AsyncClient
|
|
721
|
-
from app.core.security import create_access_token
|
|
722
|
-
|
|
723
|
-
@pytest.mark.asyncio
|
|
724
|
-
async def test_register_user(client: AsyncClient):
|
|
725
|
-
"""Test user registration."""
|
|
726
|
-
user_data = {
|
|
727
|
-
"username": "testuser",
|
|
728
|
-
"email": "test@example.com",
|
|
729
|
-
"password": "testpass123",
|
|
730
|
-
"first_name": "Test",
|
|
731
|
-
"last_name": "User"
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
response = await client.post("/api/v1/auth/register", json=user_data)
|
|
735
|
-
assert response.status_code == 201
|
|
736
|
-
|
|
737
|
-
data = response.json()
|
|
738
|
-
assert data["username"] == user_data["username"]
|
|
739
|
-
assert data["email"] == user_data["email"]
|
|
740
|
-
assert "hashed_password" not in data
|
|
741
|
-
|
|
742
|
-
@pytest.mark.asyncio
|
|
743
|
-
async def test_login_user(client: AsyncClient, test_user):
|
|
744
|
-
"""Test user login."""
|
|
745
|
-
login_data = {
|
|
746
|
-
"username": test_user.username,
|
|
747
|
-
"password": "testpass123"
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
response = await client.post(
|
|
751
|
-
"/api/v1/auth/login",
|
|
752
|
-
data=login_data,
|
|
753
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
assert response.status_code == 200
|
|
757
|
-
|
|
758
|
-
data = response.json()
|
|
759
|
-
assert "access_token" in data
|
|
760
|
-
assert "refresh_token" in data
|
|
761
|
-
assert data["token_type"] == "bearer"
|
|
762
|
-
|
|
763
|
-
@pytest.mark.asyncio
|
|
764
|
-
async def test_get_current_user(client: AsyncClient, test_user):
|
|
765
|
-
"""Test get current user endpoint."""
|
|
766
|
-
token = create_access_token(subject=test_user.id)
|
|
767
|
-
headers = {"Authorization": f"Bearer {token}"}
|
|
768
|
-
|
|
769
|
-
response = await client.get("/api/v1/auth/me", headers=headers)
|
|
770
|
-
assert response.status_code == 200
|
|
771
|
-
|
|
772
|
-
data = response.json()
|
|
773
|
-
assert data["username"] == test_user.username
|
|
774
|
-
assert data["email"] == test_user.email
|
|
775
|
-
```
|