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 sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Optional
import hashlib
import json
import os
import secrets
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from database import get_db, init_db
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))
_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:
@@ -64,6 +76,9 @@ class LoginRequest(BaseModel):
username: str # accepts username or email
password: str
class MagicLinkRequest(BaseModel):
email: str
class InstanceCreate(BaseModel):
catalog_id: str
name: Optional[str] = None
@@ -134,6 +149,78 @@ def logout(response: Response, session: Optional[str] = Cookie(None)):
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")
def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
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">
<title>Login — Agent Command Center</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2e3345;
--text: #e4e6ed;
--text-dim: #8b8fa3;
--accent: #6c5ce7;
--accent-hover: #7c6ef0;
--red: #e17055;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
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;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
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;
}
:root {--bg:#0f1117;--surface:#1a1d27;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--red:#e17055;--green:#00b894}
*{margin:0;padding:0;box-sizing:border-box}
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}
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:2.5rem;width:100%;max-width:400px}
.login-card h1{font-size:1.3rem;font-weight:600;margin-bottom:.4rem;text-align:center}
.login-card .subtitle{color:var(--text-dim);font-size:.85rem;text-align:center;margin-bottom:2rem}
.field{margin-bottom:1.25rem}
.field label{display:block;font-size:.8rem;font-weight:500;color:var(--text-dim);margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.05em}
.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)}
.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}
.btn:hover{background:var(--accent-hover)}.btn:disabled{opacity:.6;cursor:not-allowed}
.btn-ghost{background:none;color:var(--text-dim);border:1px solid var(--border)}.btn-ghost:hover{border-color:var(--text-dim);color:var(--text)}
.msg{font-size:.85rem;text-align:center;margin-top:1rem;display:none}
.msg.error{color:var(--red)}.msg.success{color:var(--green)}
.divider{display:flex;align-items:center;gap:1rem;margin:1.5rem 0;color:var(--text-dim);font-size:.8rem}
.divider::before,.divider::after{content:'';flex:1;border-top:1px solid var(--border)}
.tab-row{display:flex;margin-bottom:1.5rem;border-bottom:1px solid var(--border)}
.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)}
.panel{display:none}.panel.active{display:block}
.sent-state{text-align:center;padding:1rem 0}
.sent-state .icon{font-size:2rem;margin-bottom:.75rem}
.sent-state p{color:var(--text-dim);font-size:.85rem;line-height:1.5}
</style>
</head>
<body>
@@ -100,49 +36,122 @@
<div class="login-card">
<h1>Agent Command Center</h1>
<p class="subtitle">Sign in to continue</p>
<form id="login-form">
<div class="field">
<label>Username</label>
<input type="text" id="username" autocomplete="username" autofocus required>
<div class="tab-row">
<div class="tab active" onclick="switchTab('magic')">Magic Link</div>
<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 class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<div id="magic-sent-state" style="display:none">
<div class="sent-state">
<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>
<button type="submit" class="btn" id="submit-btn">Sign In</button>
<p class="error" id="error-msg"></p>
</form>
</div>
<!-- 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>
<script>
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';
function switchTab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelector(`.tab[onclick*="${name}"]`).classList.add('active');
document.getElementById('panel-'+name).classList.add('active');
}
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,
// Check URL params for errors from magic link
const params = new URLSearchParams(window.location.search);
if(params.get('error')){
const msg = document.getElementById('magic-msg');
msg.textContent = params.get('error') === 'expired' ? 'Link expired. Please request a new one.' : 'Invalid link. Please try again.';
msg.className = 'msg error';
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) {
window.location.href = '/';
} else {
err.textContent = 'Invalid username or password';
err.style.display = 'block';
}
} catch (ex) {
err.textContent = 'Connection error';
err.style.display = 'block';
}
btn.disabled = false;
if(res.ok){window.location.href='/'}
else{err.textContent='Invalid username or password';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>
</body>
</html>