| @@ -17,6 +17,8 @@ gem "stimulus-rails" | ||
| 17 | 17 | # Build JSON APIs with ease [https://github.com/rails/jbuilder] |
| 18 | 18 | gem "jbuilder" |
| 19 | 19 | |
| 20 | +gem "jwt" # for google signin | |
| 21 | + | |
| 20 | 22 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] |
| 21 | 23 | # gem "bcrypt", "~> 3.1.7" |
| 22 | 24 |
| @@ -122,6 +122,8 @@ GEM | ||
| 122 | 122 | actionview (>= 7.0.0) |
| 123 | 123 | activesupport (>= 7.0.0) |
| 124 | 124 | json (2.13.2) |
| 125 | + jwt (3.1.2) | |
| 126 | + base64 | |
| 125 | 127 | kamal (2.7.0) |
| 126 | 128 | activesupport (>= 7.0) |
| 127 | 129 | base64 (~> 0.2) |
| @@ -373,6 +375,7 @@ DEPENDENCIES | ||
| 373 | 375 | debug |
| 374 | 376 | importmap-rails |
| 375 | 377 | jbuilder |
| 378 | + jwt | |
| 376 | 379 | kamal |
| 377 | 380 | letter_opener |
| 378 | 381 | propshaft |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | class SessionsController < ApplicationController |
| 2 | 2 | require_authentication only: :destroy |
| 3 | 3 | rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." } |
| 4 | + skip_before_action :verify_authenticity_token, only: [:create_for_google] | |
| 4 | 5 | |
| 5 | 6 | def new |
| 6 | 7 | # Show signin form with email subscription |
| @@ -80,4 +81,33 @@ class SessionsController < ApplicationController | ||
| 80 | 81 | terminate_session |
| 81 | 82 | redirect_to root_path, notice: "You have been signed out." |
| 82 | 83 | end |
| 84 | + | |
| 85 | + def create_for_google | |
| 86 | + google_signin = GoogleSignin.new(web_credential: params[:credential]) | |
| 87 | + | |
| 88 | + if google_signin.valid? | |
| 89 | + # Find or create user by email | |
| 90 | + user = User.find_or_initialize_by(email_address: google_signin.email) | |
| 91 | + | |
| 92 | + # Update Google user ID if it's a new association | |
| 93 | + user.google_user_id = google_signin.user_id if user.google_user_id.blank? | |
| 94 | + user.first_name = google_signin.first_name if user.first_name.blank? | |
| 95 | + user.last_name = google_signin.last_name if user.last_name.blank? | |
| 96 | + user.photo_url = google_signin.photo_url if user.photo_url.blank? | |
| 97 | + user.save! | |
| 98 | + | |
| 99 | + # Sign them in | |
| 100 | + start_new_session_for(user) | |
| 101 | + | |
| 102 | + flash[:notice] = "Successfully signed in with Google!" | |
| 103 | + redirect_to root_path | |
| 104 | + else | |
| 105 | + flash[:alert] = "Could not authenticate you from Google" | |
| 106 | + redirect_to signin_path | |
| 107 | + end | |
| 108 | + rescue => e | |
| 109 | + Rails.logger.error "Google signin error: #{e.message}" | |
| 110 | + flash[:alert] = "Could not authenticate you from Google" | |
| 111 | + redirect_to signin_path | |
| 112 | + end | |
| 83 | 113 | end |
| @@ -0,0 +1,50 @@ | ||
| 1 | +require 'net/http' | |
| 2 | +require 'json' | |
| 3 | +require 'jwt' | |
| 4 | + | |
| 5 | +class GoogleIdTokenValidator | |
| 6 | + GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs' | |
| 7 | + GOOGLE_ISSUER = 'https://accounts.google.com' | |
| 8 | + | |
| 9 | + def self.validate(token:, client_id:) | |
| 10 | + # Fetch the JWT header without verification | |
| 11 | + header = JWT.decode(token, nil, false)[1] | |
| 12 | + kid = header['kid'] | |
| 13 | + | |
| 14 | + # Fetch Google's public keys | |
| 15 | + uri = URI(GOOGLE_JWKS_URI) | |
| 16 | + response = Net::HTTP.get_response(uri) | |
| 17 | + jwks = JSON.parse(response.body) | |
| 18 | + | |
| 19 | + # Find the key that matches our token's kid | |
| 20 | + public_key = nil | |
| 21 | + jwks['keys'].each do |key| | |
| 22 | + if key['kid'] == kid | |
| 23 | + public_key = JWT::JWK.import(key).public_key | |
| 24 | + break | |
| 25 | + end | |
| 26 | + end | |
| 27 | + | |
| 28 | + raise 'No matching key found' unless public_key | |
| 29 | + | |
| 30 | + # Verify the token | |
| 31 | + decoded_token = JWT.decode( | |
| 32 | + token, | |
| 33 | + public_key, | |
| 34 | + true, | |
| 35 | + { | |
| 36 | + algorithm: 'RS256', | |
| 37 | + verify_iss: true, | |
| 38 | + iss: GOOGLE_ISSUER, | |
| 39 | + verify_aud: true, | |
| 40 | + aud: client_id, | |
| 41 | + verify_iat: true | |
| 42 | + } | |
| 43 | + ) | |
| 44 | + | |
| 45 | + # Return the payload | |
| 46 | + decoded_token.first | |
| 47 | + rescue JWT::DecodeError, OpenSSL::PKey::RSAError => e | |
| 48 | + raise "Token validation failed: #{e.message}" | |
| 49 | + end | |
| 50 | +end | |
| \ No newline at end of file |
| @@ -0,0 +1,31 @@ | ||
| 1 | +class GoogleSignin | |
| 2 | + attr_reader :email, :first_name, :last_name, :photo_url, :user_id | |
| 3 | + | |
| 4 | + # Your provided client ID | |
| 5 | + WEB_CLIENT_ID = "98725179367-0nt90fh00mfb6vmpm3as1hb8453r0ki0.apps.googleusercontent.com" | |
| 6 | + | |
| 7 | + def initialize(web_credential:) | |
| 8 | + @web_credential = web_credential | |
| 9 | + @client_id = WEB_CLIENT_ID | |
| 10 | + end | |
| 11 | + | |
| 12 | + def valid? | |
| 13 | + return false if @web_credential.blank? | |
| 14 | + | |
| 15 | + validated_token = GoogleIdTokenValidator.validate( | |
| 16 | + token: @web_credential, | |
| 17 | + client_id: @client_id, | |
| 18 | + ).stringify_keys | |
| 19 | + | |
| 20 | + @email = validated_token['email'] | |
| 21 | + @first_name = validated_token['given_name'] | |
| 22 | + @last_name = validated_token['family_name'] | |
| 23 | + @photo_url = validated_token['picture'] | |
| 24 | + @user_id = validated_token['sub'] | |
| 25 | + | |
| 26 | + @email.present? | |
| 27 | + rescue => e | |
| 28 | + Rails.logger.error "Google signin validation failed: #{e.message}" | |
| 29 | + false | |
| 30 | + end | |
| 31 | +end | |
| \ No newline at end of file |
| @@ -3,6 +3,38 @@ | ||
| 3 | 3 | <div class="signin-form" style="flex: 1; padding: 2rem; background: #f8f9fa; border-radius: 8px;"> |
| 4 | 4 | <h2>Join our mailing list</h2> |
| 5 | 5 | |
| 6 | + <!-- Google Sign-In Button --> | |
| 7 | + <div style="margin-bottom: 2rem;"> | |
| 8 | + <div id="g_id_onload" | |
| 9 | + data-client_id="<%= GoogleSignin::WEB_CLIENT_ID %>" | |
| 10 | + data-context="signin" | |
| 11 | + data-ux_mode="redirect" | |
| 12 | + data-login_uri="<%= google_signin_url %>" | |
| 13 | + data-auto_prompt="false" | |
| 14 | + data-itp_support="true"> | |
| 15 | + </div> | |
| 16 | + | |
| 17 | + <div class="g_id_signin" | |
| 18 | + data-type="standard" | |
| 19 | + data-shape="rectangular" | |
| 20 | + data-theme="outline" | |
| 21 | + data-text="continue_with" | |
| 22 | + data-size="large" | |
| 23 | + data-logo_alignment="left"> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <% content_for(:head) do %> | |
| 27 | + <script src="https://accounts.google.com/gsi/client" async defer></script> | |
| 28 | + <% end %> | |
| 29 | + | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + <!-- Divider --> | |
| 33 | + <div style="text-align: center; margin: 2rem 0; position: relative;"> | |
| 34 | + <hr style="border: none; border-top: 1px solid #ddd; margin: 0;"> | |
| 35 | + <span style="background: #f8f9fa; padding: 0 1rem; color: #666; position: absolute; top: -0.5rem; left: 50%; transform: translateX(-50%);">or</span> | |
| 36 | + </div> | |
| 37 | + | |
| 6 | 38 | <%= form_with url: signin_path, local: true, class: "subscription-form" do |form| %> |
| 7 | 39 | <div style="margin-bottom: 1.5rem;"> |
| 8 | 40 | <%= form.email_field :email_address, |
| @@ -10,6 +10,7 @@ Rails.application.routes.draw do | ||
| 10 | 10 | post "signin", to: "sessions#create" |
| 11 | 11 | get "verify", to: "sessions#verify" |
| 12 | 12 | post "verify", to: "sessions#verify_code" |
| 13 | + post "google-signin", to: "sessions#create_for_google", as: :google_signin | |
| 13 | 14 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html |
| 14 | 15 | |
| 15 | 16 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. |
| @@ -0,0 +1,8 @@ | ||
| 1 | +class AddGoogleUserId < ActiveRecord::Migration[8.0] | |
| 2 | + def change | |
| 3 | + add_column :users, :google_user_id, :string | |
| 4 | + add_column :users, :first_name, :string | |
| 5 | + add_column :users, :last_name, :string | |
| 6 | + add_column :users, :photo_url, :string | |
| 7 | + end | |
| 8 | +end |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | # |
| 11 | 11 | # It's strongly recommended that you check this file into your version control system. |
| 12 | 12 | |
| 13 | -ActiveRecord::Schema[8.0].define(version: 2025_09_29_031843) do | |
| 13 | +ActiveRecord::Schema[8.0].define(version: 2025_09_29_043752) do | |
| 14 | 14 | create_table "posts", force: :cascade do |t| |
| 15 | 15 | t.string "title", null: false |
| 16 | 16 | t.string "slug", null: false |
| @@ -42,6 +42,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_29_031843) do | ||
| 42 | 42 | t.datetime "updated_at", null: false |
| 43 | 43 | t.string "verification_code" |
| 44 | 44 | t.datetime "verification_code_generated_at" |
| 45 | + t.string "google_user_id" | |
| 46 | + t.string "first_name" | |
| 47 | + t.string "last_name" | |
| 48 | + t.string "photo_url" | |
| 45 | 49 | t.index ["email_address"], name: "index_users_on_email_address", unique: true |
| 46 | 50 | end |
| 47 | 51 |