Magic link auth via Gmail SMTP + password fallback

This commit is contained in:
2026-04-13 14:08:16 +00:00
parent 00e76e412d
commit f57dd9621f
2 changed files with 219 additions and 123 deletions
+88 -1
View File
@@ -3,12 +3,15 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import hashlib import hashlib
import json import json
import os import os
import secrets import secrets
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from database import get_db, init_db from database import get_db, init_db
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge
@@ -19,6 +22,15 @@ app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32)) SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
_sessions: dict[str, dict] = {} # token -> {user_id, username, role} _sessions: dict[str, dict] = {} # token -> {user_id, username, role}
_magic_links: dict[str, dict] = {} # token -> {user_id, email, expires}
# SMTP Config
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "eric.jungbauer@gmail.com")
SMTP_PASS = os.environ.get("SMTP_PASS", "jozj oags ifqy auey")
SMTP_FROM = os.environ.get("SMTP_FROM", "eric.jungbauer@gmail.com")
APP_URL = os.environ.get("APP_URL", "https://agents.jfamily.io")
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@@ -64,6 +76,9 @@ class LoginRequest(BaseModel):
username: str # accepts username or email username: str # accepts username or email
password: str password: str
class MagicLinkRequest(BaseModel):
email: str
class InstanceCreate(BaseModel): class InstanceCreate(BaseModel):
catalog_id: str catalog_id: str
name: Optional[str] = None name: Optional[str] = None
@@ -134,6 +149,78 @@ def logout(response: Response, session: Optional[str] = Cookie(None)):
return {"status": "ok"} return {"status": "ok"}
def send_magic_email(email: str, token: str):
"""Send a magic link email via SMTP."""
link = f"{APP_URL}/auth/verify?token={token}"
html = f"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:2rem">
<h2 style="color:#6c5ce7">Agent Command Center</h2>
<p>Click the button below to sign in:</p>
<a href="{link}" style="display:inline-block;background:#6c5ce7;color:#fff;padding:12px 32px;
border-radius:6px;text-decoration:none;font-weight:500;margin:1rem 0">Sign In</a>
<p style="color:#888;font-size:13px">This link expires in 15 minutes. If you didn't request this, ignore this email.</p>
<p style="color:#888;font-size:12px">{APP_URL}</p>
</div>
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = "Sign in to Agent Command Center"
msg["From"] = SMTP_FROM
msg["To"] = email
msg.attach(MIMEText(f"Sign in: {link}\n\nExpires in 15 minutes.", "plain"))
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASS)
server.send_message(msg)
@app.post("/api/auth/magic-link")
def request_magic_link(data: MagicLinkRequest, db: Session = Depends(get_db)):
"""Send a magic link to the user's email."""
user = db.query(User).filter(User.email == data.email).first()
if not user:
# Don't reveal whether email exists — return success either way
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
# Generate token
token = secrets.token_urlsafe(32)
_magic_links[token] = {
"user_id": user.id,
"email": user.email,
"expires": datetime.now(timezone.utc) + timedelta(minutes=15),
}
# Send email
try:
send_magic_email(user.email, token)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send email: {e}")
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
@app.get("/auth/verify")
def verify_magic_link(token: str, response: Response, db: Session = Depends(get_db)):
"""Verify a magic link token and log the user in."""
link_data = _magic_links.pop(token, None)
if not link_data:
return RedirectResponse("/login?error=invalid", status_code=302)
if datetime.now(timezone.utc) > link_data["expires"]:
return RedirectResponse("/login?error=expired", status_code=302)
user = db.query(User).filter(User.id == link_data["user_id"]).first()
if not user:
return RedirectResponse("/login?error=invalid", status_code=302)
# Create session
session_token = create_session(user)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("session", session_token, httponly=True, samesite="lax", max_age=86400 * 7)
return resp
@app.get("/api/me") @app.get("/api/me")
def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)): def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
u = db.query(User).filter(User.id == user["user_id"]).first() u = db.query(User).filter(User.id == user["user_id"]).first()
+131 -122
View File
@@ -5,94 +5,30 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Agent Command Center</title> <title>Login — Agent Command Center</title>
<style> <style>
:root { :root {--bg:#0f1117;--surface:#1a1d27;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--red:#e17055;--green:#00b894}
--bg: #0f1117; *{margin:0;padding:0;box-sizing:border-box}
--surface: #1a1d27; body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
--border: #2e3345; .login-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:2.5rem;width:100%;max-width:400px}
--text: #e4e6ed; .login-card h1{font-size:1.3rem;font-weight:600;margin-bottom:.4rem;text-align:center}
--text-dim: #8b8fa3; .login-card .subtitle{color:var(--text-dim);font-size:.85rem;text-align:center;margin-bottom:2rem}
--accent: #6c5ce7; .field{margin-bottom:1.25rem}
--accent-hover: #7c6ef0; .field label{display:block;font-size:.8rem;font-weight:500;color:var(--text-dim);margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.05em}
--red: #e17055; .field input{width:100%;padding:.65rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .2s}
} .field input:focus{border-color:var(--accent)}
* { margin: 0; padding: 0; box-sizing: border-box; } .btn{width:100%;padding:.7rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:.95rem;font-weight:500;cursor:pointer;transition:background .2s;margin-top:.5rem}
body { .btn:hover{background:var(--accent-hover)}.btn:disabled{opacity:.6;cursor:not-allowed}
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; .btn-ghost{background:none;color:var(--text-dim);border:1px solid var(--border)}.btn-ghost:hover{border-color:var(--text-dim);color:var(--text)}
background: var(--bg); .msg{font-size:.85rem;text-align:center;margin-top:1rem;display:none}
color: var(--text); .msg.error{color:var(--red)}.msg.success{color:var(--green)}
min-height: 100vh; .divider{display:flex;align-items:center;gap:1rem;margin:1.5rem 0;color:var(--text-dim);font-size:.8rem}
display: flex; .divider::before,.divider::after{content:'';flex:1;border-top:1px solid var(--border)}
align-items: center; .tab-row{display:flex;margin-bottom:1.5rem;border-bottom:1px solid var(--border)}
justify-content: center; .tab{flex:1;text-align:center;padding:.6rem;font-size:.85rem;cursor:pointer;color:var(--text-dim);border-bottom:2px solid transparent;transition:all .2s}
} .tab:hover{color:var(--text)}.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.login-card { .panel{display:none}.panel.active{display:block}
background: var(--surface); .sent-state{text-align:center;padding:1rem 0}
border: 1px solid var(--border); .sent-state .icon{font-size:2rem;margin-bottom:.75rem}
border-radius: 12px; .sent-state p{color:var(--text-dim);font-size:.85rem;line-height:1.5}
padding: 2.5rem;
width: 100%;
max-width: 380px;
}
.login-card h1 {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 0.4rem;
text-align: center;
}
.login-card .subtitle {
color: var(--text-dim);
font-size: 0.85rem;
text-align: center;
margin-bottom: 2rem;
}
.field {
margin-bottom: 1.25rem;
}
.field label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-dim);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field input {
width: 100%;
padding: 0.65rem 0.85rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.field input:focus {
border-color: var(--accent);
}
.btn {
width: 100%;
padding: 0.7rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 0.5rem;
}
.btn:hover { background: var(--accent-hover); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error {
color: var(--red);
font-size: 0.85rem;
text-align: center;
margin-top: 1rem;
display: none;
}
</style> </style>
</head> </head>
<body> <body>
@@ -100,49 +36,122 @@
<div class="login-card"> <div class="login-card">
<h1>Agent Command Center</h1> <h1>Agent Command Center</h1>
<p class="subtitle">Sign in to continue</p> <p class="subtitle">Sign in to continue</p>
<form id="login-form">
<div class="field"> <div class="tab-row">
<label>Username</label> <div class="tab active" onclick="switchTab('magic')">Magic Link</div>
<input type="text" id="username" autocomplete="username" autofocus required> <div class="tab" onclick="switchTab('password')">Password</div>
</div>
<!-- Magic Link -->
<div class="panel active" id="panel-magic">
<div id="magic-form-state">
<div class="field">
<label>Email Address</label>
<input type="email" id="magic-email" autocomplete="email" autofocus placeholder="you@example.com" required>
</div>
<button class="btn" id="magic-btn" onclick="sendMagicLink()">Send Sign-In Link</button>
<p class="msg" id="magic-msg"></p>
</div> </div>
<div class="field"> <div id="magic-sent-state" style="display:none">
<label>Password</label> <div class="sent-state">
<input type="password" id="password" autocomplete="current-password" required> <div class="icon">&#9993;</div>
<h3 style="margin-bottom:.5rem">Check your email</h3>
<p>We sent a sign-in link to <strong id="sent-email"></strong>.<br>Click the link in the email to sign in.<br>The link expires in 15 minutes.</p>
<button class="btn btn-ghost" style="margin-top:1.5rem" onclick="resetMagic()">Send another</button>
</div>
</div> </div>
<button type="submit" class="btn" id="submit-btn">Sign In</button> </div>
<p class="error" id="error-msg"></p>
</form> <!-- Password -->
<div class="panel" id="panel-password">
<form id="login-form">
<div class="field">
<label>Username or Email</label>
<input type="text" id="username" autocomplete="username" required>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn" id="submit-btn">Sign In</button>
<p class="msg error" id="error-msg"></p>
</form>
</div>
</div> </div>
<script> <script>
document.getElementById('login-form').addEventListener('submit', async (e) => { function switchTab(name){
e.preventDefault(); document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
const btn = document.getElementById('submit-btn'); document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
const err = document.getElementById('error-msg'); document.querySelector(`.tab[onclick*="${name}"]`).classList.add('active');
btn.disabled = true; document.getElementById('panel-'+name).classList.add('active');
err.style.display = 'none'; }
try { // Check URL params for errors from magic link
const res = await fetch('/api/login', { const params = new URLSearchParams(window.location.search);
method: 'POST', if(params.get('error')){
headers: {'Content-Type': 'application/json'}, const msg = document.getElementById('magic-msg');
body: JSON.stringify({ msg.textContent = params.get('error') === 'expired' ? 'Link expired. Please request a new one.' : 'Invalid link. Please try again.';
username: document.getElementById('username').value, msg.className = 'msg error';
password: document.getElementById('password').value, msg.style.display = 'block';
}
async function sendMagicLink(){
const btn = document.getElementById('magic-btn');
const msg = document.getElementById('magic-msg');
const email = document.getElementById('magic-email').value.trim();
if(!email){msg.textContent='Enter your email';msg.className='msg error';msg.style.display='block';return}
btn.disabled=true;msg.style.display='none';
try{
const res = await fetch('/api/auth/magic-link',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({email}),
});
if(res.ok){
document.getElementById('magic-form-state').style.display='none';
document.getElementById('magic-sent-state').style.display='block';
document.getElementById('sent-email').textContent=email;
} else {
const data = await res.json();
msg.textContent = data.detail || 'Failed to send link';
msg.className='msg error';msg.style.display='block';
}
}catch(e){
msg.textContent='Connection error';msg.className='msg error';msg.style.display='block';
}
btn.disabled=false;
}
function resetMagic(){
document.getElementById('magic-form-state').style.display='block';
document.getElementById('magic-sent-state').style.display='none';
document.getElementById('magic-email').value='';
}
document.getElementById('login-form').addEventListener('submit', async(e)=>{
e.preventDefault();
const btn=document.getElementById('submit-btn');
const err=document.getElementById('error-msg');
btn.disabled=true;err.style.display='none';
try{
const res=await fetch('/api/login',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
username:document.getElementById('username').value,
password:document.getElementById('password').value,
}), }),
}); });
if (res.ok) { if(res.ok){window.location.href='/'}
window.location.href = '/'; else{err.textContent='Invalid username or password';err.style.display='block'}
} else { }catch(ex){err.textContent='Connection error';err.style.display='block'}
err.textContent = 'Invalid username or password'; btn.disabled=false;
err.style.display = 'block';
}
} catch (ex) {
err.textContent = 'Connection error';
err.style.display = 'block';
}
btn.disabled = false;
}); });
// Enter key on magic email field
document.getElementById('magic-email').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendMagicLink()}});
</script> </script>
</body> </body>
</html> </html>