Magic link auth via Gmail SMTP + password fallback
This commit is contained in:
+88
-1
@@ -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()
|
||||||
|
|||||||
+113
-104
@@ -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,28 +36,105 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<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 id="magic-sent-state" style="display:none">
|
||||||
|
<div class="sent-state">
|
||||||
|
<div class="icon">✉</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>
|
||||||
|
|
||||||
|
<!-- Password -->
|
||||||
|
<div class="panel" id="panel-password">
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Username</label>
|
<label>Username or Email</label>
|
||||||
<input type="text" id="username" autocomplete="username" autofocus required>
|
<input type="text" id="username" autocomplete="username" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Password</label>
|
<label>Password</label>
|
||||||
<input type="password" id="password" autocomplete="current-password" required>
|
<input type="password" id="password" autocomplete="current-password" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn" id="submit-btn">Sign In</button>
|
<button type="submit" class="btn" id="submit-btn">Sign In</button>
|
||||||
<p class="error" id="error-msg"></p>
|
<p class="msg error" id="error-msg"></p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)=>{
|
document.getElementById('login-form').addEventListener('submit', async(e)=>{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn=document.getElementById('submit-btn');
|
const btn=document.getElementById('submit-btn');
|
||||||
const err=document.getElementById('error-msg');
|
const err=document.getElementById('error-msg');
|
||||||
btn.disabled = true;
|
btn.disabled=true;err.style.display='none';
|
||||||
err.style.display = 'none';
|
|
||||||
|
|
||||||
try{
|
try{
|
||||||
const res=await fetch('/api/login',{
|
const res=await fetch('/api/login',{
|
||||||
method:'POST',
|
method:'POST',
|
||||||
@@ -131,18 +144,14 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|||||||
password:document.getElementById('password').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';
|
|
||||||
err.style.display = 'block';
|
|
||||||
}
|
|
||||||
} catch (ex) {
|
|
||||||
err.textContent = 'Connection error';
|
|
||||||
err.style.display = 'block';
|
|
||||||
}
|
|
||||||
btn.disabled=false;
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user