From 8f6d40063567f0e228087cf4dd67031d8f4a3da7 Mon Sep 17 00:00:00 2001 From: Juan Pablo Gil Date: Wed, 26 Oct 2022 04:45:56 +0000 Subject: [PATCH 1/5] Add rails controllers to templates --- .../controllers/application_controller.rb | 30 +++++++++++++ .../templates/controllers/home_controller.rb | 9 ++++ .../controllers/session_controller.rb | 43 +++++++++++++++++++ .../controllers/unauthorized_controller.rb | 32 ++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 lib/generators/auth/templates/controllers/application_controller.rb create mode 100644 lib/generators/auth/templates/controllers/home_controller.rb create mode 100644 lib/generators/auth/templates/controllers/session_controller.rb create mode 100644 lib/generators/auth/templates/controllers/unauthorized_controller.rb diff --git a/lib/generators/auth/templates/controllers/application_controller.rb b/lib/generators/auth/templates/controllers/application_controller.rb new file mode 100644 index 0000000..5b0e8ea --- /dev/null +++ b/lib/generators/auth/templates/controllers/application_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +#= Application Controller +# +# Base controller for all other controllers. +class ApplicationController < ActionController::Base + helper_method :current_user, :user_signed_in? + + def warden + request.env['warden'] + end + + def user_signed_in?(...) + warden.authenticated?(...) + end + + def authenticate_user!(...) + session[:after_sign_in_path] = request.path unless user_signed_in?(...) + warden.authenticate!(...) + end + + def after_sign_in_path + session.delete(:after_sign_in_path) || root_path + end + + def current_user(...) + warden.user(...) + end + end + \ No newline at end of file diff --git a/lib/generators/auth/templates/controllers/home_controller.rb b/lib/generators/auth/templates/controllers/home_controller.rb new file mode 100644 index 0000000..a958653 --- /dev/null +++ b/lib/generators/auth/templates/controllers/home_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +#= HomeController +# +# Handles the home or start page actions. +class HomeController < ApplicationController + def show; end +end + \ No newline at end of file diff --git a/lib/generators/auth/templates/controllers/session_controller.rb b/lib/generators/auth/templates/controllers/session_controller.rb new file mode 100644 index 0000000..56b4194 --- /dev/null +++ b/lib/generators/auth/templates/controllers/session_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#= SessionController +# +# Handles the user session actions. +class SessionController < ApplicationController + def new; end + + def create + token = IdentityPlatform::Token.load session_params[:token] + if token.valid? && sign_in_token_user(token) + redirect_to session.fetch :after_sign_in_path, root_path + else + render :new, status: :unprocessable_entity + end + end + + def destroy + sign_out_user + redirect_to new_session_path + end + + private + + def session_params + params.require(:session).permit :token + end + + def sign_in_token_user(token, scope: :default) + user = User.from_identity_token token + warden.set_user(user, scope: scope) + end + + def sign_out_user(scope: nil) + if scope + warden.logout(scope) + warden.clear_strategies_cache!(scope: scope) + else + warden.logout + warden.clear_strategies_cache! + end + end +end diff --git a/lib/generators/auth/templates/controllers/unauthorized_controller.rb b/lib/generators/auth/templates/controllers/unauthorized_controller.rb new file mode 100644 index 0000000..5a1287a --- /dev/null +++ b/lib/generators/auth/templates/controllers/unauthorized_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# UnauthorizedController +# +# The controller configured to be used by Warden to deal whenever the +# authentication fails - either by harshly stopping with an HTTP 401 Unauthorized +# status, or redirecting to the sign-in page. +class UnauthorizedController < ActionController::Metal + include ActionController::Head + include ActionController::Redirecting + include Rails.application.routes.url_helpers + + cattr_accessor :navigational_formats, default: ['*/*', :html] + + def self.call(env) + @respond ||= action(:respond) + @respond.call(env) + end + + def respond + return head :unauthorized unless navigational_format? + + redirect_to sign_in_path, alert: 'You need to sign in before continuing.' + end + + private + + def navigational_format? + request.format.try(:ref).in? navigational_formats + end + end + \ No newline at end of file From 8870812aa6daf2290717a276a34aad84f1650462 Mon Sep 17 00:00:00 2001 From: Juan Pablo Gil Date: Wed, 26 Oct 2022 04:46:28 +0000 Subject: [PATCH 2/5] Add user modelo simulation --- lib/generators/auth/templates/models/user.rb | 16 ++++++++++++++++ .../auth/templates/models/user_migration.rb | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 lib/generators/auth/templates/models/user.rb create mode 100644 lib/generators/auth/templates/models/user_migration.rb diff --git a/lib/generators/auth/templates/models/user.rb b/lib/generators/auth/templates/models/user.rb new file mode 100644 index 0000000..df6800a --- /dev/null +++ b/lib/generators/auth/templates/models/user.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +#= User +# +# A person who has an account on the site (via Google Cloud Identity Platform). +class User < ApplicationRecord + validates :identity_platform_id, presence: true, uniqueness: true + + def self.from_identity_token(token) + return unless token.valid? + + find_or_initialize_by(identity_platform_id: token.subject).tap do |user| + user.update! token.payload.slice(:name, :email) + end + end +end diff --git a/lib/generators/auth/templates/models/user_migration.rb b/lib/generators/auth/templates/models/user_migration.rb new file mode 100644 index 0000000..083acef --- /dev/null +++ b/lib/generators/auth/templates/models/user_migration.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateUsers < ActiveRecord::Migration[7.0] + def change + create_table :users do |t| + t.string :email + t.string :name + t.string :identity_platform_id, null: false + + t.timestamps + + t.index %i[identity_platform_id], name: :UK_user_identity_platform, unique: true + end + end +end + \ No newline at end of file From 1d9de267078a57f7f26332daf9ebc6c0e8a69a67 Mon Sep 17 00:00:00 2001 From: Juan Pablo Gil Date: Wed, 26 Oct 2022 04:47:08 +0000 Subject: [PATCH 3/5] Add idenity plataform model --- .../models/identity_plataform/cert_store.rb | 56 +++++++++ .../models/identity_plataform/error.rb | 6 + .../models/identity_plataform/token.rb | 116 ++++++++++++++++++ .../identity_plataform/warden_strategy.rb | 45 +++++++ 4 files changed, 223 insertions(+) create mode 100644 lib/generators/auth/templates/models/identity_plataform/cert_store.rb create mode 100644 lib/generators/auth/templates/models/identity_plataform/error.rb create mode 100644 lib/generators/auth/templates/models/identity_plataform/token.rb create mode 100644 lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb diff --git a/lib/generators/auth/templates/models/identity_plataform/cert_store.rb b/lib/generators/auth/templates/models/identity_plataform/cert_store.rb new file mode 100644 index 0000000..7f47dbb --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/cert_store.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'net/http' + +module IdentityPlatform + #= DecodeIdentityToken::CertStore + # + # This class is used by the DecodeIdentityToken service to retrieve and store + # the certificates used to properly decode tokens issued by Google Cloud + # Identity Platform + class CertStore + extend MonitorMixin + + CERTS_PATH = '/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com' + CERTS_EXPIRY = 3600 + + mattr_reader :certs_last_refresh + + def self.client + @client ||= Faraday.new('https://www.googleapis.com') do |f| + f.response :json # decode response bodies as JSON + f.adapter :net_http + end + end + + def self.certs_cache_expired? + return true unless certs_last_refresh + + Time.current > certs_last_refresh + CERTS_EXPIRY + end + + def self.certs + refresh_certs if certs_cache_expired? + @@certs + end + + def self.fetch_certs + client.get(CERTS_PATH).tap do |response| + raise Error, 'Failed to fetch certs' unless response.success? + end + end + + def self.refresh_certs + synchronize do + return unless (res = fetch_certs) + + new_certs = res.body.transform_values do |cert_string| + OpenSSL::X509::Certificate.new(cert_string) + end + + (@@certs ||= {}).merge! new_certs + @@certs_last_refresh = Time.current + end + end + end +end diff --git a/lib/generators/auth/templates/models/identity_plataform/error.rb b/lib/generators/auth/templates/models/identity_plataform/error.rb new file mode 100644 index 0000000..7435508 --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module IdentityPlatform + #= IdentityPlatform::Error + class Error < StandardError; end +end diff --git a/lib/generators/auth/templates/models/identity_plataform/token.rb b/lib/generators/auth/templates/models/identity_plataform/token.rb new file mode 100644 index 0000000..49b1940 --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/token.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module IdentityPlatform + #= IdentityPlatform::Token + # + # The tokens we obtain when authenticating users through Google Cloud Identity + # Platform + class Token + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations::Callbacks + + ISSUER_PREFIX = 'https://securetoken.google.com/' + + PAYLOAD_KEY_MAP = { + 'iss' => 'issuer', + 'sub' => 'subject', + 'aud' => 'audience', + 'iat' => 'issued_at', + 'exp' => 'expires_at', + 'auth_time' => 'authenticated_at' + }.freeze + + PAYLOAD_MAPPER = proc { |key| PAYLOAD_KEY_MAP.fetch key, key } + + # Transient attributes: + attr_accessor :token, :payload, :header + + attribute :issuer, type: :string + attribute :subject, type: :string + attribute :audience, type: :string + attribute :issued_at, type: :datetime + attribute :expires_at, type: :datetime + attribute :authenticated_at, type: :datetime + attribute :created_at, type: :datetime + + before_validation :extract_token_payload + + def self.load(given_token) + new(token: given_token) + end + + def self.decode_token_with_cert(token, key, cert) + public_key = cert.public_key + + JWT.decode( + token, + public_key, + !public_key.nil?, + decoding_options.merge(kid: key) + ) + end + + def self.expected_audience + ENV.fetch 'GOOGLE_CLOUD_PROJECT', 'fir-rails-f5432' + end + + def self.expected_issuer + "#{ISSUER_PREFIX}#{expected_audience}" + end + + def self.decoding_options + { + algorithm: 'RS256', + iss: expected_issuer, + aud: expected_audience, + verify_aud: true, + verify_iss: true + } + end + + delegate :certs, to: CertStore + delegate :decode_token_with_cert, to: :class + + private + + def extract_token_payload + decode_token_with_certs + return errors.add(:token, 'invalid token') if payload.blank? + + assign_attributes string_attributes_from_payload + assign_attributes timestamp_attributes_from_payload + end + + def string_attributes_from_payload + payload.slice(*%w[iss sub aud]).transform_keys(&PAYLOAD_MAPPER) + end + + def timestamp_attributes_from_payload + payload + .slice(*%w[iat exp auth_time]) + .transform_keys(&PAYLOAD_MAPPER) + .transform_values { |value| Time.at(value) } + end + + def decode_token_with_certs + certs.detect do |key, cert| + assign_payload_and_header_with_key_and_cert(key, cert) + break if payload.present? || errors.any? + end + end + + def assign_payload_and_header_with_key_and_cert(key, cert) + return if payload.present? + + @payload, @header = decode_token_with_cert(token, key, cert) + @payload = @payload&.with_indifferent_access + rescue JWT::ExpiredSignature + errors.add :token, 'signature expired' + rescue JWT::InvalidIssuerError + errors.add :token, 'invalid issuer' + rescue JWT::DecodeError + nil + end + end +end diff --git a/lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb b/lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb new file mode 100644 index 0000000..060acae --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module IdentityPlatform + #= IdentityPlatform::WardenStrategy + # + # A warden strategy to authenticate users with a token from Identity Platform + class WardenStrategy < Warden::Strategies::Base + def valid? + !token_string.nil? + end + + def authenticate! + fail! 'invalid_token' and return unless token&.valid? + + success! User.from_identity_token(token) + end + + def store? + false + end + + private + + def token + @token ||= IdentityPlatform::Token.load(token_string) if valid? + end + + def token_string + token_string_from_header || token_string_from_request_params + end + + def token_string_from_header + Rack::Auth::AbstractRequest::AUTHORIZATION_KEYS.each do |key| + if env.key?(key) && (token_string = env[key][/^Bearer (.*)/, 1]) + return token_string + end + end + nil + end + + def token_string_from_request_params + params['access_token'] + end + end +end From df69c68a47527b3ca3e45cf6ce851591184b89ba Mon Sep 17 00:00:00 2001 From: Juan Pablo Gil Date: Wed, 26 Oct 2022 04:48:25 +0000 Subject: [PATCH 4/5] Construct generator Install --- lib/generators/auth/auth_generator.rb | 158 ++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 lib/generators/auth/auth_generator.rb diff --git a/lib/generators/auth/auth_generator.rb b/lib/generators/auth/auth_generator.rb new file mode 100644 index 0000000..e482426 --- /dev/null +++ b/lib/generators/auth/auth_generator.rb @@ -0,0 +1,158 @@ +class AuthGenerator < Rails::Generators::NamedBase + source_root File.expand_path('../../../', __dir__) + + def create_views + if class_name == 'Install' + # ---------- Gems -------------- + + gem 'faraday', '~> 1.10' + gem 'jwt', '~> 2.5' + gem 'warden', '~> 1.2', '>= 1.2.9' + + rake 'bundle' + # ---------- Controllers -------------- + + template 'lib/generators/auth/templates/controllers/home_controller.rb', + File.join('app/controllers', class_path, 'home_controller.rb') + + template 'lib/generators/auth/templates/controllers/session_controller.rb', + File.join('app/controllers', class_path, 'session_controller.rb') + + template 'lib/generators/auth/templates/controllers/unauthorized_controller.rb', + File.join('app/controllers', class_path, 'unauthorized_controller.rb') + + inject_into_file 'app/controllers/application_controller.rb', after: "class ApplicationController < ActionController::Base\n" do + <<-'RUBY' + helper_method :current_user, :user_signed_in? + + def warden + request.env['warden'] + end + + def user_signed_in?(...) + warden.authenticated?(...) + end + + def authenticate_user!(...) + session[:after_sign_in_path] = request.path unless user_signed_in?(...) + warden.authenticate!(...) + end + + def after_sign_in_path + session.delete(:after_sign_in_path) || root_path + end + + def current_user(...) + warden.user(...) + end + RUBY + end + + # ---------- Javascript -------------- + + template 'lib/generators/auth/templates/javascript/controllers/sign_in_controller.js', + File.join('app/javascript/controllers', class_path, 'sign_in_controller.js') + + # ---------- Models -------------- + + template 'lib/generators/auth/templates/models/identity_plataform/cert_store.rb', + File.join('app/models/identity_platform', class_path, 'cert_store.rb') + + template 'lib/generators/auth/templates/models/identity_plataform/error.rb', + File.join('app/models/identity_platform', class_path, 'error.rb') + + template 'lib/generators/auth/templates/models/identity_plataform/token.rb', + File.join('app/models/identity_platform', class_path, 'token.rb') + + template 'lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb', + File.join('app/models/identity_platform', class_path, 'warden_strategy.rb') + + template 'lib/generators/auth/templates/models/user_migration.rb', + File.join('db/migrate', class_path, "#{Time.now.strftime('%Y%m%d%H%M%S')}_create_users.rb") + + template 'lib/generators/auth/templates/models/user.rb', + File.join('app/models', class_path, 'user.rb') + + + # ---------- Views -------------- + + create_file 'app/views/session/new.html.erb', + + <<-FILE +
+ <%= form_with url: session_path, scope: :session, data: { sign_in_target: 'sessionForm' } do |f| %> + <%= f.hidden_field :token, data: { sign_in_target: 'token' } %> + <% end %> +
+ FILE + + create_file 'app/views/home/show.html.erb', + + <<-FILE +
+

Hello<%= ' ' + current_user.email if user_signed_in? %>!

+

+ <% unless user_signed_in? %><%= link_to 'Sign in?', new_session_path %> + <% else %><%= link_to 'Sign out?', session_path, data: { 'turbo-method': :delete } %> + <% end %> +

+
+ FILE + + # ---------- Routes -------------- + + route "root 'home#show'" + route "get 'sign-in', to: 'session#new', as: :new_session" + route "resource :session, only: %i[create destroy], controller: :session" + + # ---------- Importmap -------------- + + inject_into_file 'config/importmap.rb', after: "pin_all_from \"app/javascript/controllers\", under: \"controllers\"\n" do + <<-'RUBY' +pin 'firebaseui', to: 'https://ga.jspm.io/npm:firebaseui@6.0.1/dist/esm.js' +pin '@firebase/app', to: 'https://ga.jspm.io/npm:@firebase/app@0.7.24/dist/esm/index.esm2017.js' +pin '@firebase/app-compat', to: 'https://ga.jspm.io/npm:@firebase/app-compat@0.1.25/dist/esm/index.esm2017.js' +pin '@firebase/auth-compat', to: 'https://ga.jspm.io/npm:@firebase/auth-compat@0.2.14/dist/index.esm2017.js' +pin '@firebase/auth/internal', to: 'https://ga.jspm.io/npm:@firebase/auth@0.20.1/dist/esm2017/internal.js' +pin '@firebase/component', to: 'https://ga.jspm.io/npm:@firebase/component@0.5.14/dist/esm/index.esm2017.js' +pin '@firebase/logger', to: 'https://ga.jspm.io/npm:@firebase/logger@0.3.2/dist/esm/index.esm2017.js' +pin '@firebase/util', to: 'https://ga.jspm.io/npm:@firebase/util@1.6.0/dist/index.esm2017.js' +pin 'dialog-polyfill', to: 'https://ga.jspm.io/npm:dialog-polyfill@0.4.10/dialog-polyfill.js' +pin 'firebase/compat/app', to: 'https://ga.jspm.io/npm:firebase@9.8.1/compat/app/dist/index.esm.js' +pin 'firebase/compat/auth', to: 'https://ga.jspm.io/npm:firebase@9.8.1/compat/auth/dist/index.esm.js' +pin 'idb', to: 'https://ga.jspm.io/npm:idb@7.0.1/build/index.js' +pin 'material-design-lite/src/button/button', to: 'https://ga.jspm.io/npm:material-design-lite@1.3.0/src/button/button.js' +pin 'material-design-lite/src/mdlComponentHandler', to: 'https://ga.jspm.io/npm:material-design-lite@1.3.0/src/mdlComponentHandler.js' +pin 'material-design-lite/src/progress/progress', to: 'https://ga.jspm.io/npm:material-design-lite@1.3.0/src/progress/progress.js' +pin 'material-design-lite/src/spinner/spinner', to: 'https://ga.jspm.io/npm:material-design-lite@1.3.0/src/spinner/spinner.js' +pin 'material-design-lite/src/textfield/textfield', to: 'https://ga.jspm.io/npm:material-design-lite@1.3.0/src/textfield/textfield.js' +pin 'tslib', to: 'https://ga.jspm.io/npm:tslib@2.4.0/tslib.es6.js' + RUBY + end + + # ---------- Rake -------------- + + rake 'db:migrate' + + # ---------- Initializers -------------- + initializer "warden.rb" do + %{ +# frozen_string_literal: true + +Rails.application.reloader.to_prepare do + Warden::Strategies.add :identity_token, IdentityPlatform::WardenStrategy +end + +Rails.application.config.middleware.use Warden::Manager do |manager| + manager.default_strategies :identity_token + manager.failure_app = UnauthorizedController + + manager.serialize_into_session(&:id) + manager.serialize_from_session { |id| User.find_by id: } +end + } + end + # ---------- END -------------- + end + end +end \ No newline at end of file From cedb73f377862d0bc9e730744f66ed2b9ee67873 Mon Sep 17 00:00:00 2001 From: Juan Pablo Gil Date: Wed, 26 Oct 2022 06:32:24 +0000 Subject: [PATCH 5/5] Add stimulus controller --- .../controllers/sign_in_controller.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 lib/generators/auth/templates/javascript/controllers/sign_in_controller.js diff --git a/lib/generators/auth/templates/javascript/controllers/sign_in_controller.js b/lib/generators/auth/templates/javascript/controllers/sign_in_controller.js new file mode 100644 index 0000000..6971cbf --- /dev/null +++ b/lib/generators/auth/templates/javascript/controllers/sign_in_controller.js @@ -0,0 +1,43 @@ + +import { Controller } from "@hotwired/stimulus" + +// Using firebase v9 compatibility until firebaseui gets updated to work with +// firebase v9 modules: +import firebase from "@firebase/app-compat" +import "@firebase/auth-compat" +import * as firebaseui from "firebaseui" + +const signInOptions = [ + { provider: firebase.auth.EmailAuthProvider.PROVIDER_ID } +] + +// Connects to data-controller="sign-in" +export default class extends Controller { + static targets = [ "sessionForm", "token" ] + + initialize() { + firebase.initializeApp({ + apiKey: "HERE_GOES_MY_API_KEY", + authDomain: "HERE_GOES_MY_AUTH_DOMAIN", + }) + } + + connect() { + const firebaseAuth = firebase.auth() + const firebaseAuthUI = new firebaseui.auth.AuthUI(firebaseAuth) + const signInSuccessWithAuthResult = this.successCallBack.bind(this) + + firebaseAuthUI.start("#auth-container", { + signInOptions, callbacks: { signInSuccessWithAuthResult } + }) + } + + successCallBack(authResult) { + authResult.user.getIdToken(true).then(token => { + this.tokenTarget.value = token + this.sessionFormTarget.submit() + }) + + return false + } +} \ No newline at end of file