diff --git a/backend/app/config.py b/backend/app/config.py index fddf223..2668ef2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index b6b95c0..964b3a3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 07da10d..f533b4b 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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)}") diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 05c52c4..39f57c9 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -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, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 7a5b77f..8292112 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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}") diff --git a/backend/app/utils/scheduler.py b/backend/app/utils/scheduler.py index 54c5474..f358d20 100644 --- a/backend/app/utils/scheduler.py +++ b/backend/app/utils/scheduler.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 7ec55b6..f9f5b56 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 28e8cda..83bc1be 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> {/* Authenticated app — wrapped in AppLayout */} }> diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index ef6437c..bbda56a 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -710,6 +710,39 @@ export default function AdminPage() { + {/* SSO Only */} + {settings.sso_configured && ( +
+
+ SSO-Only Login ({settings.sso_provider_name}) + + Disable password login — users must sign in via SSO. Admins can still use CLI to reset passwords. + +
+ +
+ )} + {/* Embedding model change warning */} {embedModelChanged && (
diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 9085f82..9f23ef3 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -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 &&
{error}
} + {ssoError &&
SSO login failed. Please try again.
} -
-
- - setEmail(e.target.value)} required /> -
-
- - setPassword(e.target.value)} required /> -
- - - -
- Forgot password? -
-
- Don't have an account? Sign up -
+ {ssoConfig?.sso_enabled && ( + <> + + Sign in with {ssoConfig.provider_name} + + {!ssoConfig.sso_only &&
or sign in with email
} + + )} + + {!ssoConfig?.sso_only && ( + <> +
+
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+ + + +
+ Forgot password? +
+
+ Don't have an account? Sign up +
+ + )}
) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 82dcfcd..d342977 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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 ( +
+
+
+ Quiz Reminders + + Receive email reminders to review quizzes based on your performance. + +
+ +
+
+ ) +} + function AppearanceSection() { const { theme, setTheme } = useTheme() const themes = [ @@ -275,6 +318,7 @@ export default function SettingsPage() {

Settings

+ {isModerator && } {isModerator && } diff --git a/frontend/src/pages/SsoCallbackPage.jsx b/frontend/src/pages/SsoCallbackPage.jsx new file mode 100644 index 0000000..273d08b --- /dev/null +++ b/frontend/src/pages/SsoCallbackPage.jsx @@ -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 ( +
Signing in...
+ ) +}