-
-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathroutes.py
More file actions
269 lines (231 loc) · 8.84 KB
/
Copy pathroutes.py
File metadata and controls
269 lines (231 loc) · 8.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
"""
Authentication routes for QWED Enterprise Portal.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Depends, Header, Request
from typing import Optional, List
from datetime import datetime
from sqlmodel import Session, select
from qwed_new.core.database import get_session
from qwed_new.core.models import User, Organization, ApiKey
from qwed_new.core.rate_limiter import check_auth_rate_limit
from .models import (
SignUpRequest, SignInRequest, TokenResponse,
APIKeyCreateRequest, APIKeyResponse, APIKeyListItem
)
from .security import (
hash_password, verify_password, create_access_token,
generate_api_key, mask_api_key, decode_access_token
)
router = APIRouter(prefix="/auth", tags=["authentication"])
# bcrypt cost-12 verify burns ~270 ms on an unknown email and returns in the
# same time for a known one, equalizing the email-enumeration timing oracle
# (issue #334). Initialized lazily so module import stays cheap.
_dummy_password_hash: Optional[str] = None
async def _burn_one_bcrypt(password: str) -> None:
"""Run one bcrypt verify against a throwaway hash (timing equalizer)."""
global _dummy_password_hash
if _dummy_password_hash is None:
_dummy_password_hash = await asyncio.to_thread(
hash_password, "qwed-timing-equalizer"
)
await asyncio.to_thread(verify_password, password, _dummy_password_hash)
@router.post("/signup", response_model=TokenResponse)
async def signup(
request: SignUpRequest,
req: Request,
session: Session = Depends(get_session)
):
"""
Sign up a new user and create their organization.
Returns JWT token for immediate login.
"""
# Anonymous route: per-IP throttle before any DB or bcrypt work
# (bcrypt is ~269 ms of CPU; issues #226/#334).
check_auth_rate_limit(req)
# Check if email already exists
statement = select(User).where(User.email == request.email)
existing_user = session.exec(statement).first()
if existing_user:
raise HTTPException(status_code=400, detail="Email already registered")
# Create organization
# Check if org name exists
org_statement = select(Organization).where(Organization.name == request.organization_name)
if session.exec(org_statement).first():
# Auto-append random suffix if name taken, or just fail?
# For now, let's fail
raise HTTPException(status_code=400, detail="Organization name already taken")
# Hash in the threadpool — bcrypt must never run on the event loop
# (issue #334). Also BEFORE any row is written so a hash failure
# cannot strand an orphaned Organization row.
password_hash = await asyncio.to_thread(hash_password, request.password)
org = Organization(
name=request.organization_name,
display_name=request.organization_name,
tier="free"
)
session.add(org)
session.commit()
session.refresh(org)
# Create user (first user is owner)
try:
print(f"DEBUG: Creating user with email={request.email}, org_id={org.id}")
user = User(
email=request.email,
password_hash=password_hash,
organization_id=org.id,
role="owner"
)
print(f"DEBUG: User object created: {user}")
session.add(user)
session.commit()
session.refresh(user)
print("DEBUG: User committed successfully")
except Exception as e:
print(f"DEBUG: Error creating user: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# Generate JWT token
access_token = create_access_token(data={"sub": str(user.id), "org_id": str(org.id)})
return {
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": str(user.id),
"email": user.email,
"org_id": str(user.organization_id),
"role": user.role
}
}
@router.post("/signin", response_model=TokenResponse)
async def signin(
request: SignInRequest,
req: Request,
session: Session = Depends(get_session)
):
"""Sign in an existing user."""
# Anonymous route: per-IP throttle — unthrottled signin is a password-
# guessing oracle and a ~4 req/s whole-service DoS (issues #226/#334).
check_auth_rate_limit(req)
statement = select(User).where(User.email == request.email)
user = session.exec(statement).first()
# bcrypt in the threadpool (issue #334); when the email is unknown,
# burn one bcrypt anyway so response timing does not enumerate emails.
if user is None:
await _burn_one_bcrypt(request.password)
raise HTTPException(status_code=401, detail="Invalid email or password")
if not await asyncio.to_thread(verify_password, request.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid email or password")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
# Generate JWT token
access_token = create_access_token(data={"sub": str(user.id), "org_id": str(user.organization_id)})
return {
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": str(user.id),
"email": user.email,
"org_id": str(user.organization_id),
"role": user.role
}
}
def get_current_user_token(authorization: Optional[str] = Header(None)) -> dict:
"""Dependency to get the decoded token payload."""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Not authenticated")
token = authorization.replace("Bearer ", "")
payload = decode_access_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return payload
async def get_current_user(
payload: dict = Depends(get_current_user_token),
session: Session = Depends(get_session)
) -> User:
"""Dependency to get the current user object."""
user_id = payload.get("sub")
user = session.get(User, int(user_id))
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
"""Get current user info."""
return {
"id": str(current_user.id),
"email": current_user.email,
"org_id": str(current_user.organization_id),
"role": current_user.role
}
@router.post("/api-keys", response_model=APIKeyResponse)
async def create_api_key_endpoint(
request: APIKeyCreateRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Generate a new API key."""
# Generate key
plaintext_key, key_hash = generate_api_key()
# Store in database
api_key = ApiKey(
key_hash=key_hash,
key_preview=mask_api_key(plaintext_key),
user_id=current_user.id,
organization_id=current_user.organization_id,
name=request.name
)
session.add(api_key)
session.commit()
session.refresh(api_key)
return {
"id": str(api_key.id),
"name": api_key.name,
"key": plaintext_key, # Only shown once
"created_at": api_key.created_at
}
@router.get("/api-keys", response_model=List[APIKeyListItem])
async def list_api_keys(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""List all API keys for the current user's organization."""
# Show all keys for the organization
statement = select(ApiKey).where(
ApiKey.organization_id == current_user.organization_id,
ApiKey.is_active == True
).order_by(ApiKey.created_at.desc())
keys = session.exec(statement).all()
return [
{
"id": str(key.id),
"name": key.name,
"key_preview": key.key_preview,
"created_at": key.created_at,
"last_used_at": key.last_used_at,
"is_revoked": key.revoked_at is not None
}
for key in keys
]
@router.delete("/api-keys/{key_id}")
async def revoke_api_key_endpoint(
key_id: str,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Revoke an API key."""
# Ensure key belongs to user's org
statement = select(ApiKey).where(
ApiKey.id == int(key_id),
ApiKey.organization_id == current_user.organization_id
)
key = session.exec(statement).first()
if not key:
raise HTTPException(status_code=404, detail="API key not found")
# Soft delete / revoke
key.is_active = False
key.revoked_at = datetime.utcnow()
session.add(key)
session.commit()
return {"message": "API key revoked successfully"}