7 Commits

Author SHA1 Message Date
dependabot[bot]
81b16c374a Bump python-multipart from 0.0.20 to 0.0.22
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.20 to 0.0.22.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.20...0.0.22)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.22
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 23:49:39 +00:00
Jack Case
a36b6e9865 actually send the email 2025-11-15 16:04:45 +00:00
Jack Case
20fffc85c1 check if email is verified when authorizing 2025-11-15 15:53:02 +00:00
Jack Case
6724a903eb email verification responses 2025-11-15 15:45:05 +00:00
Jack Case
cfd54eca5e working on verification email sending 2025-11-15 14:04:03 +00:00
Jack Case
f5f8b5c873 change async path functions from async so that fastapi handles concurrency for non-async apis 2025-11-15 14:02:53 +00:00
Jack Case
f125fc1807 add resend to requirements and create email.py 2025-11-15 13:24:26 +00:00
5 changed files with 94 additions and 9 deletions

View File

@@ -33,8 +33,9 @@ pydantic_core==2.41.4
Pygments==2.19.2 Pygments==2.19.2
PyJWT==2.10.1 PyJWT==2.10.1
python-dotenv==1.1.1 python-dotenv==1.1.1
python-multipart==0.0.20 python-multipart==0.0.22
PyYAML==6.0.3 PyYAML==6.0.3
resend==2.19.0
rich==14.2.0 rich==14.2.0
rich-toolkit==0.15.1 rich-toolkit==0.15.1
rignore==0.7.1 rignore==0.7.1

View File

@@ -71,7 +71,7 @@ def insert_slop(urls: list[ParseResult], engine: Engine, user: User | None = Non
session.commit() session.commit()
def get_user(email, engine): def get_user(email, engine) -> User:
query = select(User).where(User.email == email) query = select(User).where(User.email == email)
with Session(engine) as session: with Session(engine) as session:
@@ -84,3 +84,9 @@ def create_user(email, password_hash, engine):
with Session(engine) as session: with Session(engine) as session:
session.add(user) session.add(user)
session.commit() session.commit()
def verify_user_email(user: User, engine):
with Session(engine) as session:
session.add(user)
user.email_verified = True
session.commit()

22
slopserver/email.py Normal file
View File

@@ -0,0 +1,22 @@
"""Send emails for account verification through Resend API"""
from slopserver.settings import settings
import resend
resend.api_key = settings.resend_token
def send_email(to: str, subject, content):
params: resend.Emails.SendParams = {
"from": "slopfarmer@jack-case.pro",
"to": to,
"subject": subject,
"html": content
}
email: resend.Emails.SendResponse = resend.Emails.send(params)
return email
def generate_verification_email(verification_url: str):
return f"""
<p>click here to verify your Slop Farmer account: <a href={verification_url}>{verification_url}</a></p>
"""

View File

@@ -15,6 +15,7 @@ import uvicorn
from fastapi import Body, Depends, FastAPI, Form, HTTPException, Header from fastapi import Body, Depends, FastAPI, Form, HTTPException, Header
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from pydantic import AfterValidator, Base64Str from pydantic import AfterValidator, Base64Str
@@ -32,7 +33,8 @@ from uuid import uuid4
from slopserver.settings import settings from slopserver.settings import settings
from slopserver.models import Domain, Path, User from slopserver.models import Domain, Path, User
from slopserver.models import SlopReport, SignupForm, altcha_validator from slopserver.models import SlopReport, SignupForm, altcha_validator
from slopserver.db import select_slop, insert_slop, get_user, create_user from slopserver.db import select_slop, insert_slop, get_user, create_user, verify_user_email
from slopserver.email import generate_verification_email, send_email
app = FastAPI() app = FastAPI()
@@ -87,6 +89,17 @@ def generate_auth_token(username):
encoded_jwt = jwt.encode(bearer_token, TOKEN_SECRET, ALGO) encoded_jwt = jwt.encode(bearer_token, TOKEN_SECRET, ALGO)
return encoded_jwt return encoded_jwt
def generate_verification_token(username):
expiration = datetime.now() + timedelta(days=2)
verification_token = {
"iss": "slopserver",
"exp": int(expiration.timestamp()),
"sub": username
}
encoded_jwt = jwt.encode(verification_token, TOKEN_SECRET, ALGO)
return encoded_jwt
def get_token_user(decoded_token): def get_token_user(decoded_token):
user = get_user(decoded_token["sub"], DB_ENGINE) user = get_user(decoded_token["sub"], DB_ENGINE)
return user return user
@@ -98,13 +111,20 @@ def verify_auth_token(token: str):
except: except:
raise HTTPException(status_code=401, detail="invalid access token") raise HTTPException(status_code=401, detail="invalid access token")
def verify_verification_token(token: str):
try:
token = jwt.decode(token, TOKEN_SECRET, ALGO)
return token
except:
raise HTTPException(status_code=404, detail="invalid verification URL")
@app.post("/report") @app.post("/report")
async def report_slop(report: SlopReport, bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]): def report_slop(report: SlopReport, bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]):
user = get_token_user(bearer) user = get_token_user(bearer)
insert_slop(report.slop_urls, DB_ENGINE, user) insert_slop(report.slop_urls, DB_ENGINE, user)
@app.post("/check") @app.post("/check")
async def check_slop(check: Annotated[SlopReport, Body()], bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]): def check_slop(check: Annotated[SlopReport, Body()], bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]):
slop_results = select_slop(check.slop_urls, DB_ENGINE) slop_results = select_slop(check.slop_urls, DB_ENGINE)
return slop_results return slop_results
@@ -112,13 +132,13 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
pass pass
@app.post("/token") @app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
user = get_user(form_data.username, DB_ENGINE) user = get_user(form_data.username, DB_ENGINE)
if not user: if not user:
raise HTTPException(status_code=400, detail="Incorrect username or password") raise HTTPException(status_code=400, detail="Incorrect username or password")
@app.post("/signup") @app.post("/signup")
async def signup_form(form_data: Annotated[SignupForm, Form()]): def signup_form(form_data: Annotated[SignupForm, Form()]):
# if we're here, form is validated including the altcha # if we're here, form is validated including the altcha
# check for existing user with the given email # check for existing user with the given email
if get_user(form_data.email, DB_ENGINE): if get_user(form_data.email, DB_ENGINE):
@@ -128,8 +148,38 @@ async def signup_form(form_data: Annotated[SignupForm, Form()]):
# create user # create user
create_user(form_data.email, get_password_hash(form_data.password.get_secret_value()), DB_ENGINE) create_user(form_data.email, get_password_hash(form_data.password.get_secret_value()), DB_ENGINE)
# send verification email
# create a jwt encoding the username and a time limit to be the verification URL
token = generate_verification_token(form_data.email)
email_html = generate_verification_email(settings.api_base + "verify/?token=" + token)
status = send_email(form_data.email, "Slop Farmer Email Verification", email_html)
return status
@app.get("/verify")
def verify_email(token: Annotated[str, AfterValidator(verify_verification_token)]):
user = get_user(token["sub"], DB_ENGINE)
if not user:
raise HTTPException(status_code=404, detail="invalid verification URL")
if user.email_verified:
raise HTTPException(status_code=404, detail="already verified")
verify_user_email(user, DB_ENGINE)
html = f"""
<html>
<head>
</head>
<body>
<p>{token["sub"]} verified. You may log in now.</p>
</body>
</html>
"""
return HTMLResponse(content=html, status_code=200)
@app.get("/altcha-challenge") @app.get("/altcha-challenge")
async def altcha_challenge(): def altcha_challenge():
options = ChallengeOptions( options = ChallengeOptions(
expires=datetime.now() + timedelta(minutes=10), expires=datetime.now() + timedelta(minutes=10),
max_number=80000, max_number=80000,
@@ -139,10 +189,12 @@ async def altcha_challenge():
return challenge return challenge
@app.post("/login") @app.post("/login")
async def simple_login(username: Annotated[str, Form()], password: Annotated[str, Form()]): def simple_login(username: Annotated[str, Form()], password: Annotated[str, Form()]):
user = auth_user(username, password, DB_ENGINE) user = auth_user(username, password, DB_ENGINE)
if not user: if not user:
raise HTTPException(status_code=401, detail="Incorrect username or password") raise HTTPException(status_code=401, detail="Incorrect username or password")
if not user.email_verified:
raise HTTPException(status_code=401, detail="Unverified email address")
token = generate_auth_token(username) token = generate_auth_token(username)
return {"access_token": token, "token_type": "bearer"} return {"access_token": token, "token_type": "bearer"}

View File

@@ -5,5 +5,9 @@ class ServerSettings(BaseSettings):
db_url: str = "sqlite+pysqlite:///test_db.sqlite" db_url: str = "sqlite+pysqlite:///test_db.sqlite"
token_secret: str = "5bcc778a96b090c3ac1d587bb694a060eaf7bdb5832365f91d5078faf1fff210" token_secret: str = "5bcc778a96b090c3ac1d587bb694a060eaf7bdb5832365f91d5078faf1fff210"
altcha_secret: str = "0460de065912d0292df1e7422a5ed2dc362ed56d6bab64fe50b89957463061f3" altcha_secret: str = "0460de065912d0292df1e7422a5ed2dc362ed56d6bab64fe50b89957463061f3"
resend_token: str = "re_NXpjzbqR_KgAbu72PKjYHcquX24WvnN3i"
sender_email: str = "slopfarmer@jack-case.pro"
api_base: str = "api.slopfarmer.jack-case.pro/"
settings = ServerSettings() settings = ServerSettings()