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:
parent
695392f022
commit
699cbabfcb
12 changed files with 298 additions and 27 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />}>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
22
frontend/src/pages/SsoCallbackPage.jsx
Normal file
22
frontend/src/pages/SsoCallbackPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue