Skip to content

Commit 6534a32

Browse files
zachlagdenclaude
andcommitted
feat: add text, dev, and web tool APIs with OAuth, email verification, and security hardening
Add new v1 API routers for text-tools (slugify, case conversion, lorem ipsum, markdown), dev-tools (UUID, password, hash, base64, JSON, JWT), and web-tools (metadata extraction, URL validation, URL shortener) with SSRF protection. Introduce GitHub OAuth login flow, email verification system with SMTP support, account email/password management endpoints, and color palette generation. Harden security with private IP blocking on web-tools, XSS sanitization on markdown output, and fix session expiry from 5 seconds to 30 days. Co-Authored-By: Claude <81847+claude@users.noreply.github.com>
1 parent 6c5deb2 commit 6534a32

22 files changed

+3980
-114
lines changed

requirements.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,22 @@ watchfiles==1.0.0
5858
websockets==14.1
5959
altcha==1.0.0
6060
sentry-sdk[fastapi]==2.19.2
61+
62+
# SMTP
63+
aiosmtplib==3.0.1
64+
65+
# Image Tools
66+
qrcode[pil]==7.4.2
67+
blurhash-python==1.2.1
68+
69+
# Text Tools
70+
python-slugify==8.0.1
71+
mistune==3.0.2
72+
73+
# Dev Tools
74+
pyjwt==2.8.0
75+
uuid6==2024.1.12
76+
77+
# Web Tools
78+
beautifulsoup4==4.12.3
79+
lxml==5.1.0

src/.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,19 @@ ALTCHA_MAX_NUMBER=100000
1616
SENTRY_DSN=https://your-key@your-org.ingest.sentry.io/project-id
1717
SENTRY_TRACES_SAMPLE_RATE=1.0
1818
SENTRY_PROFILE_SAMPLE_RATE=1.0
19+
20+
# SMTP Configuration
21+
SMTP_HOST=smtp.example.com
22+
SMTP_PORT=587
23+
SMTP_USERNAME=
24+
SMTP_PASSWORD=
25+
SMTP_FROM_EMAIL=noreply@lagden.dev
26+
SMTP_USE_TLS=true
27+
28+
# GitHub OAuth
29+
GITHUB_CLIENT_ID=
30+
GITHUB_CLIENT_SECRET=
31+
GITHUB_REDIRECT_URI=https://api.lagden.dev/api/auth/github/callback
32+
33+
# URL Shortener
34+
SHORT_URL_BASE=https://ldev.click

src/app.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,21 @@
3232
# Now import FastAPI and create app
3333
from fastapi import FastAPI
3434
from fastapi.staticfiles import StaticFiles
35-
from fastapi.responses import JSONResponse
35+
from fastapi.responses import JSONResponse, RedirectResponse
3636

3737
# Import routers
3838
from routers import main_router
3939
from routers.v1 import main_router as v1_main_router
40-
from routers.v1 import watcher_router as v1_watcher_router
40+
# Temporarily disabled: from routers.v1 import watcher_router as v1_watcher_router
4141
from routers.v1 import image_tools_router as v1_image_tools_router
4242
from routers.v1 import color_tools_router as v1_color_tools_router
43-
from routers.api import accounts_router, me_router, altcha_router
43+
from routers.v1 import text_tools_router as v1_text_tools_router
44+
from routers.v1 import dev_tools_router as v1_dev_tools_router
45+
from routers.v1 import web_tools_router as v1_web_tools_router
46+
from routers.api import accounts_router, me_router, altcha_router, auth_router
4447
from helpers.altcha import setup_altcha_indexes
48+
from helpers.oauth import OAuthHelper
49+
from db import short_urls
4550

4651
# Configure logging based on environment
4752
log_level = logging.DEBUG if os.getenv("ENVIRONMENT", "production").lower() == "development" else logging.INFO
@@ -58,14 +63,15 @@
5863
app = FastAPI(
5964
title="api.lagden.dev",
6065
description="The lagden.dev API used for our services and tools.",
61-
version="2.0.0beta",
66+
version="2.0.0",
6267
)
6368

6469

6570
@app.on_event("startup")
6671
async def startup_event():
6772
"""Initialize application on startup."""
6873
setup_altcha_indexes()
74+
OAuthHelper.setup_oauth_indexes()
6975
logger.info("Application started successfully")
7076

7177

@@ -86,14 +92,33 @@ async def health_check():
8692

8793
# Include API routers
8894
app.include_router(v1_main_router.router, prefix="/v1")
89-
app.include_router(v1_watcher_router.router, prefix="/v1/watcher")
95+
# Temporarily disabled: app.include_router(v1_watcher_router.router, prefix="/v1/watcher")
9096
app.include_router(v1_image_tools_router.router, prefix="/v1/image-tools")
9197
app.include_router(v1_color_tools_router.router, prefix="/v1/color-tools")
98+
app.include_router(v1_text_tools_router.router, prefix="/v1/text-tools")
99+
app.include_router(v1_dev_tools_router.router, prefix="/v1/dev-tools")
100+
app.include_router(v1_web_tools_router.router, prefix="/v1/web-tools")
92101

93102
# Include the internal API routers
94103
app.include_router(accounts_router.router, prefix="/api/accounts")
95104
app.include_router(me_router.router, prefix="/api/me")
96105
app.include_router(altcha_router.router, prefix="/api/altcha")
106+
app.include_router(auth_router.router, prefix="/api/auth")
107+
108+
109+
# Short URL redirect endpoint
110+
@app.get("/s/{code}", include_in_schema=False)
111+
async def short_url_redirect(code: str):
112+
"""Redirect short URL to original URL."""
113+
short_url_doc = short_urls.find_one({"_id": code})
114+
if not short_url_doc:
115+
return JSONResponse(status_code=404, content={"error": "Short URL not found"})
116+
117+
# Increment click counter
118+
short_urls.update_one({"_id": code}, {"$inc": {"clicks": 1}})
119+
120+
return RedirectResponse(url=short_url_doc["url"], status_code=301)
121+
97122

98123
# Run the application
99124
if __name__ == "__main__":

src/db.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ def create_mongo_client() -> Optional[MongoClient]:
5858
accounts = api_db["api_accounts"]
5959
api_logs = api_db["api_logs"]
6060
api_keys = api_db["api_keys"]
61+
email_verifications = api_db["email_verifications"]
62+
oauth_states = api_db["oauth_states"]
63+
short_urls = api_db["short_urls"]
6164
else:
6265
raise RuntimeError("Failed to establish MongoDB connection")
6366

src/helpers/accounts.py

Lines changed: 216 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,21 @@
77
"""
88

99
# Python Standard Library Imports
10-
from datetime import datetime
11-
from typing import Optional, Dict, Any
10+
from datetime import datetime, timedelta
11+
from typing import Optional, Dict, Any, Tuple
1212
import uuid
13+
import secrets
1314

1415
# Third-Party Imports
1516
from fastapi import Response, Request
1617
from fastapi.exceptions import HTTPException
18+
import bcrypt
1719

1820
# Helper Imports
1921
from helpers.fastapi.ip import get_client_ip
2022

2123
# Database Imports
22-
from db import accounts
24+
from db import accounts, email_verifications
2325

2426

2527
class AccountHelper:
@@ -144,7 +146,7 @@ async def create_session(account_id: str, request: Request) -> Dict[str, Any]:
144146
"ip": get_client_ip(request),
145147
"created_at": datetime.now().timestamp(),
146148
"last_used": datetime.now().timestamp(),
147-
"expires_at": datetime.now().timestamp() + 5, # 86400 * 30, # 30 days
149+
"expires_at": datetime.now().timestamp() + 86400 * 30, # 30 days
148150
}
149151

150152
# Create an index to delete expired sessions if it doesn't exist
@@ -177,3 +179,213 @@ async def get_public_account_data(account: Dict[Any, Any]) -> Dict[Any, Any]:
177179
session["current"] = False
178180

179181
return public_account
182+
183+
@staticmethod
184+
async def create_verification_token(account_id: str, email: str) -> str:
185+
"""Create an email verification token"""
186+
token = secrets.token_urlsafe(32)
187+
expires_at = datetime.now().timestamp() + 86400 # 24 hours
188+
189+
# Remove any existing verification for this email
190+
email_verifications.delete_many({"email": email})
191+
192+
# Create new verification
193+
email_verifications.insert_one({
194+
"_id": token,
195+
"account_id": account_id,
196+
"email": email,
197+
"created_at": datetime.now().timestamp(),
198+
"expires_at": expires_at,
199+
})
200+
201+
return token
202+
203+
@staticmethod
204+
async def verify_email_token(token: str) -> Tuple[bool, Optional[str], Optional[str]]:
205+
"""
206+
Verify an email token and mark the email as verified.
207+
208+
Returns:
209+
Tuple of (success, account_id, error_message)
210+
"""
211+
verification = email_verifications.find_one({"_id": token})
212+
213+
if not verification:
214+
return False, None, "Invalid or expired verification token"
215+
216+
if verification["expires_at"] < datetime.now().timestamp():
217+
email_verifications.delete_one({"_id": token})
218+
return False, None, "Verification token has expired"
219+
220+
account_id = verification["account_id"]
221+
email = verification["email"]
222+
223+
# Mark email as verified in the account
224+
result = accounts.update_one(
225+
{"_id": account_id, "emails.address": email},
226+
{"$set": {"emails.$.verified": True}}
227+
)
228+
229+
if result.modified_count == 0:
230+
return False, None, "Email not found in account"
231+
232+
# Delete the verification token
233+
email_verifications.delete_one({"_id": token})
234+
235+
return True, account_id, None
236+
237+
@staticmethod
238+
async def set_primary_email(account_id: str, email: str) -> Tuple[bool, Optional[str]]:
239+
"""
240+
Set an email as the primary email for an account.
241+
The email must be verified.
242+
243+
Returns:
244+
Tuple of (success, error_message)
245+
"""
246+
account = accounts.find_one({"_id": account_id})
247+
if not account:
248+
return False, "Account not found"
249+
250+
# Find the email and check if it's verified
251+
email_found = False
252+
is_verified = False
253+
for e in account["emails"]:
254+
if e["address"] == email:
255+
email_found = True
256+
is_verified = e.get("verified", False)
257+
break
258+
259+
if not email_found:
260+
return False, "Email not found in account"
261+
262+
if not is_verified:
263+
return False, "Email must be verified before setting as primary"
264+
265+
# Remove primary flag from all emails
266+
accounts.update_one(
267+
{"_id": account_id},
268+
{"$set": {"emails.$[].primary": False}}
269+
)
270+
271+
# Set primary flag on the specified email
272+
accounts.update_one(
273+
{"_id": account_id, "emails.address": email},
274+
{"$set": {"emails.$.primary": True}}
275+
)
276+
277+
return True, None
278+
279+
@staticmethod
280+
async def change_password(
281+
account_id: str, current_password: str, new_password: str
282+
) -> Tuple[bool, Optional[str]]:
283+
"""
284+
Change an account's password.
285+
286+
Returns:
287+
Tuple of (success, error_message)
288+
"""
289+
account = accounts.find_one({"_id": account_id})
290+
if not account:
291+
return False, "Account not found"
292+
293+
# Verify current password
294+
if not bcrypt.checkpw(
295+
current_password.encode("utf-8"),
296+
account["password"].encode("utf-8")
297+
):
298+
return False, "Current password is incorrect"
299+
300+
# Hash and set new password
301+
hashed_password = bcrypt.hashpw(
302+
new_password.encode("utf-8"),
303+
bcrypt.gensalt()
304+
).decode("utf-8")
305+
306+
accounts.update_one(
307+
{"_id": account_id},
308+
{"$set": {"password": hashed_password}}
309+
)
310+
311+
return True, None
312+
313+
@staticmethod
314+
async def has_verified_email(account_id: str) -> bool:
315+
"""Check if an account has at least one verified email"""
316+
account = accounts.find_one({"_id": account_id})
317+
if not account:
318+
return False
319+
320+
for email in account.get("emails", []):
321+
if email.get("verified", False):
322+
return True
323+
324+
return False
325+
326+
@staticmethod
327+
async def find_account_by_oauth(provider: str, provider_id: str) -> Optional[Dict[Any, Any]]:
328+
"""Find an account by OAuth provider and ID"""
329+
return accounts.find_one({f"oauth_connections.{provider}.id": provider_id})
330+
331+
@staticmethod
332+
async def link_oauth_account(
333+
account_id: str, provider: str, provider_data: Dict[str, Any]
334+
) -> None:
335+
"""Link an OAuth account to an existing account"""
336+
accounts.update_one(
337+
{"_id": account_id},
338+
{"$set": {f"oauth_connections.{provider}": {
339+
**provider_data,
340+
"linked_at": datetime.now().timestamp()
341+
}}}
342+
)
343+
344+
@staticmethod
345+
async def unlink_oauth_account(account_id: str, provider: str) -> Tuple[bool, Optional[str]]:
346+
"""Unlink an OAuth account from an existing account"""
347+
account = accounts.find_one({"_id": account_id})
348+
if not account:
349+
return False, "Account not found"
350+
351+
# Check if account has a password (required to unlink OAuth)
352+
if not account.get("password"):
353+
return False, "Cannot unlink OAuth - account has no password set"
354+
355+
# Check if the OAuth connection exists
356+
oauth_connections = account.get("oauth_connections", {})
357+
if provider not in oauth_connections:
358+
return False, f"No {provider} connection found"
359+
360+
accounts.update_one(
361+
{"_id": account_id},
362+
{"$unset": {f"oauth_connections.{provider}": ""}}
363+
)
364+
365+
return True, None
366+
367+
@staticmethod
368+
async def create_oauth_account(
369+
name: str,
370+
email: str,
371+
provider: str,
372+
provider_data: Dict[str, Any],
373+
org: Optional[str] = None
374+
) -> Dict[Any, Any]:
375+
"""Create a new account from OAuth login"""
376+
account = {
377+
"_id": str(uuid.uuid4()),
378+
"name": name,
379+
"emails": [{"address": email, "verified": True, "primary": True}],
380+
"password": None, # OAuth accounts don't have passwords initially
381+
"org": org,
382+
"sessions": [],
383+
"oauth_connections": {
384+
provider: {
385+
**provider_data,
386+
"linked_at": datetime.now().timestamp()
387+
}
388+
}
389+
}
390+
accounts.insert_one(account)
391+
return account

0 commit comments

Comments
 (0)