Skip to content
Merged
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
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ GEM
bindex (0.8.1)
bootsnap (1.24.5)
msgpack (~> 1.2)
brakeman (8.0.4)
brakeman (8.0.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
Expand Down Expand Up @@ -177,7 +177,7 @@ GEM
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
net-imap (0.6.4)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
Expand Down Expand Up @@ -449,7 +449,7 @@ CHECKSUMS
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
bootsnap (1.24.5) sha256=36b677448524d279b470469aabd5dff4a980e3fa4931a0df68da4a500eb1b6c4
brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f
brakeman (8.0.5) sha256=03735f9690d3fd4b32d66aacbf0a6d15a84266bdd06b32c05c8ecc8f6021d2be
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
Expand Down Expand Up @@ -487,7 +487,7 @@ CHECKSUMS
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b
net-imap (0.6.4.1) sha256=29f0360d75a7efd3539f16ac1957dea5c0a51ddeceb348db4553c3120914ea0d
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d
Expand Down
43 changes: 43 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ def oauth_callback_redirect_uri(slug)
# controllers descend from ActionController::API, not this class, so they keep
# their own ApiKey/proxy-token auth and are unaffected.
before_action :require_login
# A signed-in user must also be approved (active) to use the console. The login
# and pending controllers skip this so pending users can reach the holding page
# and sign out.
before_action :require_active_account

private

Expand All @@ -47,6 +51,45 @@ def require_login
redirect_to login_path unless current_user
end

# Second gate, after require_login: a disabled user is signed out; a pending
# (not-yet-approved) user is sent to the holding page. Active users pass through.
def require_active_account
return unless current_user
if current_user.disabled?
reset_session
redirect_to login_path, alert: "Your account is disabled."
elsif current_user.pending?
redirect_to pending_path
end
end

# Guard for admin-only controllers (e.g. user management). Not a global gate.
def require_admin
redirect_to root_path, alert: "That page is restricted to admins." unless current_user&.admin?
end

# Establishes the console cookie session and sends the user to the right
# post-login page. Password login re-renders for disabled accounts; SSO login
# redirects because it is returning from an external provider.
def sign_in_console_user(user, disabled: :redirect)
if user.disabled?
if disabled == :render
flash.now[:alert] = "Your account is disabled."
return render :new, status: :unprocessable_entity
end

return redirect_to login_path, alert: "Your account is disabled."
end

reset_session
session[:user_id] = user.id
if user.active?
redirect_to console_principals_path, notice: "Signed in as #{user.email}."
else
redirect_to pending_path, notice: "Your account is awaiting approval."
end
end

def render_not_found(e)
render plain: e.message, status: :not_found
end
Expand Down
41 changes: 41 additions & 0 deletions app/controllers/console/users_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module Console
# Operator (console user) management: approve pending sign-ins, disable access,
# or promote a user to admin. Admin-only -- the gate is require_admin, layered on
# top of the app-wide require_login/require_active_account gates.
class UsersController < ApplicationController
layout "console"

before_action :require_admin
before_action :set_user, only: %i[approve disable promote]

def index
@pending = User.pending.includes(:user_identities).order(:created_at)
@users = User.where.not(status: "pending").includes(:user_identities).order(admin: :desc, email: :asc)
end

def approve
@user.approve!(by: current_user)
redirect_to console_users_path, notice: "Approved #{@user.email}."
end

def disable
if @user == current_user
return redirect_to console_users_path, alert: "You can't disable your own account."
end
@user.update!(status: :disabled)
redirect_to console_users_path, notice: "Disabled #{@user.email}."
end

def promote
# Promoting also activates: an admin must be able to use the console.
@user.update!(admin: true, status: :active)
redirect_to console_users_path, notice: "#{@user.email} is now an admin."
end

private

def set_user
@user = User.find_by_oid!(params[:id])
end
end
end
1 change: 1 addition & 0 deletions app/controllers/oauth/flows_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class FlowsController < ApplicationController
layout "auth"

skip_before_action :require_login
skip_before_action :require_active_account

# The message_verifier purpose binding the signed state to this flow, the
# state/cookie lifetime, and the encrypted cookie that ties a callback back to
Expand Down
136 changes: 136 additions & 0 deletions app/controllers/session_oauth_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
require "base64"
require "digest"
require "uri"

# Console SSO login, keyed by provider: /auth/:provider/start sends an operator to
# the IdP, and /auth/:provider/callback turns the returned code into a signed-in
# User. Structurally mirrors Oauth::FlowsController (signed state, PKCE, an
# encrypted flow cookie binding the callback to the browser that started it), but
# it produces a console session instead of a BrokerCredential.
#
# Provisioning (find_or_provision_user): a returning identity matches by
# (provider, subject); a new identity whose verified email matches an existing
# user links to that user; otherwise a new user is created -- active + admin when
# the email is on the bootstrap allowlist, pending otherwise.
#
# SECURITY: never logs codes, tokens, or response bodies -- only provider keys and
# error codes, like the Broker/Oauth subsystem. Account linking is gated on
# email_verified so an unverified IdP email can't take over an existing account.
class SessionOauthController < ApplicationController
layout "auth"

# The login form, the IdP redirect, and the callback must all work signed out.
skip_before_action :require_login
skip_before_action :require_active_account

STATE_PURPOSE = :console_login_flow
FLOW_TTL = 10.minutes
FLOW_COOKIE = :console_login_flow

# Tests swap in an AuthorizationCodeClient built around an http double, mirroring
# Oauth::FlowsController.
class_attribute :exchange_client_factory, default: -> { Broker::AuthorizationCodeClient.new }

before_action :set_provider

# GET /auth/:provider/start
def start
nonce = SecureRandom.urlsafe_base64(32)
code_verifier = SecureRandom.urlsafe_base64(64)

state = Rails.application.message_verifier(STATE_PURPOSE).generate(
{ "provider" => @key, "nonce" => nonce },
purpose: STATE_PURPOSE, expires_in: FLOW_TTL
)

# :lax is required -- the callback arrives via a top-level cross-site redirect
# from the IdP, which Lax permits for GET.
cookies.encrypted[FLOW_COOKIE] = {
value: { "nonce" => nonce, "code_verifier" => code_verifier }.to_json,
expires: FLOW_TTL.from_now, httponly: true, same_site: :lax
}

redirect_to authorization_url(state, code_verifier), allow_other_host: true
end

# GET /auth/:provider/callback?code=&state= (or ?error=)
def callback
state = Rails.application.message_verifier(STATE_PURPOSE).verified(params[:state], purpose: STATE_PURPOSE)
return invalid_flow if state.nil? || state["provider"] != @key

flow = read_and_clear_flow_cookie
return invalid_flow if flow.nil? || flow["nonce"] != state["nonce"]

if params[:error].present?
return redirect_to login_path, alert: "Sign in was canceled."
end

result = exchange_code(params[:code], flow["code_verifier"])
identity = @provider.identity_from(result, client_id: ConsoleAuth.client_id(@key))
sign_in_console_user(User.link_or_provision(provider: @key, identity: identity))
rescue Broker::ExchangeError => e
Rails.logger.error { "console login exchange failed (#{@key}): #{e.reason}" }
redirect_to login_path, alert: "Sign in failed. Please try again."
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error { "console login provisioning failed: #{e.record.errors.full_messages.to_sentence}" }
redirect_to login_path, alert: "Sign in failed while setting up your account."
end

private

# Resolves the provider strategy, rejecting unknown or unconfigured providers
# (no client credentials => no button => no flow).
def set_provider
@key = params[:provider].to_s
@provider = ConsoleAuth.configured?(@key) ? Login::Providers.fetch(@key) : nil
redirect_to login_path, alert: "That sign-in method is not available." if @provider.nil?
end

def authorization_url(state, code_verifier)
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
query = {
"client_id" => ConsoleAuth.client_id(@key),
"redirect_uri" => callback_redirect_uri,
"response_type" => "code",
"scope" => @provider.scopes.join(" "),
"state" => state,
"code_challenge" => challenge,
"code_challenge_method" => "S256"
}.merge(@provider.extra_authorization_params)

uri = URI.parse(@provider.authorization_endpoint)
uri.query = URI.encode_www_form(query)
uri.to_s
end

def exchange_code(code, code_verifier)
exchange_client_factory.call.exchange(
token_endpoint: @provider.token_endpoint,
client_id: ConsoleAuth.client_id(@key),
client_secret: ConsoleAuth.client_secret(@key),
code: code.to_s,
redirect_uri: callback_redirect_uri,
code_verifier: code_verifier.to_s,
# Login requests no offline access, so the IdP returns no refresh token.
require_refresh_token: false
)
end

# The callback redirect URI registered with the IdP: "<public base>/auth/<provider>/callback".
def callback_redirect_uri
URI.join(public_base_url, "/auth/#{@key}/callback").to_s
end

def read_and_clear_flow_cookie
raw = cookies.encrypted[FLOW_COOKIE]
cookies.delete(FLOW_COOKIE)
return nil if raw.blank?
JSON.parse(raw)
rescue JSON::ParserError
nil
end

def invalid_flow
redirect_to login_path, alert: "This sign-in link is invalid or expired. Start again."
end
end
21 changes: 14 additions & 7 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,28 @@ class SessionsController < ApplicationController
# app-wide require_login gate. (logout keeps the gate: it's a no-op when
# there's no session.)
skip_before_action :require_login, only: %i[new create]
# None of these enforce approval: the login form is anonymous, and pending users
# must still reach the holding page and be able to sign out.
skip_before_action :require_active_account

def new
redirect_to console_principals_path if current_user
redirect_to console_principals_path if current_user&.active?
end

# Holding page for a signed-in but not-yet-approved user. Active users have no
# reason to be here, so send them to the console.
def pending
redirect_to console_principals_path if current_user&.active?
end

def create
user = User.find_by(email: params[:email].to_s.strip.downcase)
if user&.authenticate(params[:password])
reset_session
session[:user_id] = user.id
redirect_to console_principals_path, notice: "Signed in as #{user.email}."
else
unless user&.authenticate(params[:password])
flash.now[:alert] = "Invalid email or password."
render :new, status: :unprocessable_entity
return render :new, status: :unprocessable_entity
end

sign_in_console_user(user, disabled: :render)
end

def destroy
Expand Down
31 changes: 31 additions & 0 deletions app/helpers/console/users_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module Console
# View helpers for the operator (console user) management screen.
module UsersHelper
STATUS_CHIP_CLASSES = {
"active" => "bg-emerald-500/10 text-emerald-300 ring-emerald-500/20",
"pending" => "bg-amber-500/10 text-amber-300 ring-amber-500/20",
"disabled" => "bg-zinc-500/10 text-zinc-400 ring-zinc-500/20"
}.freeze

IDP_CHIP_CLASSES = "bg-ink-700/60 text-zinc-300 ring-ink-500".freeze

# A colored pill for a user's account status.
def user_status_badge(user)
chip(user.status, STATUS_CHIP_CLASSES.fetch(user.status, STATUS_CHIP_CLASSES["disabled"]))
end

# One pill per linked identity provider, or a single "password" pill when the
# account has no SSO identity. All share the same neutral IdP styling.
def user_idp_chips(user)
labels = user.user_identities.map(&:provider).uniq.sort
labels = %w[password] if labels.empty?
tag.div(safe_join(labels.map { |label| chip(label.capitalize, IDP_CHIP_CLASSES) }), class: "flex flex-wrap gap-1")
end

private

def chip(text, classes)
tag.span(text, class: "rounded px-1.5 py-0.5 text-xs ring-1 #{classes}")
end
end
end
Loading