Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/add firebase authentication #3

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions lib/generators/auth/auth_generator.rb
Original file line number Diff line number Diff line change
@@ -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
<section id='auth-container' data-controller='sign-in' class='w-full content-center'>
<%= form_with url: session_path, scope: :session, data: { sign_in_target: 'sessionForm' } do |f| %>
<%= f.hidden_field :token, data: { sign_in_target: 'token' } %>
<% end %>
</section>
FILE

create_file 'app/views/home/show.html.erb',

<<-FILE
<div>
<h1 class="font-bold text-4xl">Hello<%= ' ' + current_user.email if user_signed_in? %>!</h1>
<p class=" text-blue-600">
<% unless user_signed_in? %><%= link_to 'Sign in?', new_session_path %>
<% else %><%= link_to 'Sign out?', session_path, data: { 'turbo-method': :delete } %>
<% end %>
</p>
</div>
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:[email protected]/dist/esm.js'
pin '@firebase/app', to: 'https://ga.jspm.io/npm:@firebase/[email protected]/dist/esm/index.esm2017.js'
pin '@firebase/app-compat', to: 'https://ga.jspm.io/npm:@firebase/[email protected]/dist/esm/index.esm2017.js'
pin '@firebase/auth-compat', to: 'https://ga.jspm.io/npm:@firebase/[email protected]/dist/index.esm2017.js'
pin '@firebase/auth/internal', to: 'https://ga.jspm.io/npm:@firebase/[email protected]/dist/esm2017/internal.js'
pin '@firebase/component', to: 'https://ga.jspm.io/npm:@firebase/[email protected]/dist/esm/index.esm2017.js'
pin '@firebase/logger', to: 'https://ga.jspm.io/npm:@firebase/[email protected]/dist/esm/index.esm2017.js'
pin '@firebase/util', to: 'https://ga.jspm.io/npm:@firebase/[email protected]/dist/index.esm2017.js'
pin 'dialog-polyfill', to: 'https://ga.jspm.io/npm:[email protected]/dialog-polyfill.js'
pin 'firebase/compat/app', to: 'https://ga.jspm.io/npm:[email protected]/compat/app/dist/index.esm.js'
pin 'firebase/compat/auth', to: 'https://ga.jspm.io/npm:[email protected]/compat/auth/dist/index.esm.js'
pin 'idb', to: 'https://ga.jspm.io/npm:[email protected]/build/index.js'
pin 'material-design-lite/src/button/button', to: 'https://ga.jspm.io/npm:[email protected]/src/button/button.js'
pin 'material-design-lite/src/mdlComponentHandler', to: 'https://ga.jspm.io/npm:[email protected]/src/mdlComponentHandler.js'
pin 'material-design-lite/src/progress/progress', to: 'https://ga.jspm.io/npm:[email protected]/src/progress/progress.js'
pin 'material-design-lite/src/spinner/spinner', to: 'https://ga.jspm.io/npm:[email protected]/src/spinner/spinner.js'
pin 'material-design-lite/src/textfield/textfield', to: 'https://ga.jspm.io/npm:[email protected]/src/textfield/textfield.js'
pin 'tslib', to: 'https://ga.jspm.io/npm:[email protected]/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
Original file line number Diff line number Diff line change
@@ -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

9 changes: 9 additions & 0 deletions lib/generators/auth/templates/controllers/home_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

#= HomeController
#
# Handles the home or start page actions.
class HomeController < ApplicationController
def show; end
end

43 changes: 43 additions & 0 deletions lib/generators/auth/templates/controllers/session_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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/[email protected]'
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module IdentityPlatform
#= IdentityPlatform::Error
class Error < StandardError; end
end
Loading