Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions backend/alembic/versions/f250cb93a643_add_email_verified.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""add_email_verified

Revision ID: f250cb93a643
Revises: 2e1dda44cde5
Create Date: 2026-06-28 22:31:08.365083

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'f250cb93a643'
down_revision: Union[str, None] = '2e1dda44cde5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=False, server_default=sa.text('false')))


def downgrade() -> None:
op.drop_column('users', 'email_verified')
46 changes: 43 additions & 3 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
require_admin,
)
from app.core.config import get_settings
from app.core.users import check_if_email_exists
from app.core.users import check_if_email_exists, find_user_by_id
from app.core.email_verification import generate_verification_token, verify_verification_token
from app.db.session import get_db
from app.models.models import User
from app.schemas.user import UserResponse
from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest
from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest, MessageResponse
from app.services.email_service import send_verification_email

router = APIRouter( tags=["auth"])

Expand Down Expand Up @@ -72,6 +74,13 @@ def _create_user(
db.refresh(user)
return user

async def _send_verification_email(to: str, id: int):
try:
await send_verification_email(to, generate_verification_token(id))

except Exception:
raise HTTPException(500, "Failed to send verification email")


@router.post("/auth/login/", response_model=UserResponse)
def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)):
Expand Down Expand Up @@ -138,4 +147,35 @@ def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _:
"""
check_if_email_exists(db, body.email)

return _create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False)
return _create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False)

@router.get("/auth/verify-email/", status_code=status.HTTP_200_OK, response_model=MessageResponse,
responses={
400: {"description": "Invalid or expired token"},
404: {"description": "User not found"},
},
)
def verify_email(token: str, db: Session = Depends(get_db)):
user_id = verify_verification_token(token)
if user_id is None:
raise HTTPException(400, "Invalid or expired token")

user = find_user_by_id(db, user_id)
user.email_verified = True
db.commit()
return {"detail": "User email successfully verified"}

# todo: add rate limiting
@router.post("/auth/send-email-verification/", status_code=status.HTTP_200_OK, response_model=MessageResponse,
responses={
400: {"description": "Email already verified"},
500: {"description": "Failed to send verification email"},
},
)
async def send_email_verification(user: User = Depends(get_current_user)):
if user.email_verified:
raise HTTPException(400, "Email already verified")

# await _send_verification_email(user.email, user.id)

return {"detail": "Verification email successfully sent"}
11 changes: 2 additions & 9 deletions backend/app/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,13 @@

from app.core.auth import get_current_user, require_admin
from app.core.permissions import MANAGE_TOURNAMENT, MANAGE_VOLUNTEERS, has_permission
from app.core.users import check_if_email_exists
from app.core.users import check_if_email_exists, find_user_by_id
from app.db.session import get_db
from app.models.models import Membership, User
from app.schemas.user import UserResponse, UserUpdate, AdminUserUpdate

router = APIRouter(tags=["users"])

def _find_user_by_id(db: Session, id: int) -> User:
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user



# ---------------------------------------------------------------------------
# GET /users/ — admin only (global unscoped list)
Expand Down Expand Up @@ -74,7 +67,7 @@ def admin_update_user(
_: User = Depends(require_admin)
):
"""Admin can only update a user's role and is_active status."""
user = _find_user_by_id(db, user_id)
user = find_user_by_id(db, user_id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(user, field, value)
db.commit()
Expand Down
4 changes: 2 additions & 2 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(user_id: int) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
payload = {"sub": str(user_id), "exp": expire}
payload = {"sub": str(user_id), "exp": expire, "aud": "session"}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)


def decode_access_token(token: str) -> Optional[int]:
"""Returns user_id from a valid token, or None if invalid/expired."""
settings = get_settings()
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM], audience="session")
user_id = payload.get("sub")
if user_id is None:
return None
Expand Down
7 changes: 5 additions & 2 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ class Settings(BaseSettings):
app_host: str = "0.0.0.0"
app_port: int = 8000

database_url: str = "sqlite:///./nexus.db"
database_url: str = "postgresql://nexus:nexus@127.0.0.1:5432/nexus"

google_service_account_file: str = "./credentials.json"
google_service_account_json: str = "" # JSON string — used in production instead of file

api_key: str = "" # For direct API access / Swagger only

# Must be set to a long random string in production — never commit the real value
# Must be set to a long random string in production
jwt_secret: str = "dev-secret-change-in-production"

resend_api_key: str = "" # set in .env file for dev or env vars in prod, never commit here
frontend_url: str = "http://localhost:3000/" # remember to set to actual url in prod


@lru_cache()
def get_settings() -> Settings:
Expand Down
27 changes: 27 additions & 0 deletions backend/app/core/email_verification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from datetime import datetime, timedelta, timezone
from typing import Optional

from jose import JWTError, jwt

from app.core.config import get_settings

ALGORITHM = "HS256"
VERIFICATION_TOKEN_EXPIRE_DAYS = 1

def generate_verification_token(user_id: int) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(days=VERIFICATION_TOKEN_EXPIRE_DAYS)
payload = {"sub": str(user_id), "exp": expire, "aud": "email_verification"}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)

def verify_verification_token(token: str) -> Optional[int]:
"""Returns user_id if valid token, or None if invalid/expired/wrong purpose"""
settings = get_settings()
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM], audience="email_verification")
user_id = payload.get("sub")
if user_id is None:
return None
return int(user_id)
except JWTError:
return None
9 changes: 8 additions & 1 deletion backend/app/core/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,11 @@ def check_if_email_exists(db: Session, email: str):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
)


def find_user_by_id(db: Session, id: int) -> User:
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
1 change: 1 addition & 0 deletions backend/app/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class User(Base):

# Auth fields
hashed_password = Column(String(255), nullable=True) # null = cannot log in, must reset password and verify via email
email_verified = Column(Boolean, nullable=False, default=False)
role = Column(String(32), nullable=False, default="user") # "admin" | "user"
is_active = Column(Boolean, nullable=False, default=True)

Expand Down
6 changes: 5 additions & 1 deletion backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,8 @@ class AdminRegisterRequest(BaseModel):
# password is excluded because when user logs into their new account they will make one themselves
first_name: str
last_name: str
role: Literal["admin", "user"]
role: Literal["admin", "user"]


class MessageResponse(BaseModel):
detail: str
1 change: 1 addition & 0 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class UserResponse(BaseModel):
email: EmailStr
phone: Optional[str] = None

email_verified: bool
role: ROLE
is_active: bool

Expand Down
17 changes: 17 additions & 0 deletions backend/app/services/email_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import resend

from app.core.config import get_settings


async def send_verification_email(to: str, token: str) -> None:
settings = get_settings()
resend.api_key = settings.resend_api_key

params: resend.Emails.SendParams = {
"from": "NEXUS <verify@nexus.ethanshih.com>",
"to": to,
"subject": "Verify Your Email on NEXUS",
"text": f"Please verify your email: {settings.frontend_url.rstrip('/')}/verify-email?token={token}"
}

await resend.Emails.send_async(params)
3 changes: 3 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ google-auth==2.37.0
google-auth-oauthlib==1.2.1
google-api-python-client==2.154.0

# Resend (email services) API
resend==2.32.2

# Testing
pytest==8.3.4
pytest-asyncio==0.24.0
Expand Down
Loading
Loading