Skip to content

Commit 300e899

Browse files
authored
Merge pull request #6 from KayvanShah1:feat-auth-api-key-verification
Authentication: User verification
2 parents a6b99bf + db8aeaa commit 300e899

27 files changed

+662
-34
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
.vscode/
1+
.vscode/
2+
.coverage
3+
*.log

backend/app/api/v1/apikeys.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from app.core.dependencies import get_db, require_verified_user
6+
from app.crud import apikey as crud
7+
from app.models.user import User
8+
from app.schemas.apikey import APIKeyCreate, APIKeyOut, APIKeySecretOut
9+
from fastapi import APIRouter, Depends, HTTPException, Response, status
10+
from sqlalchemy.ext.asyncio import AsyncSession
11+
12+
router = APIRouter(prefix="/apikeys", tags=["API Keys"])
13+
14+
15+
@router.post("", response_model=APIKeySecretOut, status_code=status.HTTP_201_CREATED)
16+
async def create_api_key(
17+
payload: APIKeyCreate,
18+
user: User = Depends(require_verified_user),
19+
db: AsyncSession = Depends(get_db),
20+
):
21+
scopes_json = json.dumps(payload.scopes) if payload.scopes else None
22+
key, display = await crud.create_api_key(
23+
db, user_id=user.id, name=payload.name, scopes_json=scopes_json, expires_at=payload.expires_at
24+
)
25+
return APIKeySecretOut(
26+
id=key.id,
27+
name=key.name,
28+
prefix=key.prefix,
29+
scopes=payload.scopes or None,
30+
expires_at=key.expires_at,
31+
revoked=key.revoked,
32+
created_at=key.created_at,
33+
api_key=display, # show ONCE
34+
)
35+
36+
37+
@router.get("", response_model=list[APIKeyOut])
38+
async def list_api_keys(
39+
user: User = Depends(require_verified_user),
40+
db: AsyncSession = Depends(get_db),
41+
):
42+
keys = await crud.list_api_keys(db, user.id)
43+
out = []
44+
45+
for k in keys:
46+
scopes = json.loads(k.scopes) if k.scopes else None
47+
out.append(
48+
APIKeyOut(
49+
id=k.id,
50+
name=k.name,
51+
prefix=k.prefix,
52+
scopes=scopes,
53+
expires_at=k.expires_at,
54+
revoked=k.revoked,
55+
created_at=k.created_at,
56+
)
57+
)
58+
return out
59+
60+
61+
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Revoke or delete an API key")
62+
async def delete_or_revoke_api_key(
63+
key_id: int,
64+
hard: bool = False,
65+
user: User = Depends(require_verified_user),
66+
db: AsyncSession = Depends(get_db),
67+
):
68+
if hard:
69+
ok = await crud.delete_api_key(db, user.id, key_id)
70+
else:
71+
ok = await crud.revoke_api_key(db, user.id, key_id)
72+
if not ok:
73+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Key not found or already revoked")
74+
return Response(status_code=status.HTTP_204_NO_CONTENT, content="API key deleted/revoked")

backend/app/api/v1/auth_email.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from app.core.config import settings
4+
from app.core.dependencies import get_db
5+
from app.core.security import hash_password
6+
from app.crud.email_token import consume_email_token, issue_email_token
7+
from app.models.user import User
8+
from app.schemas.email_token import CompletePasswordReset, RequestPasswordReset, RequestVerifyEmail
9+
from app.services.mailer import send_email
10+
from fastapi import APIRouter, Depends, HTTPException, status
11+
from sqlalchemy import select
12+
from sqlalchemy.ext.asyncio import AsyncSession
13+
14+
router = APIRouter(prefix="/auth", tags=["Auth Email"])
15+
16+
17+
# 1) Send verification email (after signup or on demand)
18+
@router.post("/verify-email/request", status_code=status.HTTP_202_ACCEPTED)
19+
async def request_verify_email(
20+
body: RequestVerifyEmail,
21+
db: AsyncSession = Depends(get_db),
22+
):
23+
24+
res = await db.execute(select(User).where(User.email == body.email))
25+
user = res.scalar_one_or_none()
26+
# Always respond 202 to prevent enumeration, but only send if user exists
27+
if user and not user.email_verified:
28+
tok, raw = await issue_email_token(db, user_id=user.id, purpose="verify")
29+
link = f"{settings.FRONTEND_ORIGIN}/auth/verify-email?token={raw}"
30+
html = f"<p>Verify your Taskaza email by clicking <a href='{link}'>this link</a>. It expires in 24 hours.</p>"
31+
await send_email(user.email, "Verify your Taskaza email", html)
32+
return {"message": "If your email exists and is unverified, a link has been sent."}
33+
34+
35+
# 2) Complete verification
36+
@router.post("/verify-email/complete", status_code=status.HTTP_200_OK)
37+
async def complete_verify_email(
38+
token: str,
39+
db: AsyncSession = Depends(get_db),
40+
):
41+
tok = await consume_email_token(db, token, purpose="verify")
42+
if not tok:
43+
raise HTTPException(status_code=400, detail="Invalid or expired token")
44+
45+
user = tok.user
46+
if not user.email_verified:
47+
user.email_verified = True
48+
await db.commit()
49+
return {"message": "Email verified successfully."}
50+
51+
52+
# 3) Request password reset
53+
@router.post("/password-reset/request", status_code=status.HTTP_202_ACCEPTED)
54+
async def request_password_reset(
55+
body: RequestPasswordReset,
56+
db: AsyncSession = Depends(get_db),
57+
):
58+
res = await db.execute(select(User).where(User.email == body.email))
59+
user = res.scalar_one_or_none()
60+
if user:
61+
tok, raw = await issue_email_token(db, user_id=user.id, purpose="reset")
62+
link = f"{settings.FRONTEND_ORIGIN}/auth/reset-password?token={raw}"
63+
html = f"<p>Reset your Taskaza password <a href='{link}'>here</a>. Link valid for 1 hour.</p>"
64+
await send_email(user.email, "Reset your Taskaza password", html)
65+
return {"message": "If the account exists, a reset link has been sent."}
66+
67+
68+
# 4) Complete password reset
69+
@router.post("/password-reset/complete", status_code=status.HTTP_200_OK)
70+
async def complete_password_reset(
71+
body: CompletePasswordReset,
72+
db: AsyncSession = Depends(get_db),
73+
):
74+
tok = await consume_email_token(db, body.token, purpose="reset")
75+
if not tok:
76+
raise HTTPException(status_code=400, detail="Invalid or expired token")
77+
78+
user = tok.user
79+
80+
user.hashed_password = hash_password(body.new_password)
81+
await db.commit()
82+
return {"message": "Password reset successfully."}

backend/app/core/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
2525
JWT_ALGORITHM: str = Field("HS256", description="JWT algorithm for signing tokens")
2626
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(60 * 24 * 3, description="JWT token expiration time in minutes")
2727
HTTP_API_KEY: str = Field("123456", description="HTTP API key for authentication")
28+
API_KEY_PREFIX: str = Field("tsk", description="Prefix for API keys")
2829

2930
# CORS settings
3031
BACKEND_CORS_ORIGINS: List[AnyHttpUrl | str] = Field(
@@ -34,6 +35,14 @@ class Settings(BaseSettings):
3435
# Database settings
3536
DATABASE_URL: str = Field("sqlite+aiosqlite:///./data/taskaza.db", description="Database connection URL")
3637

38+
# Email settings
39+
FRONTEND_ORIGIN: str = Field("http://localhost:3000", description="Frontend origin for CORS and email links")
40+
SMTP_HOST: str = Field("smtp.gmail.com", description="SMTP server host")
41+
SMTP_PORT: int = Field(587, description="SMTP server port")
42+
SMTP_USERNAME: str = Field("username", description="SMTP username")
43+
SMTP_PASSWORD: str = Field("password", description="SMTP password or app password")
44+
EMAIL_FROM: str = Field("Taskaza <[email protected]>", description="Default 'from' email address")
45+
3746
# Configuration for Pydantic settings
3847
model_config = SettingsConfigDict(env_prefix="TSKZ_", env_file=".env", env_file_encoding="utf-8", extra="ignore")
3948

backend/app/core/dependencies.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import hashlib
2+
from datetime import datetime, timezone
13
from typing import AsyncGenerator
24

3-
from fastapi import Depends, HTTPException, status
4-
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
5-
from sqlalchemy.ext.asyncio import AsyncSession
6-
75
from app.core.auth import verify_access_token
8-
from app.core.config import settings
6+
from app.core.timeutils import as_aware_utc
7+
8+
# from app.core.config import settings
9+
from app.crud.apikey import get_key_by_hash
910
from app.crud.user import get_user_by_id
1011
from app.db.session import async_session
1112
from app.models.user import User
13+
from fastapi import Depends, HTTPException, status
14+
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
15+
from sqlalchemy.ext.asyncio import AsyncSession
1216

1317
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
1418
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
@@ -25,12 +29,42 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
2529
# ---------------------------- #
2630
# Dependency: API Key Check
2731
# ---------------------------- #
28-
def verify_api_key(x_api_key: str = Depends(api_key_header)):
32+
# def verify_api_key(x_api_key: str = Depends(api_key_header)):
33+
# if not x_api_key:
34+
# raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API Key header missing")
35+
# if x_api_key != settings.HTTP_API_KEY:
36+
# raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API Key")
37+
# return x_api_key
38+
39+
40+
async def verify_api_key(
41+
x_api_key: str = Depends(api_key_header),
42+
db: AsyncSession = Depends(get_db),
43+
):
2944
if not x_api_key:
30-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API Key header missing")
31-
if x_api_key != settings.HTTP_API_KEY:
32-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API Key")
33-
return x_api_key
45+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key")
46+
47+
# Expect format: "tsk_<prefix>_<secret>"
48+
try:
49+
scheme, prefix, secret = x_api_key.split("_", 2)
50+
except ValueError:
51+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format")
52+
53+
if scheme != "tsk" or not prefix or not secret:
54+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
55+
56+
raw = f"{prefix}.{secret}"
57+
secret_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
58+
key = await get_key_by_hash(db, secret_hash)
59+
if not key or key.revoked:
60+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key revoked or not found")
61+
62+
# expiry check
63+
if key.expires_at and as_aware_utc(key.expires_at) < datetime.now(timezone.utc):
64+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key expired")
65+
66+
# You can attach scopes or the user to request state if needed
67+
return key
3468

3569

3670
# ---------------------------- #
@@ -45,3 +79,12 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession
4579
detail="User not found",
4680
)
4781
return user
82+
83+
84+
# ---------------------------- #
85+
# Dependency: Require Verified User
86+
# ---------------------------- #
87+
async def require_verified_user(user: User = Depends(get_current_user)) -> User:
88+
if not user.email_verified:
89+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email not verified")
90+
return user

backend/app/core/metadata.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,7 @@
2626
tags_metadata = [
2727
{"name": "Users", "description": "User registration and login routes"},
2828
{"name": "Login", "description": "Authentication and token management routes"},
29+
{"name": "Auth Email", "description": "Email-based authentication routes"},
30+
{"name": "API Keys", "description": "API key management routes"},
2931
{"name": "Tasks", "description": "CRUD operations for user tasks"},
3032
]

backend/app/core/security.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
import hashlib
2+
import hmac
3+
import secrets
4+
from typing import Tuple
5+
16
from passlib.context import CryptContext
7+
from app.core.config import settings
28

39
# ---------------------------- #
410
# Password Hashing
@@ -12,3 +18,30 @@ def hash_password(password: str):
1218

1319
def verify_password(plain_password: str, hashed_password: str):
1420
return pwd_context.verify(plain_password, hashed_password)
21+
22+
23+
def generate_api_key() -> Tuple[str, str, str]:
24+
"""
25+
Returns (display_key, prefix, secret_hash).
26+
display_key is what you show once: "tsk_<prefix>_<secret>"
27+
Store only secret_hash in DB using SHA256.
28+
"""
29+
prefix = secrets.token_hex(3) # short, user-visible fragment
30+
secret = secrets.token_urlsafe(32)
31+
raw = f"{prefix}.{secret}"
32+
secret_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
33+
display_key = f"{settings.API_KEY_PREFIX}_{prefix}_{secret}"
34+
return display_key, prefix, secret_hash
35+
36+
37+
def hash_email_token(raw_token: str) -> str:
38+
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
39+
40+
41+
def generate_email_token() -> Tuple[str, str]:
42+
raw = secrets.token_urlsafe(32)
43+
return raw, hash_email_token(raw)
44+
45+
46+
def constant_time_equals(a: str, b: str) -> bool:
47+
return hmac.compare_digest(a, b)

backend/app/core/timeutils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from datetime import datetime, timezone
2+
3+
4+
def as_aware_utc(dt: datetime | None) -> datetime | None:
5+
if dt is None:
6+
return None
7+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)

backend/app/crud/apikey.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from typing import Optional
5+
6+
from app.core.security import generate_api_key
7+
from app.models.apikey import APIKey
8+
from sqlalchemy import select
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
11+
12+
async def create_api_key(
13+
db: AsyncSession,
14+
user_id: int,
15+
name: str,
16+
scopes_json: Optional[str],
17+
expires_at: Optional[datetime],
18+
):
19+
display_key, prefix, secret_hash = generate_api_key()
20+
key = APIKey(
21+
user_id=user_id,
22+
name=name,
23+
prefix=prefix,
24+
secret_hash=secret_hash,
25+
scopes=scopes_json,
26+
expires_at=expires_at,
27+
revoked=False,
28+
)
29+
db.add(key)
30+
await db.commit()
31+
await db.refresh(key)
32+
return key, display_key
33+
34+
35+
async def list_api_keys(db: AsyncSession, user_id: int):
36+
q = select(APIKey).where(APIKey.user_id == user_id).order_by(APIKey.created_at.desc())
37+
res = await db.execute(q)
38+
return list(res.scalars())
39+
40+
41+
async def revoke_api_key(db: AsyncSession, user_id: int, key_id: int) -> bool:
42+
q = select(APIKey).where(APIKey.id == key_id, APIKey.user_id == user_id)
43+
res = await db.execute(q)
44+
key = res.scalar_one_or_none()
45+
if not key or key.revoked:
46+
return False
47+
key.revoked = True
48+
key.revoked_at = datetime.now(timezone.utc)
49+
await db.commit()
50+
return True
51+
52+
53+
async def get_key_by_hash(db: AsyncSession, secret_hash: str) -> Optional[APIKey]:
54+
q = select(APIKey).where(APIKey.secret_hash == secret_hash)
55+
res = await db.execute(q)
56+
return res.scalar_one_or_none()
57+
58+
59+
async def delete_api_key(db, user_id: int, key_id: int) -> bool:
60+
res = await db.execute(select(APIKey).where(APIKey.id == key_id, APIKey.user_id == user_id))
61+
key = res.scalar_one_or_none()
62+
if not key:
63+
return False
64+
await db.delete(key)
65+
await db.commit()
66+
return True

0 commit comments

Comments
 (0)