Skip to content

Commit

Permalink
AdminAuthentication middleware for authenticating admins
Browse files Browse the repository at this point in the history
Resolves a potential issue where a non-authenticated user could
enumerate modules by checking for 307 vs 404 responses. Ensures that
client apps get authentication correct.
  • Loading branch information
sfnelson committed Nov 21, 2024
1 parent 24b88a4 commit 31a0d9a
Show file tree
Hide file tree
Showing 18 changed files with 181 additions and 87 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
katalyst-koi (4.13.2)
katalyst-koi (4.14.0)
bcrypt
importmap-rails
katalyst-content
Expand Down
19 changes: 12 additions & 7 deletions app/controllers/admin/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ module Admin
class SessionsController < ApplicationController
include Koi::Controller::HasWebauthn

skip_before_action :authenticate_admin, only: %i[new create]
before_action :redirect_authenticated, only: %i[new], if: :admin_signed_in?
before_action :authenticate_local_admin, only: %i[new], if: :authenticate_local_admins?

layout "koi/login"

def new
return redirect_to admin_dashboard_path if admin_signed_in?

render :new, locals: { admin_user: Admin::User.new }
render locals: { admin_user: Admin::User.new }
end

def create
Expand All @@ -20,12 +19,12 @@ def create

session[:admin_user_id] = admin_user.id

redirect_to admin_dashboard_path, notice: I18n.t("koi.auth.login")
redirect_to(params[:redirect].presence || admin_dashboard_path, status: :see_other)
else
admin_user = Admin::User.new(session_params.slice(:email, :password))
admin_user.errors.add(:email, "Invalid email or password")

render :new, status: :unprocessable_content, locals: { admin_user: }
render(:new, status: :unprocessable_content, locals: { admin_user: })
end
end

Expand All @@ -34,11 +33,15 @@ def destroy

session[:admin_user_id] = nil

redirect_to admin_dashboard_path, notice: I18n.t("koi.auth.logout")
redirect_to new_admin_session_path
end

private

def redirect_authenticated
redirect_to(admin_dashboard_path, status: :see_other)
end

def session_params
params.require(:admin).permit(:email, :password, :response)
end
Expand All @@ -65,6 +68,8 @@ def record_sign_in!(admin_user)
end

def record_sign_out!(admin_user)
return unless admin_user

update_last_sign_in(admin_user)

admin_user.current_sign_in_at = nil
Expand Down
51 changes: 28 additions & 23 deletions app/controllers/admin/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,54 @@ module Admin
class TokensController < ApplicationController
include Koi::Controller::JsonWebToken

skip_before_action :authenticate_admin, only: %i[show update]
before_action :set_admin, only: %i[create]
before_action :set_token, only: %i[show update]
before_action :invalid_token, only: %i[show update], unless: :token_valid?

def show
return redirect_to new_admin_session_path, notice: I18n.t("koi.auth.token_invalid") if @token.blank?

admin = Admin::User.find(@token[:admin_id])

if token_utilised?(admin, @token)
return redirect_to new_admin_session_path, notice: I18n.t("koi.auth.token_invalid")
end

render locals: { admin:, token: params[:token] }, layout: "koi/login"
render locals: { admin: @admin, token: params[:token] }, layout: "koi/login"
end

def create
admin = Admin::User.find(params[:id])
token = encode_token(admin_id: admin.id, exp: 5.minutes.from_now.to_i, iat: Time.current.to_i)
token = encode_token(admin_id: @admin.id, exp: 30.minutes.from_now.to_i, iat: Time.current.to_i)

render locals: { token: }
end

def update
return redirect_to admin_dashboard_path, status: :see_other if admin_signed_in?

if @token.blank?
return redirect_to new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid")
end

admin = Admin::User.find(@token[:admin_id])
sign_in_admin(admin)
sign_in_admin(@admin)

redirect_to admin_admin_user_path(admin)
redirect_to admin_admin_user_path(@admin), status: :see_other, notice: t("koi.auth.token_consumed")
end

private

def set_admin
@admin = Admin::User.find(params[:admin_user_id])
end

def set_token
@token = decode_token(params[:token])

# constant time token validation requires that we always try to retrieve a user
@admin = Admin::User.find_by(id: @token&.fetch(:admin_id) || "")
end

def token_valid?
return false unless @token.present? && @admin.present?

# Ensure that the user has not signed in since the token was generated
if @admin.current_sign_in_at.present?
@admin.current_sign_in_at.to_i < @token[:iat]
elsif @admin.last_sign_in_at.present?
@admin.last_sign_in_at.to_i < @token[:iat]
else
true # first sign in
end
end

def token_utilised?(admin, token)
admin.current_sign_in_at.present? || (admin.last_sign_in_at.present? && admin.last_sign_in_at.to_i > token[:iat])
def invalid_token
redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
end

def sign_in_admin(admin)
Expand Down
11 changes: 6 additions & 5 deletions app/controllers/concerns/koi/controller/is_admin_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ def authenticate_local_admins(value)
helper :all

layout -> { turbo_frame_layout || "koi/application" }

before_action :authenticate_local_admin, if: -> { Koi::Controller::IsAdminController.authenticate_local_admins }
before_action :authenticate_admin, unless: :admin_signed_in?
end

class << self
Expand All @@ -47,10 +44,14 @@ def authenticate_local_admin

session[:admin_user_id] =
Admin::User.where(email: %W[#{ENV.fetch('USER', nil)}@katalyst.com.au [email protected]]).first&.id

flash.delete(:redirect) if (redirect = flash[:redirect])

redirect_to(redirect || admin_dashboard_path, status: :see_other)
end

def authenticate_admin
redirect_to new_admin_session_path, status: :temporary_redirect
def authenticate_local_admins?
IsAdminController.authenticate_local_admins
end

def turbo_frame_layout
Expand Down
2 changes: 1 addition & 1 deletion app/views/admin/admin_users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
method: :delete,
form: { data: { turbo_confirm: "Are you sure?" } } %>
<% end %>
<%= button_to "Generate login link", invite_admin_admin_user_path(admin), class: "button button--primary", form: { id: "invite" } %>
<%= button_to "Generate login link", admin_admin_user_tokens_path(admin), class: "button button--primary", form: { id: "invite" } %>
</div>

<h2>Authentication</h2>
Expand Down
9 changes: 6 additions & 3 deletions app/views/admin/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
webauthn_authentication_options_value: { publicKey: webauthn_auth_options },
},
) do |f| %>
<% (redirect = flash[:redirect] || params[:redirect]) && flash.delete(:redirect) %>
<% unless flash.empty? %>
<div class="govuk-error-summary">
<ul class="govuk-error-summary__list">
<% flash.each do |_, message| %>
<% flash.each do |type, message| %>
<%= tag.li message %>
<% end %>
</ul>
</div>
<% end %>
<%= f.govuk_fieldset legend: nil do %>
<%= f.govuk_email_field :email, autofocus: true, autocomplete: "email" %>
<%= f.govuk_password_field :password, autocomplete: "current-password" %>
<%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
<%= f.govuk_email_field :email, autofocus: true, autocomplete: "off" %>
<%= f.govuk_password_field :password, autocomplete: "off" %>
<%= f.hidden_field :response, data: { webauthn_authentication_target: "response" } %>
<%= hidden_field_tag(:redirect, redirect) %>
<% end %>
<div class="actions-group">
<%= f.admin_save "Log in" %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/admin/tokens/create.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<%= turbo_stream.replace "invite" do %>
<div class="action copy-to-clipboard govuk-input__wrapper" data-controller="clipboard" data-clipboard-supported-class="clipboard--supported">
<%= text_field_tag :invite_link, admin_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
<%= text_field_tag :invite_link, admin_session_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
<button class="govuk-input__suffix clipboard-button" aria-hidden="true" data-action="clipboard#copy">
Copy link
</button>
Expand Down
3 changes: 1 addition & 2 deletions app/views/admin/tokens/show.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<%= render "layouts/koi/navigation_header" %>
<%= form_with(url: accept_admin_session_path) do |form| %>
<%= tag.input name: :token, type: :hidden, value: token %>
<%= form_with(url: admin_session_token_path(token), method: :patch) do |form| %>
<p>Welcome to Koi Admin</p>
<%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
<%= builder.text :name %>
Expand Down
3 changes: 1 addition & 2 deletions config/locales/koi.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ en:
admin: "%e %B %Y"
koi:
auth:
login: "You have been logged in"
logout: "You have been logged out"
token_invalid: "Token invalid or consumed already"
token_consumed: "Please create a password or passkey"
labels:
new: New
search: Search
Expand Down
17 changes: 6 additions & 11 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,28 @@
Rails.application.routes.draw do
namespace :admin do
resource :session, only: %i[new create destroy] do
post :accept, to: "tokens#update"
# JWT tokens contain periods
resources :tokens, param: :token, only: %i[show update], token: /[^\/]+/
end

resources :url_rewrites
resources :admin_users do
resources :credentials, only: %i[new create destroy]
post :invite, on: :member, to: "tokens#create"
resources :tokens, only: %i[create]
get :archived, on: :collection
put :archive, on: :collection
put :restore, on: :collection
end

# JWT tokens have dots(represents the 3 parts of data) in them, so we need to allow them in the URL
# can by pass if we use token as a query param
get "token/:token", to: "tokens#show", as: :token, token: /[^\/]+/

resource :cache, only: %i[destroy]
resource :dashboard, only: %i[show]

root to: redirect("admin/dashboard")
end

scope :admin do
constraints ->(req) { req.session[:admin_user_id].present? } do
mount Katalyst::Content::Engine, at: "content"
mount Katalyst::Navigation::Engine, at: "navigation"
mount Flipper::UI.app(Flipper) => "flipper" if Object.const_defined?("Flipper::UI")
end
mount Katalyst::Content::Engine, at: "content"
mount Katalyst::Navigation::Engine, at: "navigation"
mount Flipper::UI.app(Flipper) => "flipper" if Object.const_defined?("Flipper::UI")
end
end
2 changes: 1 addition & 1 deletion katalyst-koi.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Describe your gem and declare its dependencies:
Gem::Specification.new do |s|
s.name = "katalyst-koi"
s.version = "4.13.2"
s.version = "4.14.0"
s.authors = ["Katalyst Interactive"]
s.email = ["[email protected]"]

Expand Down
1 change: 1 addition & 0 deletions lib/koi/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class Engine < ::Rails::Engine
end

initializer "koi.middleware" do |app|
app.middleware.use Koi::Middleware::AdminAuthentication
app.middleware.use ::ActionDispatch::Static, root.join("public").to_s
app.middleware.insert_before Rack::Sendfile, Koi::Middleware::UrlRedirect
end
Expand Down
46 changes: 46 additions & 0 deletions lib/koi/middleware/admin_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

module Koi
module Middleware
class AdminAuthentication
def initialize(app)
@app = app
end

def call(env)
if env["PATH_INFO"].starts_with?("/admin")
admin_call(env)
else
@app.call(env)
end
end

def admin_call(env)
request = ActionDispatch::Request.new(env)
session = ActionDispatch::Request::Session.find(request)

if requires_authentication?(request) && !authenticated?(session)
# Set the redirection path for returning the user to their requested path after login
if request.get?
request.flash[:redirect] = request.fullpath
request.commit_flash
end

[303, { "Location" => "/admin/session/new" }, []]
else
@app.call(env)
end
end

private

def requires_authentication?(request)
!request.path.starts_with?("/admin/session")
end

def authenticated?(session)
session[:admin_user_id].present?
end
end
end
end
21 changes: 21 additions & 0 deletions spec/requests/admin/authentication_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe "admin authentication" do
describe "GET /admin/dashboard" do
subject { action && response }

let(:action) { get "/admin/dashboard" }

it { is_expected.to have_http_status(:see_other).and(redirect_to("/admin/session/new")) }
end

describe "GET /admin/guess" do
subject { action && response }

let(:action) { get "/admin/guess" }

it { is_expected.to have_http_status(:see_other).and(redirect_to("/admin/session/new")) }
end
end
2 changes: 1 addition & 1 deletion spec/requests/admin/sessions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
delete admin_session_path, as: :turbo_stream
end

it_behaves_like "requires admin"
include_context "with admin session"

it "renders successfully" do
action
Expand Down
Loading

0 comments on commit 31a0d9a

Please sign in to comment.