diff --git a/.env.example b/.env.example index 0fe7a7ee..fdcb63b6 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ CANVAS_APP_KEY= CANVAS_CLIENT_ID= CANVAS_REDIRECT_URI='https://flextensions.eecs.cloud' CANVAS_URL='https://ucberkeleysandbox.instructure.com' +# Google OAuth Configuration +# Login with Google is only enabled when both variables are set. +# Existing users only — Google login will not create new accounts. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= ## Gradescope Configuration # We use a single username/password for Gradescope # This should be a "service account" that can be used to set course settings diff --git a/Gemfile b/Gemfile index 9f7eed60..52202e52 100644 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,7 @@ gem 'ostruct' # used to authenticate with the LMS gem 'omniauth' gem 'omniauth-canvas' +gem 'omniauth-google-oauth2' gem 'omniauth-oauth2' # Audit for potentially unsafe database migrations diff --git a/Gemfile.lock b/Gemfile.lock index 4d116cbc..c5bc4ee7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -358,6 +358,11 @@ GEM omniauth-canvas (2.0.0) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) + omniauth-google-oauth2 (1.2.2) + jwt (>= 2.9.2) + oauth2 (~> 2.0) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) omniauth-oauth2 (1.9.0) oauth2 (>= 2.0.2, < 3) omniauth (~> 2.0) @@ -644,6 +649,7 @@ DEPENDENCIES lograge omniauth omniauth-canvas + omniauth-google-oauth2 omniauth-oauth2 ostruct pg diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index eeac56a9..dcde47ab 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -46,6 +46,11 @@ def omniauth_callback return end + if auth.provider == 'google_oauth2' + handle_google_login(auth) + return + end + user_data = { 'id' => auth.uid, 'name' => auth.info.name, @@ -89,6 +94,25 @@ def destroy private + # Google OAuth only authenticates users who already have an account. + # No new accounts are created via Google login. + def handle_google_login(auth) + email = auth.info.email + user = User.find_by(email: email) if email.present? + + unless user + Rails.logger.warn("Google login rejected: no user with email #{email.inspect}") + redirect_to root_path, + alert: 'No account found for that Google email. Please log in with Canvas first.' + return + end + + session[:username] = user.name + session[:user_id] = user.canvas_uid + + redirect_to courses_path, notice: "Logged in! Welcome, #{user.name}!" + end + def ensure_developer_test_enrollments(user) # Find the test course test_course = Course.find_by(course_code: 'DEV101') diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b83449b1..b0659462 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,4 +1,8 @@ module ApplicationHelper + def google_oauth_enabled? + ENV['GOOGLE_CLIENT_ID'].present? && ENV['GOOGLE_CLIENT_SECRET'].present? + end + def assignment_link_for(assignment, course) case assignment.course_to_lms.lms_id when 1 diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index f356c5f5..186d3328 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -23,6 +23,9 @@
<%= link_to 'Login', '/auth/canvas', class: 'btn btn-primary', id: 'login-button-index' %> + <% if google_oauth_enabled? %> + <%= link_to 'Login with Google', '/auth/google_oauth2', class: 'btn btn-outline-primary ms-2', id: 'google-login-button' %> + <% end %> <% if Rails.env.development? %> <%= link_to 'Developer Login', '/auth/developer', class: 'btn btn-secondary ms-2', id: 'dev-login-button' %> <% end %> diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index bfffa61e..3aa8becd 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -28,6 +28,14 @@ def build_access_token authorize_url: "/login/oauth2/auth?scope=#{encoded_scopes}" }, redirect_uri: "#{ENV['CANVAS_REDIRECT_URI']}/auth/canvas/callback" + + if ENV['GOOGLE_CLIENT_ID'].present? && ENV['GOOGLE_CLIENT_SECRET'].present? + provider :google_oauth2, + ENV['GOOGLE_CLIENT_ID'], + ENV['GOOGLE_CLIENT_SECRET'], + scope: 'email,profile', + prompt: 'select_account' + end end # OmniAuth.config.before_request_phase do |env| diff --git a/spec/Helpers/application_helper_spec.rb b/spec/Helpers/application_helper_spec.rb new file mode 100644 index 00000000..da0fc5ae --- /dev/null +++ b/spec/Helpers/application_helper_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe ApplicationHelper, type: :helper do + describe '#google_oauth_enabled?' do + around do |example| + original_id = ENV['GOOGLE_CLIENT_ID'] + original_secret = ENV['GOOGLE_CLIENT_SECRET'] + example.run + ENV['GOOGLE_CLIENT_ID'] = original_id + ENV['GOOGLE_CLIENT_SECRET'] = original_secret + end + + it 'returns true when both Google OAuth env vars are set' do + ENV['GOOGLE_CLIENT_ID'] = 'fake-id' + ENV['GOOGLE_CLIENT_SECRET'] = 'fake-secret' + expect(helper.google_oauth_enabled?).to be(true) + end + + it 'returns false when GOOGLE_CLIENT_ID is missing' do + ENV['GOOGLE_CLIENT_ID'] = nil + ENV['GOOGLE_CLIENT_SECRET'] = 'fake-secret' + expect(helper.google_oauth_enabled?).to be(false) + end + + it 'returns false when GOOGLE_CLIENT_SECRET is missing' do + ENV['GOOGLE_CLIENT_ID'] = 'fake-id' + ENV['GOOGLE_CLIENT_SECRET'] = nil + expect(helper.google_oauth_enabled?).to be(false) + end + + it 'returns false when both env vars are blank' do + ENV['GOOGLE_CLIENT_ID'] = '' + ENV['GOOGLE_CLIENT_SECRET'] = '' + expect(helper.google_oauth_enabled?).to be(false) + end + end +end diff --git a/spec/controllers/home_controller_spec.rb b/spec/controllers/home_controller_spec.rb index 1a57eb82..e0379ea9 100644 --- a/spec/controllers/home_controller_spec.rb +++ b/spec/controllers/home_controller_spec.rb @@ -18,5 +18,32 @@ expect(response).to redirect_to(courses_path) end end + + context 'Google login button visibility' do + render_views + + around do |example| + original_id = ENV['GOOGLE_CLIENT_ID'] + original_secret = ENV['GOOGLE_CLIENT_SECRET'] + example.run + ENV['GOOGLE_CLIENT_ID'] = original_id + ENV['GOOGLE_CLIENT_SECRET'] = original_secret + end + + it 'shows the Login with Google button when Google OAuth is configured' do + ENV['GOOGLE_CLIENT_ID'] = 'fake-id' + ENV['GOOGLE_CLIENT_SECRET'] = 'fake-secret' + get :index + expect(response.body).to include('Login with Google') + expect(response.body).to include('/auth/google_oauth2') + end + + it 'hides the Login with Google button when env vars are not set' do + ENV['GOOGLE_CLIENT_ID'] = nil + ENV['GOOGLE_CLIENT_SECRET'] = nil + get :index + expect(response.body).not_to include('Login with Google') + end + end end end diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 6acae4d2..31c6b6a3 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -166,6 +166,77 @@ end end + describe 'GET #omniauth_callback (google_oauth2 provider)' do + let(:google_auth_hash) do + OmniAuth::AuthHash.new( + provider: 'google_oauth2', + uid: 'google-uid-1', + info: OpenStruct.new(name: 'Google User', email: 'existing@example.com'), + credentials: { + token: 'google-token', + refresh_token: 'google-refresh', + expires_at: 1.hour.from_now.to_i + } + ) + end + + before do + request.env['omniauth.auth'] = google_auth_hash + end + + context 'when a user with the given email already exists' do + let!(:existing_user) do + User.create!(email: 'existing@example.com', canvas_uid: 'canvas-99', name: 'Existing User') + end + + it 'logs the user in and redirects to courses' do + get :omniauth_callback, params: { provider: 'google_oauth2' } + + expect(session[:user_id]).to eq('canvas-99') + expect(session[:username]).to eq('Existing User') + expect(response).to redirect_to(courses_path) + expect(flash[:notice]).to include('Logged in!') + end + + it 'does not create a new user' do + expect do + get :omniauth_callback, params: { provider: 'google_oauth2' } + end.not_to change(User, :count) + end + + it 'does not create LMS credentials for the user' do + expect do + get :omniauth_callback, params: { provider: 'google_oauth2' } + end.not_to change { existing_user.reload.lms_credentials.count } + end + end + + context 'when no user with the given email exists' do + it 'does not create a user and redirects to root with an alert' do + expect do + get :omniauth_callback, params: { provider: 'google_oauth2' } + end.not_to change(User, :count) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('No account found') + expect(session[:user_id]).to be_nil + end + end + + context 'when the auth hash has no email' do + before do + google_auth_hash.info.email = nil + end + + it 'rejects the login with an alert' do + get :omniauth_callback, params: { provider: 'google_oauth2' } + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('No account found') + end + end + end + describe 'GET #logout' do before do session[:user_id] = 'test_user_id'