aaaa
This commit is contained in:
Binary file not shown.
20
2025/lake/web/web-magicauth/compose.yaml
Normal file
20
2025/lake/web/web-magicauth/compose.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: ./web
|
||||||
|
ports:
|
||||||
|
- "8025:80"
|
||||||
|
environment:
|
||||||
|
- FLAG=EPFL{test_flag_placeholder}
|
||||||
|
- DOMAIN=auth.ctf.cx
|
||||||
|
- SMTP2HTTP_TOKEN=different_secret_on_remote
|
||||||
|
|
||||||
|
smtp2http:
|
||||||
|
build:
|
||||||
|
context: ./smtp2http
|
||||||
|
command: ["--webhook=http://web/api/email?token=different_secret_on_remote"]
|
||||||
|
|
||||||
|
mta:
|
||||||
|
build: ./mta
|
||||||
|
ports:
|
||||||
|
- "25:25"
|
||||||
|
- "587:25" # in case player's ISP blocks port 25
|
||||||
13
2025/lake/web/web-magicauth/mta/Dockerfile
Normal file
13
2025/lake/web/web-magicauth/mta/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 999 ctf \
|
||||||
|
&& useradd --system --gid 999 --uid 999 --create-home ctf
|
||||||
|
|
||||||
|
RUN pip install 'aiosmtpd==1.4.6'
|
||||||
|
|
||||||
|
COPY server.py /app/server.py
|
||||||
|
|
||||||
|
USER ctf
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
CMD ["python", "server.py"]
|
||||||
121
2025/lake/web/web-magicauth/mta/server.py
Normal file
121
2025/lake/web/web-magicauth/mta/server.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from email import message_from_bytes
|
||||||
|
|
||||||
|
from aiosmtpd.controller import Controller
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LISTEN_HOST = "0.0.0.0"
|
||||||
|
LISTEN_PORT = 25
|
||||||
|
FORWARD_HOST = "smtp2http"
|
||||||
|
FORWARD_PORT = 25
|
||||||
|
|
||||||
|
class MTAHandler:
|
||||||
|
async def handle_DATA(self, server, session, envelope):
|
||||||
|
try:
|
||||||
|
peer = session.peer
|
||||||
|
client_ip = peer[0] if peer else "unknown"
|
||||||
|
|
||||||
|
logger.info(f"Received email from {client_ip}: {envelope.mail_from} -> {envelope.rcpt_tos}")
|
||||||
|
|
||||||
|
message_data = envelope.content
|
||||||
|
|
||||||
|
msg = message_from_bytes(message_data)
|
||||||
|
|
||||||
|
# Security: Remove any existing Received headers to prevent spoofing
|
||||||
|
# Only the last MTA's Received header should be trusted
|
||||||
|
if 'Received' in msg:
|
||||||
|
del msg['Received']
|
||||||
|
|
||||||
|
msg['Received'] = client_ip
|
||||||
|
|
||||||
|
await self.forward_email(
|
||||||
|
msg,
|
||||||
|
envelope.mail_from,
|
||||||
|
envelope.rcpt_tos
|
||||||
|
)
|
||||||
|
|
||||||
|
return '250 OK: Message accepted for delivery'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing email: {e}", exc_info=True)
|
||||||
|
return '451 Requested action aborted: error in processing'
|
||||||
|
|
||||||
|
async def forward_email(self, msg, mail_from, rcpt_tos):
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_connection(FORWARD_HOST, FORWARD_PORT)
|
||||||
|
|
||||||
|
# Read banner
|
||||||
|
banner = await reader.readline()
|
||||||
|
logger.debug(f"smtp2http banner: {banner.decode().strip()}")
|
||||||
|
|
||||||
|
# Send EHLO
|
||||||
|
writer.write(b"EHLO mta\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
response = await reader.readline()
|
||||||
|
logger.debug(f"EHLO response: {response.decode().strip()}")
|
||||||
|
|
||||||
|
# Send MAIL FROM
|
||||||
|
writer.write(f"MAIL FROM:<{mail_from}>\r\n".encode())
|
||||||
|
await writer.drain()
|
||||||
|
response = await reader.readline()
|
||||||
|
logger.debug(f"MAIL FROM response: {response.decode().strip()}")
|
||||||
|
|
||||||
|
# Send RCPT TO for each recipient
|
||||||
|
for recipient in rcpt_tos:
|
||||||
|
writer.write(f"RCPT TO:<{recipient}>\r\n".encode())
|
||||||
|
await writer.drain()
|
||||||
|
response = await reader.readline()
|
||||||
|
logger.debug(f"RCPT TO response: {response.decode().strip()}")
|
||||||
|
|
||||||
|
# Send DATA command
|
||||||
|
writer.write(b"DATA\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
response = await reader.readline()
|
||||||
|
logger.debug(f"DATA response: {response.decode().strip()}")
|
||||||
|
|
||||||
|
email_content = msg.as_string().encode('utf-8')
|
||||||
|
writer.write(email_content)
|
||||||
|
|
||||||
|
if not email_content.endswith(b"\r\n"):
|
||||||
|
writer.write(b"\r\n")
|
||||||
|
writer.write(b".\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
response = await reader.readline()
|
||||||
|
logger.debug(f"Message response: {response.decode().strip()}")
|
||||||
|
|
||||||
|
# Send QUIT
|
||||||
|
writer.write(b"QUIT\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
response = await reader.readline()
|
||||||
|
logger.debug(f"QUIT response: {response.decode().strip()}")
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
logger.info(f"Successfully forwarded email from {mail_from} to {rcpt_tos}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to forward email: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
handler = MTAHandler()
|
||||||
|
|
||||||
|
controller = Controller(
|
||||||
|
handler,
|
||||||
|
hostname=LISTEN_HOST,
|
||||||
|
port=LISTEN_PORT
|
||||||
|
)
|
||||||
|
|
||||||
|
controller.start()
|
||||||
|
logger.info(f"MTA listening on {LISTEN_HOST}:{LISTEN_PORT}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
controller.stop()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
13
2025/lake/web/web-magicauth/smtp2http/Dockerfile
Normal file
13
2025/lake/web/web-magicauth/smtp2http/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM golang:1.14.4 AS builder
|
||||||
|
RUN git clone https://github.com/alash3al/smtp2http /go/src/build
|
||||||
|
WORKDIR /go/src/build
|
||||||
|
COPY diff.patch .
|
||||||
|
RUN git apply diff.patch
|
||||||
|
RUN go mod vendor
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
RUN GOOS=linux go build -mod vendor -a -o smtp2http .
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
WORKDIR /root/
|
||||||
|
COPY --from=builder /go/src/build/smtp2http /usr/bin/smtp2http
|
||||||
|
ENTRYPOINT ["smtp2http"]
|
||||||
49
2025/lake/web/web-magicauth/smtp2http/diff.patch
Normal file
49
2025/lake/web/web-magicauth/smtp2http/diff.patch
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
diff --git a/go.mod b/go.mod
|
||||||
|
index a12f908..0402218 100644
|
||||||
|
--- a/go.mod
|
||||||
|
+++ b/go.mod
|
||||||
|
@@ -6,5 +6,6 @@ require (
|
||||||
|
github.com/alash3al/go-smtpsrv v0.0.0-20220704173150-cdaad3f3f582 // indirect
|
||||||
|
github.com/go-resty/resty/v2 v2.3.0
|
||||||
|
github.com/miekg/dns v1.1.50 // indirect
|
||||||
|
+ github.com/zaccone/spf v0.0.0-20170817004109-76747b8658d9 // indirect
|
||||||
|
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 // indirect
|
||||||
|
)
|
||||||
|
diff --git a/main.go b/main.go
|
||||||
|
index b7b6b4f..8e37638 100644
|
||||||
|
--- a/main.go
|
||||||
|
+++ b/main.go
|
||||||
|
@@ -6,12 +6,14 @@ import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
+ "net"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alash3al/go-smtpsrv"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
+ "github.com/zaccone/spf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
@@ -27,7 +29,17 @@ func main() {
|
||||||
|
return errors.New("Cannot read your message: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
- spfResult, _, _ := c.SPF()
|
||||||
|
+ // replace source IP with the one from Received header because there is an additional MTA in front
|
||||||
|
+ var spfResult spf.Result = spf.None
|
||||||
|
+ receivedAddr := msg.Header.Get("Received")
|
||||||
|
+ if receivedAddr == "" {
|
||||||
|
+ spfResult, _, _ = c.SPF()
|
||||||
|
+ } else {
|
||||||
|
+ _, host, err := smtpsrv.SplitAddress(c.From().Address)
|
||||||
|
+ if err == nil {
|
||||||
|
+ spfResult, _, _ = spf.CheckHost(net.ParseIP(receivedAddr), host, c.From().Address)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
|
||||||
|
jsonData := EmailMessage{
|
||||||
|
ID: msg.MessageID,
|
||||||
11
2025/lake/web/web-magicauth/web/.dockerignore
Normal file
11
2025/lake/web/web-magicauth/web/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.parcel-cache
|
||||||
|
dist
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/.parcel-cache
|
||||||
|
frontend/dist
|
||||||
|
static/.parcel-cache
|
||||||
21
2025/lake/web/web-magicauth/web/Dockerfile
Normal file
21
2025/lake/web/web-magicauth/web/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 999 ctf \
|
||||||
|
&& useradd --system --gid 999 --uid 999 --create-home ctf
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y nodejs npm && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install 'fastapi[standard]==0.121.0' 'pyjwt==2.10.1' 'fastapi-cli==0.0.14'
|
||||||
|
|
||||||
|
COPY frontend/package.json /app/frontend/package.json
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend /app/frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
COPY backend /app/backend
|
||||||
|
|
||||||
|
USER ctf
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "80", "backend"]
|
||||||
293
2025/lake/web/web-magicauth/web/backend/__init__.py
Normal file
293
2025/lake/web/web-magicauth/web/backend/__init__.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SECRET_KEY = secrets.token_urlsafe(32)
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
FLAG = os.getenv("FLAG")
|
||||||
|
DOMAIN = os.getenv("DOMAIN")
|
||||||
|
SMTP2HTTP_TOKEN = os.getenv("SMTP2HTTP_TOKEN")
|
||||||
|
|
||||||
|
if not FLAG or not DOMAIN or not SMTP2HTTP_TOKEN:
|
||||||
|
logger.error("FLAG, DOMAIN, and SMTP2HTTP_TOKEN environment variables must be set")
|
||||||
|
raise RuntimeError("Missing required environment variables")
|
||||||
|
|
||||||
|
# In-memory storage
|
||||||
|
users_db: Dict[str, str] = {} # email -> role
|
||||||
|
pending_registrations: Dict[str, WebSocket] = {} # token -> websocket
|
||||||
|
pending_logins: Dict[str, WebSocket] = {} # token -> websocket
|
||||||
|
|
||||||
|
# Initialize admin user at startup
|
||||||
|
admin_email = "admin@auth.ctf.cx"
|
||||||
|
users_db[admin_email] = "admin"
|
||||||
|
logger.info(f"Admin user created: {admin_email}")
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
class Address(BaseModel):
|
||||||
|
address: str
|
||||||
|
|
||||||
|
|
||||||
|
class Addresses(BaseModel):
|
||||||
|
from_: Address = Field(alias="from")
|
||||||
|
to: Address
|
||||||
|
|
||||||
|
|
||||||
|
class EmailPayload(BaseModel):
|
||||||
|
subject: str
|
||||||
|
addresses: Addresses
|
||||||
|
spf: str
|
||||||
|
|
||||||
|
|
||||||
|
def create_jwt_token(email: str, role: str) -> str:
|
||||||
|
"""Create a JWT token for the user"""
|
||||||
|
payload = {
|
||||||
|
"sub": email,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
"is_admin": role == "admin",
|
||||||
|
"exp": datetime.utcnow() + timedelta(hours=1)
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_jwt_token(token: str) -> dict:
|
||||||
|
"""Verify and decode a JWT token"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=401, detail="Token has expired")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||||
|
"""Get the current user from JWT token"""
|
||||||
|
token = credentials.credentials
|
||||||
|
return verify_jwt_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Serve the main page"""
|
||||||
|
return FileResponse(Path("/app/static/index.html"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/auth")
|
||||||
|
async def websocket_auth(websocket: WebSocket):
|
||||||
|
"""WebSocket endpoint for registration and login"""
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info("WebSocket connection established")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_json()
|
||||||
|
action = data.get("action")
|
||||||
|
|
||||||
|
if action == "register":
|
||||||
|
# Generate a random token
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
pending_registrations[token] = websocket
|
||||||
|
|
||||||
|
# Create mailto link
|
||||||
|
mailto_link = f"mailto:magic@{DOMAIN}?subject=register:{token}"
|
||||||
|
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Please send an email to complete registration",
|
||||||
|
"token": token,
|
||||||
|
"mailto_link": mailto_link,
|
||||||
|
"auth_email": f"magic@{DOMAIN}",
|
||||||
|
"subject": f"register:{token}"
|
||||||
|
})
|
||||||
|
logger.info(f"Registration pending, token={token}")
|
||||||
|
|
||||||
|
elif action == "login":
|
||||||
|
# Generate a random token
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
pending_logins[token] = websocket
|
||||||
|
|
||||||
|
# Create mailto link
|
||||||
|
mailto_link = f"mailto:magic@{DOMAIN}?subject=login:{token}"
|
||||||
|
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Please send an email to complete login",
|
||||||
|
"token": token,
|
||||||
|
"mailto_link": mailto_link,
|
||||||
|
"auth_email": f"magic@{DOMAIN}",
|
||||||
|
"subject": f"login:{token}"
|
||||||
|
})
|
||||||
|
logger.info(f"Login pending, token={token}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Unknown action"
|
||||||
|
})
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("WebSocket connection closed")
|
||||||
|
# Clean up pending requests for this websocket
|
||||||
|
for token, ws in list(pending_registrations.items()):
|
||||||
|
if ws == websocket:
|
||||||
|
del pending_registrations[token]
|
||||||
|
for token, ws in list(pending_logins.items()):
|
||||||
|
if ws == websocket:
|
||||||
|
del pending_logins[token]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/email")
|
||||||
|
async def email_webhook(token: str, payload: EmailPayload):
|
||||||
|
"""Webhook endpoint to receive emails from smtp2http"""
|
||||||
|
if token != SMTP2HTTP_TOKEN:
|
||||||
|
return {"status": "Invalid token"}
|
||||||
|
|
||||||
|
logger.info("Received email payload: %s", payload)
|
||||||
|
|
||||||
|
# Check SPF
|
||||||
|
if payload.spf != "pass":
|
||||||
|
logger.warning(f"Email failed SPF check: {payload.spf}")
|
||||||
|
return {"status": "Email rejected due to SPF failure"}
|
||||||
|
|
||||||
|
# Parse the subject
|
||||||
|
subject = payload.subject.strip()
|
||||||
|
|
||||||
|
# Handle registration
|
||||||
|
if subject.startswith("register:"):
|
||||||
|
token = subject.split(":", 1)[1].strip()
|
||||||
|
if token in pending_registrations:
|
||||||
|
websocket = pending_registrations[token]
|
||||||
|
email = payload.addresses.from_.address
|
||||||
|
|
||||||
|
if email in users_db:
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Email already registered"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send to websocket: {e}")
|
||||||
|
del pending_registrations[token]
|
||||||
|
return {"status": "Email already registered"}
|
||||||
|
|
||||||
|
if payload.addresses.to.address.lower() != f"magic@{DOMAIN}":
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Email must be sent to magic@{DOMAIN}"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send to websocket: {e}")
|
||||||
|
del pending_registrations[token]
|
||||||
|
return {"status": f"Email must be sent to magic@{DOMAIN}"}
|
||||||
|
|
||||||
|
# Store user with default role
|
||||||
|
role = "user"
|
||||||
|
users_db[email] = role
|
||||||
|
|
||||||
|
# Generate JWT
|
||||||
|
jwt_token = create_jwt_token(email, role)
|
||||||
|
|
||||||
|
# Send JWT to websocket
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "success",
|
||||||
|
"message": "Registration successful!",
|
||||||
|
"token": jwt_token,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
"is_admin": False
|
||||||
|
})
|
||||||
|
logger.info(f"User registered: {email} -> {role}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send to websocket: {e}")
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
del pending_registrations[token]
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown registration token: {token}")
|
||||||
|
|
||||||
|
# Handle login
|
||||||
|
elif subject.startswith("login:"):
|
||||||
|
token = subject.split(":", 1)[1].strip()
|
||||||
|
if token in pending_logins:
|
||||||
|
websocket = pending_logins[token]
|
||||||
|
email = payload.addresses.from_.address
|
||||||
|
|
||||||
|
if email not in users_db:
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Email not registered"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send to websocket: {e}")
|
||||||
|
del pending_logins[token]
|
||||||
|
return {"status": "Email not registered"}
|
||||||
|
|
||||||
|
role = users_db[email]
|
||||||
|
|
||||||
|
# Generate JWT
|
||||||
|
jwt_token = create_jwt_token(email, role)
|
||||||
|
|
||||||
|
# Send JWT to websocket
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"status": "success",
|
||||||
|
"message": "Login successful!",
|
||||||
|
"token": jwt_token,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
"is_admin": role == "admin"
|
||||||
|
})
|
||||||
|
logger.info(f"User logged in: {email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send to websocket: {e}")
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
del pending_logins[token]
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown login token: {token}")
|
||||||
|
|
||||||
|
return {"status": "Email processed"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/flag")
|
||||||
|
async def get_flag(user: dict = Depends(get_current_user)):
|
||||||
|
"""Get the flag (only for admins)"""
|
||||||
|
if not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Only admins can access the flag")
|
||||||
|
|
||||||
|
return {"flag": FLAG}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/me")
|
||||||
|
async def get_me(user: dict = Depends(get_current_user)):
|
||||||
|
"""Get current user info"""
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# Mount static files (frontend will be built here)
|
||||||
|
try:
|
||||||
|
app.mount("/static", StaticFiles(directory="/app/static"), name="static")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Static files not mounted: {e}")
|
||||||
9
2025/lake/web/web-magicauth/web/frontend/.parcelrc
Normal file
9
2025/lake/web/web-magicauth/web/frontend/.parcelrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@parcel/config-default",
|
||||||
|
"transformers": {
|
||||||
|
"*.{js,mjs,jsx,cjs,ts,tsx}": [
|
||||||
|
"@parcel/transformer-js",
|
||||||
|
"@parcel/transformer-react-refresh-wrap"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
2025/lake/web/web-magicauth/web/frontend/.postcssrc
Normal file
5
2025/lake/web/web-magicauth/web/frontend/.postcssrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
3682
2025/lake/web/web-magicauth/web/frontend/package-lock.json
generated
Normal file
3682
2025/lake/web/web-magicauth/web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
2025/lake/web/web-magicauth/web/frontend/package.json
Normal file
24
2025/lake/web/web-magicauth/web/frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "magic-auth-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MagicAuth Frontend",
|
||||||
|
"author": "sam.ninja",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "parcel src/index.html",
|
||||||
|
"build": "parcel build src/index.html --dist-dir ../static --public-url /static"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
|
"tailwindcss": "^4.1.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@parcel/transformer-postcss": "^2.16.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"parcel": "^2.16.1",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
356
2025/lake/web/web-magicauth/web/frontend/src/App.jsx
Normal file
356
2025/lake/web/web-magicauth/web/frontend/src/App.jsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [token, setToken] = useState(null);
|
||||||
|
const [mode, setMode] = useState('login'); // 'login' or 'register'
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [mailtoLink, setMailtoLink] = useState('');
|
||||||
|
const [authEmail, setAuthEmail] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [flag, setFlag] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if token exists in localStorage
|
||||||
|
const savedToken = localStorage.getItem('authToken');
|
||||||
|
if (savedToken) {
|
||||||
|
setToken(savedToken);
|
||||||
|
fetchUserInfo(savedToken);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserInfo = async (authToken) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/me', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const userData = await response.json();
|
||||||
|
setUser(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
// If admin, try to fetch flag
|
||||||
|
if (userData.is_admin) {
|
||||||
|
fetchFlag(authToken);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Token is invalid
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user info:', error);
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFlag = async (authToken) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/flag', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setFlag(data.flag);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch flag:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectWebSocket = () => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/auth`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('Received:', data);
|
||||||
|
|
||||||
|
if (data.status === 'pending') {
|
||||||
|
setStatus(data.message);
|
||||||
|
setMailtoLink(data.mailto_link);
|
||||||
|
setAuthEmail(data.auth_email);
|
||||||
|
setSubject(data.subject);
|
||||||
|
setLoading(false);
|
||||||
|
} else if (data.status === 'success') {
|
||||||
|
setStatus(data.message);
|
||||||
|
setToken(data.token);
|
||||||
|
setUser({
|
||||||
|
sub: data.email,
|
||||||
|
email: data.email,
|
||||||
|
role: data.role,
|
||||||
|
is_admin: data.is_admin
|
||||||
|
});
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
localStorage.setItem('authToken', data.token);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// Fetch flag if admin
|
||||||
|
if (data.is_admin) {
|
||||||
|
fetchFlag(data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close WebSocket
|
||||||
|
ws.close();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
setError(data.message);
|
||||||
|
setStatus('');
|
||||||
|
setMailtoLink('');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
setError('WebSocket connection failed');
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuth = (authMode) => {
|
||||||
|
setError('');
|
||||||
|
setStatus('Connecting...');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
connectWebSocket();
|
||||||
|
|
||||||
|
// Wait for connection to open
|
||||||
|
setTimeout(() => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({
|
||||||
|
action: authMode
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
setFlag(null);
|
||||||
|
setStatus('');
|
||||||
|
setMailtoLink('');
|
||||||
|
setAuthEmail('');
|
||||||
|
setSubject('');
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAuthenticated && user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
|
||||||
|
<h1 className="text-3xl font-bold text-center mb-6 text-blue-600">
|
||||||
|
MagicAuth™
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Welcome!</h2>
|
||||||
|
<p className="text-gray-600">Email: {user.email}</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Role: {user.is_admin ? (
|
||||||
|
<span className="text-green-600 font-semibold">Admin</span>
|
||||||
|
) : (
|
||||||
|
<span>User</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.is_admin && flag && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded">
|
||||||
|
<h3 className="font-semibold text-green-800 mb-2">Flag:</h3>
|
||||||
|
<code className="text-sm text-green-900 break-all">{flag}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.is_admin && !flag && (
|
||||||
|
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded">
|
||||||
|
<p className="text-yellow-800">Loading flag...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600 transition"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-2xl w-full bg-white rounded-lg shadow-lg p-8">
|
||||||
|
<h1 className="text-3xl font-bold text-center mb-6 text-blue-600">
|
||||||
|
MagicAuth™
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
No passwords or validation codes needed! Authenticate via email with our revolutionary MagicAuth™ system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!mailtoLink && !loading && (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded mb-4">
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAuth('login')}
|
||||||
|
className="flex-1 bg-blue-500 text-white py-3 px-4 rounded hover:bg-blue-600 transition font-semibold text-lg"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAuth('register')}
|
||||||
|
className="flex-1 bg-green-500 text-white py-3 px-4 rounded hover:bg-green-600 transition font-semibold text-lg"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500 mb-4"></div>
|
||||||
|
<p className="text-gray-600">{status || 'Connecting...'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mailtoLink && !loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Left column: Automatic */}
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded">
|
||||||
|
<h3 className="font-semibold text-green-800 mb-3 text-center">
|
||||||
|
Automatic
|
||||||
|
</h3>
|
||||||
|
<a
|
||||||
|
href={mailtoLink}
|
||||||
|
className="block w-full text-center bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600 transition font-semibold mb-4"
|
||||||
|
>
|
||||||
|
Compose Email
|
||||||
|
</a>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="bg-white p-3 rounded border-2 border-green-300">
|
||||||
|
<QRCodeSVG value={mailtoLink} size={150} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-green-700 text-center mt-2">
|
||||||
|
Scan with your phone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: Manual */}
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<h3 className="font-semibold text-blue-800 mb-3 text-center">
|
||||||
|
Manual
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-blue-900 mb-1">
|
||||||
|
To:
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={authEmail}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-2 py-1.5 bg-white border border-blue-300 rounded font-mono text-xs"
|
||||||
|
onClick={(e) => e.target.select()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(authEmail)}
|
||||||
|
className="px-2 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition text-xs"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-blue-900 mb-1">
|
||||||
|
Subject:
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-2 py-1.5 bg-white border border-blue-300 rounded font-mono text-xs"
|
||||||
|
onClick={(e) => e.target.select()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(subject)}
|
||||||
|
className="px-2 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition text-xs"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-blue-600 mt-3">
|
||||||
|
Email body can be empty
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waiting spinner below both columns */}
|
||||||
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-yellow-600"></div>
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
Waiting for email verification...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
13
2025/lake/web/web-magicauth/web/frontend/src/index.html
Normal file
13
2025/lake/web/web-magicauth/web/frontend/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MagicAuth™</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./index.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
2025/lake/web/web-magicauth/web/frontend/src/index.jsx
Normal file
10
2025/lake/web/web-magicauth/web/frontend/src/index.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
9
2025/lake/web/web-magicauth/web/frontend/src/styles.css
Normal file
9
2025/lake/web/web-magicauth/web/frontend/src/styles.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{html,js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user