Add OIDC/SSO login, reminder fixes, user notification settings

- Add generic OIDC/SSO support (configurable via env vars)
- Admin can enable SSO-only mode (disables password login)
- SSO callback auto-creates and verifies users
- Login page shows SSO button when configured, hides password form in SSO-only mode
- Fix reminders: skip course quizzes and deleted quizzes
- Don't create reminders for course quiz attempts
- Add user reminder opt-out toggle in Settings > Notifications
- Scheduler checks user opt-out before sending emails

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-10 04:10:15 +02:00
parent 695392f022
commit 699cbabfcb
12 changed files with 298 additions and 27 deletions

View file

@ -44,5 +44,12 @@ class Settings(BaseSettings):
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret
# OIDC / SSO — leave blank to disable
OIDC_PROVIDER_URL: str = "" # e.g. https://accounts.google.com, https://login.microsoftonline.com/{tenant}/v2.0
OIDC_CLIENT_ID: str = ""
OIDC_CLIENT_SECRET: str = ""
OIDC_SCOPES: str = "openid email profile" # space-separated
OIDC_PROVIDER_NAME: str = "SSO" # Display name on login button
settings = Settings()

View file

@ -505,6 +505,10 @@ app.add_middleware(
expose_headers=["X-New-Token"], # Allow frontend to read this header
)
# Session middleware for OIDC state (authlib needs it)
from starlette.middleware.sessions import SessionMiddleware
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
from app.utils.auth import TokenRefreshMiddleware
app.add_middleware(TokenRefreshMiddleware)

View file

@ -401,16 +401,23 @@ def get_settings(admin: User = Depends(require_admin)):
registration_enabled = r.get("settings:registration_enabled")
embedding_model = r.get("settings:embedding_model")
polly_enabled = r.get("settings:polly_enabled")
sso_only = r.get("settings:sso_only")
return {
"registration_enabled": registration_enabled != "false",
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": polly_enabled != "false",
"sso_only": sso_only == "true",
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
}
except Exception:
return {
"registration_enabled": True,
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": True,
"sso_only": False,
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
}
@ -435,6 +442,10 @@ def update_settings(
value = "true" if settings_data["polly_enabled"] else "false"
r.set("settings:polly_enabled", value)
if "sso_only" in settings_data:
value = "true" if settings_data["sso_only"] else "false"
r.set("settings:sso_only", value)
return {"success": True, "message": "Settings updated"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}")

View file

@ -167,12 +167,13 @@ def submit_attempt(
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
# Update reminder schedule
try:
from app.services.reminder_service import update_reminder_schedule
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
except Exception:
pass # Don't fail submission if reminder update fails
# Update reminder schedule (skip course quizzes)
if not is_course_quiz:
try:
from app.services.reminder_service import update_reminder_schedule
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
except Exception:
pass # Don't fail submission if reminder update fails
return AttemptDetail(
id=attempt.id,

View file

@ -145,6 +145,11 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
@router.post("/login", response_model=Token)
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
# Block password login if SSO-only mode
sso_settings = _get_sso_settings()
if sso_settings["sso_only"]:
raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.")
# Verify Turnstile if configured
from app.config import settings as cfg
if cfg.TURNSTILE_SECRET_KEY:
@ -314,3 +319,111 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
"id": current_user.id, "email": current_user.email,
"name": current_user.name, "role": current_user.role,
}
# ── SSO / OIDC ─────────────────────────────────────────────────────
def _get_sso_settings():
"""Return SSO admin settings from Redis."""
try:
import redis as redis_lib
from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
return {
"sso_only": r.get("settings:sso_only") == "true",
}
except Exception:
return {"sso_only": False}
@router.get("/sso/config")
def sso_config():
"""Public endpoint — tells frontend whether SSO is available and login mode."""
from app.config import settings as cfg
sso_enabled = bool(cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID)
sso_settings = _get_sso_settings()
return {
"sso_enabled": sso_enabled,
"sso_only": sso_settings["sso_only"],
"provider_name": cfg.OIDC_PROVIDER_NAME if sso_enabled else None,
}
@router.get("/sso/login")
def sso_login(request: Request):
"""Redirect user to the OIDC provider for login."""
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from app.config import settings as cfg
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
raise HTTPException(status_code=400, detail="SSO is not configured")
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
client_id=cfg.OIDC_CLIENT_ID,
client_secret=cfg.OIDC_CLIENT_SECRET,
client_kwargs={"scope": cfg.OIDC_SCOPES},
)
redirect_uri = f"{cfg.APP_URL}/api/auth/sso/callback"
return oauth.oidc.authorize_redirect(request, redirect_uri)
@router.get("/sso/callback")
async def sso_callback(request: Request, db: Session = Depends(get_db)):
"""Handle OIDC provider callback — create or login user."""
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from app.config import settings as cfg
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
raise HTTPException(status_code=400, detail="SSO is not configured")
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
client_id=cfg.OIDC_CLIENT_ID,
client_secret=cfg.OIDC_CLIENT_SECRET,
client_kwargs={"scope": cfg.OIDC_SCOPES},
)
try:
token = await oauth.oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed")
userinfo = token.get("userinfo") or {}
email = userinfo.get("email", "").lower().strip()
name = userinfo.get("name") or userinfo.get("preferred_username") or email.split("@")[0]
if not email:
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=no_email")
# Find or create user
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(
email=email,
hashed_password=get_password_hash(secrets.token_urlsafe(32)), # random password — SSO users don't use it
name=name,
role="user",
)
db.add(user)
db.flush()
# Auto-verify SSO users
verification = EmailVerification(
user_id=user.id,
token=secrets.token_urlsafe(32),
expires_at=datetime.utcnow() + timedelta(hours=1),
verified_at=datetime.utcnow(),
)
db.add(verification)
db.commit()
db.refresh(user)
access_token = create_access_token(data={"sub": user.email})
return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?token={access_token}")

View file

@ -32,10 +32,21 @@ def check_and_send_reminders():
user = db.query(User).filter(User.id == reminder.user_id).first()
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
if not user or not quiz:
if not user or not quiz or quiz.deleted_at or quiz.course_id:
reminder.is_active = False
continue
# Check if user has opted out of reminders
try:
import redis as redis_lib, json as _json
from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
user_settings = _json.loads(r.get(f"user_settings:{user.id}") or "{}")
if user_settings.get("reminders_disabled"):
continue
except Exception:
pass
# Send email asynchronously
try:
from app.services.email_service import send_reminder_email

View file

@ -21,3 +21,5 @@ httpx==0.27.0
boto3==1.34.69
pgvector==0.3.6
openpyxl==3.1.2
authlib==1.3.0
itsdangerous==2.1.2

View file

@ -27,6 +27,7 @@ import FlashcardStudyPage from './pages/FlashcardStudyPage'
import CoursesPage from './pages/CoursesPage'
import CourseDetailPage from './pages/CourseDetailPage'
import CourseEditorPage from './pages/CourseEditorPage'
import SsoCallbackPage from './pages/SsoCallbackPage'
// Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() {
@ -65,6 +66,7 @@ function AppRoutes() {
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} />
{/* Authenticated app — wrapped in AppLayout */}
<Route element={<RequireAuth />}>

View file

@ -710,6 +710,39 @@ export default function AdminPage() {
</label>
</div>
{/* SSO Only */}
{settings.sso_configured && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<strong style={{ display: 'block', marginBottom: 4 }}>SSO-Only Login ({settings.sso_provider_name})</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Disable password login users must sign in via SSO. Admins can still use CLI to reset passwords.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={settings.sso_only || false}
onChange={async (e) => {
const enabled = e.target.checked
setSettings(s => ({ ...s, sso_only: enabled }))
try {
await api.put('/admin/settings', { sso_only: enabled })
setSuccess(`SSO-only mode ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update setting')
setSettings(s => ({ ...s, sso_only: !enabled }))
}
}}
style={{ width: 'auto', accentColor: 'var(--primary)' }}
/>
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
{settings.sso_only ? 'Enabled' : 'Disabled'}
</span>
</label>
</div>
)}
{/* Embedding model change warning */}
{embedModelChanged && (
<div style={{ padding: '12px 16px', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, marginBottom: 0, marginTop: 8 }}>

View file

@ -38,8 +38,15 @@ export default function LoginPage() {
const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
const [ssoConfig, setSsoConfig] = useState(null)
const { login } = useAuth()
const navigate = useNavigate()
const searchParams = new URLSearchParams(window.location.search)
const ssoError = searchParams.get('error')
useEffect(() => {
api.get('/auth/sso/config').then(r => setSsoConfig(r.data)).catch(() => {})
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
@ -98,27 +105,41 @@ export default function LoginPage() {
)}
{error && <div className="alert alert-error">{error}</div>}
{ssoError && <div className="alert alert-error">SSO login failed. Please try again.</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="auth-link">
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
</div>
<div className="auth-link">
Don't have an account? <Link to="/register">Sign up</Link>
</div>
{ssoConfig?.sso_enabled && (
<>
<a href="/api/auth/sso/login" className="btn btn-secondary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}>
Sign in with {ssoConfig.provider_name}
</a>
{!ssoConfig.sso_only && <div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.82rem', margin: '12px 0' }}>or sign in with email</div>}
</>
)}
{!ssoConfig?.sso_only && (
<>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="auth-link">
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
</div>
<div className="auth-link">
Don't have an account? <Link to="/register">Sign up</Link>
</div>
</>
)}
</div>
</div>
)

View file

@ -81,6 +81,49 @@ function ProfileSection({ user }) {
)
}
function NotificationsSection() {
const [remindersDisabled, setRemindersDisabled] = useState(false)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
api.get('/auth/me/settings').then(res => {
setRemindersDisabled(!!res.data.reminders_disabled)
}).catch(() => {}).finally(() => setLoaded(true))
}, [])
const toggle = async (disabled) => {
setRemindersDisabled(disabled)
try {
const current = await api.get('/auth/me/settings')
await api.put('/auth/me/settings', { ...current.data, reminders_disabled: disabled })
} catch { setRemindersDisabled(!disabled) }
}
if (!loaded) return null
return (
<Section title="Notifications">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<strong style={{ display: 'block', marginBottom: 4, fontSize: '0.9rem' }}>Quiz Reminders</strong>
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
Receive email reminders to review quizzes based on your performance.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!remindersDisabled}
onChange={e => toggle(!e.target.checked)}
style={{ width: 'auto', accentColor: 'var(--primary)' }}
/>
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{remindersDisabled ? 'Off' : 'On'}</span>
</label>
</div>
</Section>
)
}
function AppearanceSection() {
const { theme, setTheme } = useTheme()
const themes = [
@ -275,6 +318,7 @@ export default function SettingsPage() {
<h1 style={{ fontSize: '1.4rem', fontWeight: 700 }}>Settings</h1>
</div>
<ProfileSection user={user} />
<NotificationsSection />
<AppearanceSection />
{isModerator && <NextcloudSection />}
{isModerator && <DocumentsSection />}

View file

@ -0,0 +1,22 @@
import { useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function SsoCallbackPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { loginWithToken } = useAuth()
useEffect(() => {
const token = searchParams.get('token')
if (token) {
loginWithToken(token).then(() => navigate('/')).catch(() => navigate('/login?error=sso_failed'))
} else {
navigate('/login?error=sso_failed')
}
}, [])
return (
<div className="loading"><div className="spinner"></div> Signing in...</div>
)
}