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
174 changes: 156 additions & 18 deletions src/argent/api/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ class LoginResponse(BaseModel):

success: bool
message: str
redirect: str


# Session helpers
Expand Down Expand Up @@ -210,48 +209,105 @@ async def register(
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
response: Response,
db: AsyncSession = Depends(get_db),
verification_service: VerificationService = Depends(get_verification_service),
email_service: EmailService = Depends(get_email_service),
settings: Settings = Depends(get_settings),
) -> LoginResponse:
"""
Login an existing player by email.
Request a magic link to login.

Creates a session and redirects to appropriate page based on player state.
Sends a magic link email if the account exists.
Returns the same response regardless of whether email exists (prevents enumeration).
"""
# Find player by email
result = await db.execute(select(Player).where(Player.email == request.email))
player = result.scalar_one_or_none()

# Always return success to prevent email enumeration
success_message = "If an account exists with this email, you'll receive a login link shortly."

if not player:
# No account - return success anyway to prevent enumeration
logger.info("Login attempt for non-existent email: %s", request.email)
return LoginResponse(success=True, message=success_message)

# Check rate limit
can_request, seconds_remaining = await verification_service.can_request_magic_link(player.id)
if not can_request:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Email not found. Please register first.",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {seconds_remaining} seconds before requesting another login link.",
)

# Create session
_create_session_cookie(response, player.id, settings)
logger.info("Player logged in: %s", player.id)
# Generate magic link token
magic_token = await verification_service.create_magic_link_token(player.id)
magic_link_url = f"{settings.base_url}/api/auth/magic/{magic_token}"

# Send magic link email
email_sent = await _send_magic_link_email(
email_service=email_service,
to_email=request.email,
magic_link_url=magic_link_url,
settings=settings,
)

if not email_sent:
logger.warning("Failed to send magic link email to %s", request.email)

logger.info("Magic link requested for player: %s", player.id)

return LoginResponse(success=True, message=success_message)


@router.get("/auth/magic/{token}")
async def verify_magic_link(
token: str,
response: Response,
db: AsyncSession = Depends(get_db),
verification_service: VerificationService = Depends(get_verification_service),
settings: Settings = Depends(get_settings),
) -> Response:
"""
Verify magic link token and create session.

Redirects to appropriate page on success, login page with error on failure.
"""
player_id = await verification_service.verify_magic_link_token(token)

if player_id is None:
# Invalid or expired token - redirect to login with error
response.status_code = status.HTTP_303_SEE_OTHER
response.headers["Location"] = "/login?error=invalid_magic_link"
return response

# Get player to determine redirect
result = await db.execute(select(Player).where(Player.id == player_id))
player = result.scalar_one_or_none()

if not player:
response.status_code = status.HTTP_303_SEE_OTHER
response.headers["Location"] = "/login?error=invalid_magic_link"
return response

# Create session cookie
_create_session_cookie(response, player_id, settings)
logger.info("Player logged in via magic link: %s", player_id)

# Determine redirect based on player state
if player.game_started_at:
# Game already started
if player.communication_mode == "web_only":
redirect = "/hub"
else:
redirect = "/start"
redirect = "/hub" if player.communication_mode == "web_only" else "/start"
elif player.email_verified and player.phone_verified:
# Verified but not started
redirect = "/start"
else:
# Needs verification
redirect = "/verify"

return LoginResponse(
success=True,
message="Welcome back!",
redirect=redirect,
)
response.status_code = status.HTTP_303_SEE_OTHER
response.headers["Location"] = redirect
return response


@router.get("/verify/email/{token}")
Expand Down Expand Up @@ -610,6 +666,88 @@ async def _send_verification_email(
return result.success


async def _send_magic_link_email(
email_service: EmailService,
to_email: str,
magic_link_url: str,
settings: Settings,
) -> bool:
"""Send magic link login email."""
if not settings.email_enabled:
logger.info("Email disabled - skipping magic link email")
return True

subject = "Your login link"
text_content = f"""ARGent

Sign in to your account.

{magic_link_url}

This link expires in 15 minutes and can only be used once.

If you did not request this, ignore this email.
"""
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #0a0a0a; font-family: 'Courier New', monospace;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #0a0a0a; padding: 40px 20px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 500px; background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;">
<tr>
<td style="padding: 40px 30px; text-align: center;">
<!-- Logo -->
<p style="margin: 0 0 30px 0; font-size: 28px; letter-spacing: 2px;">
<span style="color: #e0e0e0;">ARG</span><span style="color: #00ff88;">ent</span>
</p>

<!-- Main text -->
<p style="margin: 0 0 30px 0; color: #a0a0a0; font-size: 14px; line-height: 1.6;">
Sign in to your account.
</p>

<!-- Button -->
<a href="{magic_link_url}"
style="display: inline-block; padding: 14px 32px; background-color: #2a2a2a; color: #e0e0e0; text-decoration: none; border: 1px solid #333; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 14px;">
Sign In
</a>

<!-- Expiry note -->
<p style="margin: 30px 0 0 0; color: #606060; font-size: 12px;">
This link expires in 15 minutes and can only be used once.
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; border-top: 1px solid #333; text-align: center;">
<p style="margin: 0; color: #606060; font-size: 11px;">
If you did not request this, ignore this email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""

result = await email_service.send_raw(
to_email=to_email,
subject=subject,
text_content=text_content,
html_content=html_content,
from_email=settings.email_from,
)
return result.success


async def _send_verification_sms(
sms_service: SMSService,
to_phone: str,
Expand Down
1 change: 1 addition & 0 deletions src/argent/models/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class TokenType(str, Enum):

EMAIL = "email"
PHONE = "phone"
MAGIC_LINK = "magic_link"


class VerificationToken(Base, UUIDMixin, TimestampMixin):
Expand Down
92 changes: 92 additions & 0 deletions src/argent/services/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
PHONE_CODE_EXPIRY_MINUTES = 10
PHONE_RESEND_COOLDOWN_SECONDS = 60

# Magic link specifications
MAGIC_LINK_TOKEN_BYTES = 32
MAGIC_LINK_EXPIRY_MINUTES = 15
MAGIC_LINK_RATE_LIMIT_SECONDS = 60


def _hash_token(token: str) -> str:
"""Hash a token using SHA256."""
Expand Down Expand Up @@ -207,6 +212,93 @@ async def get_active_tokens_count(self, player_id: UUID, token_type: TokenType)
)
return len(result.scalars().all())

async def create_magic_link_token(self, player_id: UUID) -> str:
"""Create a new magic link token for passwordless login.

Returns the raw token (to be sent in email). The hashed version is stored.
"""
# Invalidate any existing magic link tokens for this player
await self._invalidate_tokens(player_id, TokenType.MAGIC_LINK)

# Generate new token (same format as email tokens)
raw_token = secrets.token_urlsafe(MAGIC_LINK_TOKEN_BYTES)
hashed_token = _hash_token(raw_token)

# Calculate expiry (shorter than email verification)
expires_at = datetime.now(UTC) + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES)

# Create token record
token = VerificationToken(
player_id=player_id,
token_type=TokenType.MAGIC_LINK.value,
token_value=hashed_token,
expires_at=expires_at,
)
self.db.add(token)
await self.db.flush()

return raw_token

async def verify_magic_link_token(self, raw_token: str) -> UUID | None:
"""Verify a magic link token and return the player_id if valid.

Returns None if token is invalid, expired, or already used.
Consumes the token on success (single-use).
"""
hashed_token = _hash_token(raw_token)
now = datetime.now(UTC)

# Find valid token
result = await self.db.execute(
select(VerificationToken).where(
VerificationToken.token_type == TokenType.MAGIC_LINK.value,
VerificationToken.token_value == hashed_token,
VerificationToken.used_at.is_(None),
VerificationToken.expires_at > now,
)
)
token = result.scalar_one_or_none()

if token is None:
return None

# Mark as used (single-use)
token.used_at = now
await self.db.flush()

return token.player_id

async def can_request_magic_link(self, player_id: UUID) -> tuple[bool, int]:
"""Check if player can request a new magic link.

Returns (can_request, seconds_until_allowed).
Rate limited to one request per MAGIC_LINK_RATE_LIMIT_SECONDS.
"""
now = datetime.now(UTC)
cooldown_cutoff = now - timedelta(seconds=MAGIC_LINK_RATE_LIMIT_SECONDS)

# Find most recent magic link token for this player
result = await self.db.execute(
select(VerificationToken)
.where(
VerificationToken.player_id == player_id,
VerificationToken.token_type == TokenType.MAGIC_LINK.value,
)
.order_by(VerificationToken.created_at.desc())
.limit(1)
)
token = result.scalar_one_or_none()

if token is None:
return True, 0

if token.created_at > cooldown_cutoff:
# Still in cooldown
seconds_remaining = int((token.created_at - cooldown_cutoff).total_seconds())
return False, seconds_remaining

return True, 0

async def _invalidate_tokens(self, player_id: UUID, token_type: TokenType) -> int:
"""Invalidate all active tokens of a type for a player.

Expand Down
34 changes: 34 additions & 0 deletions src/argent/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,40 @@ a:hover {
color: var(--text-muted);
}

/* Login page */
.login {
padding-top: 2rem;
}

.check-email-message {
background-color: var(--bg-secondary);
padding: 2rem;
border-radius: 4px;
margin: 2rem 0;
text-align: center;
}

.check-email-message p {
margin-bottom: 0.5rem;
}

.check-email-message .hint {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 0;
}

#resend-section {
text-align: center;
margin: 1.5rem 0;
}

.resend-text {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 0.75rem;
}

/* Mode toggle */
.mode-toggle {
margin-top: 1.5rem;
Expand Down
Loading