diff --git a/.env.sample b/.env.sample index b17ce5e..9d48e69 100644 --- a/.env.sample +++ b/.env.sample @@ -21,6 +21,17 @@ CANVAS_SERVER_URL= CANVAS_CLIENT_ID= CANVAS_CLIENT_SECRET= +# Google OAuth login (optional) +# Create an OAuth 2.0 Client ID in Google Cloud Console and paste the values +# below. Leave blank to disable the Google login option. See the README for +# the exact redirect URIs and authorized JavaScript origins to configure. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# Comma-separated list of Google Workspace domains permitted to sign in +# (e.g. "berkeley.edu"). Leave blank to accept any Google account that +# matches an existing user's email. +GOOGLE_HOSTED_DOMAINS= + # gcp service account credentials # available vaGCP_SA_CRED_TYPE values: env, file # if GCP_SA_CRED_TYPE is env, put the encoded cred value in GCP_SA_CRED_VALUE diff --git a/README.md b/README.md index 10ff3cc..2d9dd19 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,21 @@ The entire app is protected behind Canvas (bCourses) authentication and authoriz - If you are an instructor or a course staff member who wants to use this app for your course, see [here](../../wiki/for-course-staff). - If you are a developer or maintainer that works with this app, see [here](../../wiki/for-developers). - If you are a student, see [here](../../wiki/for-students). + +## Authentication + +The app supports two login providers: + +- **Canvas (bCourses)** — the primary provider. Required for first-time + sign-in; this is also what populates a user's email and course + enrollments. +- **Google** — an optional secondary provider. When configured, users + who have already signed in via Canvas at least once may sign in with + the Google account that matches their Canvas email. Google sign-in + will **never** create a new user. + +See [`docs/google-oauth.md`](docs/google-oauth.md) for step-by-step +instructions on creating an OAuth client in Google Cloud Console, the +exact JavaScript origins and redirect URIs to register, and the +`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `GOOGLE_HOSTED_DOMAINS` +environment variables that enable the Google login button. diff --git a/config.py b/config.py index 3b540bb..9123519 100644 --- a/config.py +++ b/config.py @@ -33,6 +33,15 @@ def getenv(key, default: Optional[str] = None, *, optional=False): CANVAS_CLIENT_ID = getenv('CANVAS_CLIENT_ID') CANVAS_CLIENT_SECRET = getenv('CANVAS_CLIENT_SECRET') + # Google OAuth setup (used as an alternate login provider). + # When unset, the Google login button is hidden. See the wiki/README for + # details on creating an OAuth client in Google Cloud Console. + GOOGLE_CLIENT_ID = getenv('GOOGLE_CLIENT_ID', optional=True) + GOOGLE_CLIENT_SECRET = getenv('GOOGLE_CLIENT_SECRET', optional=True) + # Comma-separated list of hosted domains to accept (e.g. "berkeley.edu"). + # Leave empty to accept any Google account that matches an existing user. + GOOGLE_HOSTED_DOMAINS = getenv('GOOGLE_HOSTED_DOMAINS', '') + # Stub Setup, allow MOCK_CANVAS = getenv('MOCK_CANVAS', 'false').lower() == 'true' SEND_EMAIL = getenv('SEND_EMAIL', 'off').lower() diff --git a/docs/google-oauth.md b/docs/google-oauth.md new file mode 100644 index 0000000..b60a02a --- /dev/null +++ b/docs/google-oauth.md @@ -0,0 +1,106 @@ +# Google OAuth Login Setup + +The seating app supports Google OAuth as a **secondary** sign-in option +alongside Canvas. Google login is only used to authenticate **existing** +users — a Google sign-in will never create a new account. Users must +first sign in via Canvas at least once to establish their account, after +which they may sign in with the Google account that matches their +Canvas email. + +This makes Google OAuth useful when: + +- A user's Canvas access has lapsed (e.g. course ended) but they still + need to access historical seating data. +- Staff prefer a faster sign-in flow than the full Canvas OAuth + round-trip. + +If Google credentials are not configured (i.e. `GOOGLE_CLIENT_ID` / +`GOOGLE_CLIENT_SECRET` are blank), the Google option is hidden and the +login flow falls back to Canvas-only — exactly the same as before. + +## 1. Create the OAuth client in Google Cloud Console + +1. Open the [Google Cloud Console](https://console.cloud.google.com/) + and select (or create) the project you want to host the OAuth + client in. The seating app already uses a Google Cloud project for + the Google Sheets service account (see `GCP_SA_CRED_*` settings); + you can reuse that project or create a new one. +2. In the left nav, go to **APIs & Services → OAuth consent screen** + and configure the consent screen if you have not already: + - User type: **Internal** if your organization uses Google + Workspace and you only want users on that domain (recommended for + `berkeley.edu`). Otherwise use **External**. + - App name, support email, developer email: required fields. + - Scopes: add `openid`, `.../auth/userinfo.email`, and + `.../auth/userinfo.profile`. No other scopes are required by the + seating app. +3. In the left nav, go to **APIs & Services → Credentials** and click + **Create Credentials → OAuth client ID**. +4. Application type: **Web application**. +5. Name: anything recognizable, e.g. *Seating App – Production*. +6. **Authorized JavaScript origins**: add the root URL of every + environment that needs to sign in. Examples: + - `https://seating.example.edu` + - `http://localhost:5000` (development only) +7. **Authorized redirect URIs**: add the full callback URL for every + environment. The path is fixed at `/authorized/google/`. Examples: + - `https://seating.example.edu/authorized/google/` + - `http://localhost:5000/authorized/google/` (development only) + + The trailing slash matters — it must match the Flask route exactly. +8. Click **Create**. Google will show you a **Client ID** and **Client + secret** — copy both. (You can retrieve them again later from the + same Credentials page.) + +## 2. Configure the seating app + +Add the following variables to your environment (`.env` file in +development, your hosting platform's secret store in production). See +[`.env.sample`](../.env.sample) for the full list. + +```bash +# OAuth Client ID issued by Google Cloud Console. +GOOGLE_CLIENT_ID=1234567890-abc...apps.googleusercontent.com + +# OAuth Client secret issued by Google Cloud Console. +GOOGLE_CLIENT_SECRET=GOCSPX-... + +# Optional: restrict sign-ins to one or more Google Workspace domains. +# Comma-separated; leave blank to accept any Google account whose email +# matches a known user. Highly recommended in production. +GOOGLE_HOSTED_DOMAINS=berkeley.edu +``` + +Restart the Flask app. The login page (`/login/`) will now show a +"Sign in with Google" button next to the existing "Sign in with +Canvas" option. + +## 3. How the matching works + +When a user signs in with Google, the app: + +1. Verifies the Google token and reads the user's email and unique + Google account id (the OpenID `sub` claim). +2. Rejects unverified emails (`email_verified=false`). +3. If `GOOGLE_HOSTED_DOMAINS` is set, rejects any email whose Google + Workspace domain (`hd` claim or, as a fallback, the email's domain + suffix) is not in the allow-list. +4. Looks up an existing `User` row by Google account id first, then + falls back to a case-insensitive email match against the `email` + column populated during Canvas sign-in. +5. If no matching user is found, the sign-in is rejected with a + flashed message instructing the user to log in via Canvas first. + **No new user record is created.** +6. On the first successful match by email, the Google account id is + stored on the user so subsequent sign-ins are bound to the stable + identifier and survive email-address changes. + +## 4. Troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| The Google button does not appear on `/login/`. | `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is unset; the server was not restarted; or you are in `MOCK_CANVAS=true` mode, which redirects to the dev login screen. | +| "redirect_uri_mismatch" error from Google. | The redirect URI registered in Google Cloud Console must exactly match `${SERVER_BASE_URL}/authorized/google/` (including scheme, host, port, and trailing slash). | +| "No account is associated with this Google email." | The user has never signed in with Canvas (so the app does not know their email), or the Google email differs from what Canvas reported. Ask the user to sign in with Canvas first. | +| "Your Google domain is not permitted to sign in here." | The user's Google Workspace domain is not in `GOOGLE_HOSTED_DOMAINS`. Add it, or have them sign in with Canvas instead. | +| Domain restriction is bypassed for personal Gmail accounts. | Personal Gmail accounts do not have an `hd` claim. The fallback uses the email suffix, so `something@gmail.com` will only be allowed if `gmail.com` is explicitly listed in `GOOGLE_HOSTED_DOMAINS`. | diff --git a/server/controllers/auth_controllers.py b/server/controllers/auth_controllers.py index 3fe6779..d05678d 100644 --- a/server/controllers/auth_controllers.py +++ b/server/controllers/auth_controllers.py @@ -1,19 +1,42 @@ -from flask import redirect, request, session, url_for +from flask import flash, redirect, render_template, request, session, url_for from flask_login import login_user, logout_user, login_required import server.services.canvas as canvas_client +from server import app from server.models import db, User from server.controllers import auth_module -from server.services.auth import oauth_provider +from server.services.auth import ( + google_oauth_provider, + is_google_login_enabled, + oauth_provider, +) + + +def _canvas_authorize(): + return oauth_provider.authorize( + callback=url_for('auth.authorized', state=None, _external=True, _scheme="https")) @auth_module.route('/login/') def login(): if canvas_client.is_mock_canvas(): return redirect(url_for('dev_login.dev_login_page')) - return oauth_provider.authorize( - callback=url_for('auth.authorized', state=None, _external=True, _scheme="https")) + if is_google_login_enabled(): + # Show a chooser so users can pick between Canvas and Google. + return render_template( + 'login.html.j2', + title="Login", + google_enabled=True, + ) + return _canvas_authorize() + + +@auth_module.route('/login/canvas/') +def login_canvas(): + if canvas_client.is_mock_canvas(): + return redirect(url_for('dev_login.dev_login_page')) + return _canvas_authorize() @auth_module.route('/authorized/') @@ -29,17 +52,22 @@ def authorized(): staff_offerings = [str(c.id) for c in staff_course_dics] student_offerings = [str(c.id) for c in student_course_dics] + email = user_info.get('email') or getattr(user, 'email', None) + user_model = User.query.filter_by(canvas_id=str(user_info['id'])).one_or_none() if not user_model: user_model = User( name=user_info['name'], canvas_id=str(user_info['id']), + email=email, staff_offerings=staff_offerings, student_offerings=student_offerings) db.session.add(user_model) else: user_model.staff_offerings = staff_offerings user_model.student_offerings = student_offerings + if email: + user_model.email = email db.session.commit() login_user(user_model, remember=True) @@ -47,6 +75,97 @@ def authorized(): return redirect(after_login) +@auth_module.route('/login/google/') +def login_google(): + if not is_google_login_enabled(): + flash("Google login is not configured for this server.", "error") + return redirect(url_for('index')) + return google_oauth_provider.authorize( + callback=url_for('auth.authorized_google', _external=True, _scheme="https")) + + +def _google_profile_from_response(resp): + """Validate the token response and fetch the user's Google profile.""" + if resp is None or 'access_token' not in resp: + return None, 'Access denied: {}'.format(request.args.get('error', 'unknown error')) + session['google_access_token'] = resp['access_token'] + profile_resp = google_oauth_provider.get('userinfo') + profile = profile_resp.data if profile_resp else None + if not profile or not isinstance(profile, dict): + flash("Could not read Google profile.", "error") + return None, redirect(url_for('index')) + if not profile.get('email_verified', True): + flash("Your Google email is not verified.", "error") + return None, redirect(url_for('index')) + return profile, None + + +def _google_domain_is_allowed(profile): + allowed_domains = [ + d.strip().lower() + for d in (app.config.get('GOOGLE_HOSTED_DOMAINS') or '').split(',') + if d.strip() + ] + if not allowed_domains: + return True + email = (profile.get('email') or '').lower().strip() + hd = (profile.get('hd') or email.rsplit('@', 1)[-1]).lower() + return hd in allowed_domains + + +def _find_existing_user_for_google(google_id, email): + user_model = User.query.filter_by(google_id=google_id).one_or_none() + if not user_model: + user_model = User.query.filter(db.func.lower(User.email) == email).one_or_none() + return user_model + + +@auth_module.route('/authorized/google/') +def authorized_google(): + """Authenticate an existing user via their Google account. + + This handler intentionally does NOT create new users — Google OAuth is + only a sign-in alternative for users that already exist in the system + (typically created on first Canvas login). Unknown emails are rejected. + """ + if not is_google_login_enabled(): + flash("Google login is not configured for this server.", "error") + return redirect(url_for('index')) + + profile, early_response = _google_profile_from_response( + google_oauth_provider.authorized_response()) + if profile is None: + return early_response + + email = (profile.get('email') or '').lower().strip() + google_id = profile.get('sub') + if not email or not google_id: + flash("Google did not return an email address.", "error") + return redirect(url_for('index')) + + if not _google_domain_is_allowed(profile): + flash("Your Google domain is not permitted to sign in here.", "error") + return redirect(url_for('index')) + + user_model = _find_existing_user_for_google(google_id, email) + if not user_model: + flash( + "No account is associated with this Google email. " + "Please log in with Canvas at least once first.", + "error") + return redirect(url_for('index')) + + # Link the Google identity on first successful match so subsequent + # logins are bound by the stable Google subject id, not just email. + if not user_model.google_id: + user_model.google_id = google_id + db.session.commit() + + login_user(user_model, remember=True) + after_login = session.pop('after_login', None) or url_for('index') + return redirect(after_login) + + @auth_module.route('/logout/') @login_required def logout(): diff --git a/server/models.py b/server/models.py index 11d4937..16dc68e 100644 --- a/server/models.py +++ b/server/models.py @@ -35,6 +35,8 @@ class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(255), index=True, nullable=False) canvas_id = db.Column(db.String(255), nullable=False, index=True, unique=True) + email = db.Column(db.String(255), index=True, nullable=True) + google_id = db.Column(db.String(255), index=True, unique=True, nullable=True) staff_offerings = db.Column(StringSet, nullable=False) student_offerings = db.Column(StringSet, nullable=False) diff --git a/server/services/auth/__init__.py b/server/services/auth/__init__.py index e448687..0c6ddba 100644 --- a/server/services/auth/__init__.py +++ b/server/services/auth/__init__.py @@ -51,6 +51,35 @@ def get_access_token(token=None): return session.get('access_token') +google_client_id = app.config.get('GOOGLE_CLIENT_ID') +google_client_secret = app.config.get('GOOGLE_CLIENT_SECRET') + +google_oauth_provider = None + +if google_client_id and google_client_secret: + google_oauth_provider = oauth.remote_app( + 'google', + consumer_key=google_client_id, + consumer_secret=google_client_secret, + request_token_params={ + 'scope': 'openid email profile', + }, + base_url='https://www.googleapis.com/oauth2/v3/', + request_token_url=None, + access_token_method='POST', + access_token_url='https://oauth2.googleapis.com/token', + authorize_url='https://accounts.google.com/o/oauth2/auth', + ) + + @google_oauth_provider.tokengetter + def get_google_access_token(token=None): + return session.get('google_access_token') + + +def is_google_login_enabled() -> bool: + return google_oauth_provider is not None + + @login_manager.user_loader def load_user(user_id): from server.models import User diff --git a/server/templates/login.html.j2 b/server/templates/login.html.j2 new file mode 100644 index 0000000..effdde8 --- /dev/null +++ b/server/templates/login.html.j2 @@ -0,0 +1,32 @@ +{% extends 'base.html.j2' %} + +{% block title %}Login{% endblock %} + +{% block body %} +
+

Sign in to the Exam Seating Tool

+ +

+ Choose how you would like to sign in. Both options sign you in to the same + account — Google login is only available for users that already have an + account from a previous Canvas login. +

+ +
+ + Sign in with Canvas + + + {% if google_enabled %} + + Sign in with Google + + {% endif %} +
+
+{% endblock %} diff --git a/tests/unit/test_google_auth.py b/tests/unit/test_google_auth.py new file mode 100644 index 0000000..7ea4e32 --- /dev/null +++ b/tests/unit/test_google_auth.py @@ -0,0 +1,198 @@ +"""Tests for the Google OAuth login flow. + +The Google login flow lives alongside the Canvas login flow. The tests below +exercise the controller in /workspace/seating/server/controllers/auth_controllers.py +by patching the Google OAuth provider on `server.services.auth`. The Canvas +mock setup (MOCK_CANVAS=True) is untouched — Google login does not depend on +Canvas being live and should work the same in tests. +""" +from unittest.mock import patch, MagicMock + +import pytest + +from server import app +from server.models import User, db as sqlalchemy_db +import server.services.auth as auth_service +import server.controllers.auth_controllers as auth_controllers + + +@pytest.fixture() +def existing_user(db): + user = User( + name='Existing User', + canvas_id='999000', + email='existing@berkeley.edu', + staff_offerings=['1'], + student_offerings=['2'], + ) + db.session.add(user) + db.session.commit() + return user + + +def _patch_google_enabled(): + """Force the controller to behave as if Google login is configured.""" + fake_provider = MagicMock() + return [ + patch.object(auth_controllers, 'is_google_login_enabled', return_value=True), + patch.object(auth_controllers, 'google_oauth_provider', fake_provider), + patch.object(auth_service, 'google_oauth_provider', fake_provider), + ], fake_provider + + +def test_login_page_shows_google_button_when_enabled(client, db): + patches, _ = _patch_google_enabled() + for p in patches: + p.start() + try: + with patch('server.services.canvas.is_mock_canvas', return_value=False): + response = client.get('/login/') + assert response.status_code == 200 + body = response.data.decode('utf-8') + assert 'Sign in with Canvas' in body + assert 'Sign in with Google' in body + finally: + for p in patches: + p.stop() + + +def test_login_page_no_google_when_disabled(client, db): + with patch('server.services.canvas.is_mock_canvas', return_value=False), \ + patch.object(auth_controllers, 'is_google_login_enabled', return_value=False), \ + patch.object(auth_controllers, 'oauth_provider') as mock_oauth: + mock_oauth.authorize.return_value = ('redirect', 302) + client.get('/login/') + # Without google enabled, login() should immediately call canvas authorize. + assert mock_oauth.authorize.called + + +def test_google_login_redirects_to_google(client, db): + patches, fake_provider = _patch_google_enabled() + for p in patches: + p.start() + try: + fake_provider.authorize.return_value = ('redirect-to-google', 302) + response = client.get('/login/google/') + assert fake_provider.authorize.called + # Make sure a callback URL was provided + _, kwargs = fake_provider.authorize.call_args + assert 'callback' in kwargs + assert '/authorized/google/' in kwargs['callback'] + finally: + for p in patches: + p.stop() + + +def test_google_login_disabled_redirects_home(client, db): + with patch.object(auth_controllers, 'is_google_login_enabled', return_value=False): + response = client.get('/login/google/') + assert response.status_code == 302 + assert response.headers['Location'].endswith('/') + + +def test_google_callback_authenticates_existing_user(client, existing_user): + patches, fake_provider = _patch_google_enabled() + for p in patches: + p.start() + try: + fake_provider.authorized_response.return_value = {'access_token': 'tok'} + userinfo = MagicMock() + userinfo.data = { + 'sub': 'google-sub-1', + 'email': 'existing@berkeley.edu', + 'email_verified': True, + } + fake_provider.get.return_value = userinfo + + response = client.get('/authorized/google/') + + assert response.status_code == 302 + # User should now be linked to the google_id + with app.app_context(): + user = User.query.filter_by(canvas_id='999000').one() + assert user.google_id == 'google-sub-1' + finally: + for p in patches: + p.stop() + + +def test_google_callback_rejects_unknown_email(client, db): + patches, fake_provider = _patch_google_enabled() + for p in patches: + p.start() + try: + fake_provider.authorized_response.return_value = {'access_token': 'tok'} + userinfo = MagicMock() + userinfo.data = { + 'sub': 'google-sub-999', + 'email': 'stranger@berkeley.edu', + 'email_verified': True, + } + fake_provider.get.return_value = userinfo + + before = User.query.count() + response = client.get('/authorized/google/', follow_redirects=False) + after = User.query.count() + + # Must not create a new user. + assert before == after + assert response.status_code == 302 + finally: + for p in patches: + p.stop() + + +def test_google_callback_enforces_hosted_domain(client, existing_user): + patches, fake_provider = _patch_google_enabled() + for p in patches: + p.start() + try: + fake_provider.authorized_response.return_value = {'access_token': 'tok'} + userinfo = MagicMock() + userinfo.data = { + 'sub': 'google-sub-1', + 'email': 'existing@gmail.com', + 'email_verified': True, + 'hd': 'gmail.com', + } + fake_provider.get.return_value = userinfo + + old = app.config.get('GOOGLE_HOSTED_DOMAINS') + app.config['GOOGLE_HOSTED_DOMAINS'] = 'berkeley.edu' + try: + response = client.get('/authorized/google/', follow_redirects=False) + finally: + app.config['GOOGLE_HOSTED_DOMAINS'] = old + + assert response.status_code == 302 + # Existing user should not have been linked to a disallowed domain. + with app.app_context(): + user = User.query.filter_by(canvas_id='999000').one() + assert user.google_id is None + finally: + for p in patches: + p.stop() + + +def test_google_callback_unverified_email_rejected(client, existing_user): + patches, fake_provider = _patch_google_enabled() + for p in patches: + p.start() + try: + fake_provider.authorized_response.return_value = {'access_token': 'tok'} + userinfo = MagicMock() + userinfo.data = { + 'sub': 'google-sub-1', + 'email': 'existing@berkeley.edu', + 'email_verified': False, + } + fake_provider.get.return_value = userinfo + + response = client.get('/authorized/google/', follow_redirects=False) + assert response.status_code == 302 + with app.app_context(): + user = User.query.filter_by(canvas_id='999000').one() + assert user.google_id is None + finally: + for p in patches: + p.stop()