Sign in with Hack Club for back_office users (#185)

* add HCA for back office users

* maybe better initializer?
This commit is contained in:
nora 2025-12-11 17:03:19 -05:00 committed by GitHub
parent f2acb68255
commit b1c8b2f91a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 163 additions and 79 deletions

View file

@ -85,6 +85,9 @@ gem "faraday", "~> 2.13"
gem "oauth2", "~> 2.0"
gem "omniauth", "~> 2.1"
gem "omniauth_openid_connect", "~> 0.7"
gem "snail", "~> 2.3"
gem "easypost", "~> 7.1"

View file

@ -94,6 +94,7 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
aes_key_wrap (1.1.0)
andand (1.3.3)
annotaterb (4.16.0)
activerecord (>= 6.0.0)
@ -101,6 +102,7 @@ GEM
argon2-kdf (0.3.1)
fiddle
ast (2.4.3)
attr_required (1.0.2)
awesome_print (1.9.2)
aws-eventstream (1.4.0)
aws-partitions (1.1124.0)
@ -126,6 +128,7 @@ GEM
bcrypt_pbkdf (1.1.1-x86_64-darwin)
benchmark (0.4.1)
bigdecimal (3.2.2)
bindata (2.5.1)
bindex (0.8.1)
blazer (3.3.0)
activerecord (>= 7.1)
@ -175,6 +178,8 @@ GEM
dry-cli (1.2.0)
easypost (7.1.0)
ed25519 (1.4.0)
email_validator (2.2.4)
activemodel
erb (5.0.1)
erubi (1.13.1)
et-orbi (1.2.11)
@ -184,6 +189,8 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.4.0)
faraday (>= 1, < 3)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.1)
@ -261,6 +268,13 @@ GEM
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.12.2)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
jwt (3.1.1)
base64
kamal (2.6.1)
@ -372,6 +386,27 @@ GEM
snaky_hash (~> 2.0, >= 2.0.3)
version_gem (>= 1.1.8, < 3)
observer (0.1.2)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.1)
activemodel
attr_required (>= 1.0.0)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_url
webfinger (~> 2.0)
ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
@ -413,6 +448,17 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.1.16)
rack-oauth2 (2.3.0)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
@ -571,6 +617,11 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
temple (0.10.3)
thor (1.3.2)
thruster (0.1.14)
@ -597,6 +648,9 @@ GEM
valid_email2 (7.0.13)
activemodel (>= 6.0)
mail (~> 2.5)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
version_gem (1.1.8)
vite_rails (3.0.19)
railties (>= 5.1, < 9)
@ -612,6 +666,10 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.7.7)
@ -676,6 +734,8 @@ DEPENDENCIES
nokogiri (~> 1.18)
norairrecord (~> 0.4.0)
oauth2 (~> 2.0)
omniauth (~> 2.1)
omniauth_openid_connect (~> 0.7)
parallel (~> 1.26)
pg (~> 1.1)
phlex-pdf (~> 0.1.2)

View file

@ -1,45 +1,8 @@
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: [:new, :create]
skip_before_action :authenticate_user!, only: [:omniauth_failure, :hackclub_callback]
skip_after_action :verify_authorized
def new
redirect_uri = url_for(action: :create, only_path: false)
Rails.logger.info "Starting Slack OAuth flow with redirect URI: #{redirect_uri}"
redirect_to User.authorize_url(redirect_uri),
host: "https://slack.com",
allow_other_host: true
end
def create
redirect_uri = url_for(action: :create, only_path: false)
if params[:error].present?
Rails.logger.error "Slack OAuth error: #{params[:error]}"
uuid = Honeybadger.notify("Slack OAuth error: #{params[:error]}")
redirect_to login_path, alert: "failed to authenticate with Slack! (error: #{uuid})"
return
end
begin
@user = User.from_slack_token(params[:code], redirect_uri)
rescue => e
Rails.logger.error "Error creating user from Slack data: #{e.message}"
uuid = Honeybadger.notify(e)
redirect_to login_path, alert: "error authenticating! (error: #{uuid})"
return
end
if @user&.persisted?
session[:user_id] = @user.id
flash[:success] = "welcome aboard!"
redirect_to root_path
else
Rails.logger.error "Failed to create/update user from Slack data"
redirect_to login_path, alert: "are you sure you should be here?"
end
end
def impersonate
unless current_user.admin?
redirect_to root_path, alert: "you are not authorized to impersonate users. this incident has been reported :-P"
@ -64,4 +27,35 @@ class SessionsController < ApplicationController
session[:user_id] = nil
redirect_to root_path, notice: "bye, see you next time!"
end
def omniauth_failure
redirect_to login_path, alert: "Authentication failed: #{params[:message]}"
end
def hackclub_callback
auth = request.env["omniauth.auth"]
if auth.nil?
redirect_to login_path, alert: "Authentication failed"
return
end
begin
@user = User.from_hack_club_auth(auth)
rescue => e
Rails.logger.error "Error creating user from Hack Club Auth: #{e.message}"
uuid = Honeybadger.notify(e)
redirect_to login_path, alert: "error authenticating! (error: #{uuid})"
return
end
if @user&.persisted?
session[:user_id] = @user.id
flash[:success] = "welcome aboard!"
redirect_to root_path
else
Rails.logger.error "Failed to create/update user from Hack Club Auth"
redirect_to login_path, alert: "are you sure you should be here?"
end
end
end

View file

@ -14,6 +14,7 @@ class UserDashboard < Administrate::BaseDashboard
icon_url: Field::String,
is_admin: Field::Boolean,
slack_id: Field::String,
hca_id: Field::String,
username: Field::String,
warehouse_templates: Field::HasMany,
home_mid: Field::BelongsTo,
@ -32,7 +33,7 @@ class UserDashboard < Administrate::BaseDashboard
is_admin
can_warehouse
email
slack_id
hca_id
].freeze
# SHOW_PAGE_ATTRIBUTES
@ -44,6 +45,7 @@ class UserDashboard < Administrate::BaseDashboard
icon_url
is_admin
slack_id
hca_id
username
warehouse_templates
home_mid
@ -61,6 +63,7 @@ class UserDashboard < Administrate::BaseDashboard
icon_url
is_admin
slack_id
hca_id
username
home_mid
home_return_address

View file

@ -14,6 +14,7 @@
# updated_at :datetime not null
# home_mid_id :bigint default(1), not null
# home_return_address_id :bigint default(1), not null
# hca_id :string
# slack_id :string
#
# Indexes
@ -45,46 +46,30 @@ class User < ApplicationRecord
def remove_admin! = update!(is_admin: false)
def self.authorize_url(redirect_uri)
params = {
client_id: ENV["SLACK_CLIENT_ID"],
redirect_uri: redirect_uri,
state: SecureRandom.hex(24),
user_scope: "users.profile:read,users:read,users:read.email",
}
def self.from_hack_club_auth(auth_hash)
hca_id = auth_hash.dig("uid")
return nil unless hca_id
URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}")
end
# Try to find by hca_id first
user = find_by(hca_id: hca_id)
def self.from_slack_token(code, redirect_uri)
# Exchange code for token
response = HTTP.post("https://slack.com/api/oauth.v2.access", form: {
client_id: ENV["SLACK_CLIENT_ID"],
client_secret: ENV["SLACK_CLIENT_SECRET"],
code: code,
redirect_uri: redirect_uri,
})
# If not found, try to migrate from slack_id
unless user
slack_id = auth_hash.dig("extra", "raw_info", "slack_id")
if slack_id.present?
user = find_by(slack_id: slack_id)
if user
# Migrate user to use hca_id
user.hca_id = hca_id
end
end
end
data = JSON.parse(response.body.to_s)
return nil unless data["ok"]
# Get user info
user_response = HTTP.auth("Bearer #{data["authed_user"]["access_token"]}")
.get("https://slack.com/api/users.info?user=#{data["authed_user"]["id"]}")
user_data = JSON.parse(user_response.body.to_s)
return nil unless user_data["ok"]
user = find_by(slack_id: data.dig("authed_user", "id"))
return nil unless user
user.email = user_data.dig("user", "profile", "email")
user.username ||= user_data.dig("user", "profile", "username")
user.username ||= user_data.dig("user", "profile", "display_name_normalized")
user.icon_url = user_data.dig("user", "profile", "image_192") || user_data.dig("user", "profile", "image_72")
# Store the OAuth data
user.email = auth_hash.dig("info", "email")
user.username ||= auth_hash.dig("info", "name")
user.save!
user
end

View file

@ -8,6 +8,9 @@
<%= vite_image_tag 'images/login/treasure.png', id: "treasure" %>
<%= render 'shared/flash' %>
<h1>welcome ashore...</h1>
<%= link_to "log in?", slack_auth_path %>
<form action="/back_office/auth/hackclub" method="post">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
<button type="submit">log in with hack club?</button>
</form>
</body>
</html>

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
Rails.application.config.hack_club_auth = ActiveSupport::OrderedOptions.new
Rails.application.config.hack_club_auth.client_id = ENV.fetch("HACKCLUB_CLIENT_ID", nil)
Rails.application.config.hack_club_auth.client_secret = ENV.fetch("HACKCLUB_CLIENT_SECRET", nil)
Rails.application.config.hack_club_auth.base_url = ENV.fetch("HACKCLUB_AUTH_URL") do
Rails.env.production? ? "https://auth.hackclub.com" : "https://hca.dinosaurbbq.org"
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
Rails.application.config.middleware.use OmniAuth::Builder do
provider :openid_connect,
name: :hackclub,
issuer: Rails.application.config.hack_club_auth.base_url,
discovery: true,
client_options: {
identifier: Rails.application.config.hack_club_auth.client_id,
secret: Rails.application.config.hack_club_auth.client_secret,
redirect_uri: ->(env) { "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}/back_office/auth/hackclub/callback" }
},
scope: %i[openid profile email slack_id]
end
OmniAuth.config.path_prefix = "/back_office/auth"
OmniAuth.config.request_validation_phase = OmniAuth::AuthenticityTokenProtection.new(key: :_csrf_token)
OmniAuth.config.allowed_request_methods = [:post]

View file

@ -33,6 +33,7 @@ en:
user:
home_mid: "Home Mailer ID"
home_return_address: "Home Return Address"
hca_id: Hack Club Auth ID
usps_payment_account:
ach: "is ACH?"
helpers:

View file

@ -559,10 +559,9 @@ Rails.application.routes.draw do
delete "signout", to: "sessions#destroy", as: :signout
get "/login" => "static_pages#login"
end
get "/auth/slack", to: "sessions#new", as: :slack_auth
get "/auth/slack/callback", to: "sessions#create"
get "/auth/hackclub/callback", to: "sessions#hackclub_callback", as: :hackclub_callback
end
root "public/static_pages#root", as: :public_root

View file

@ -0,0 +1,6 @@
class AddHcaIdToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :hca_id, :string
add_index :users, :hca_id, unique: true
end
end

6
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_07_29_204357) do
ActiveRecord::Schema[8.0].define(version: 2025_12_11_000001) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "pg_catalog.plpgsql"
@ -373,6 +373,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_204357) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "opted_out_of_map", default: false
t.string "hca_id"
t.index ["hca_id"], name: "index_public_users_on_hca_id", unique: true
end
create_table "return_addresses", force: :cascade do |t|
@ -411,6 +413,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_204357) do
t.boolean "can_impersonate_public"
t.bigint "home_mid_id", default: 1, null: false
t.bigint "home_return_address_id", default: 1, null: false
t.string "hca_id"
t.index ["hca_id"], name: "index_users_on_hca_id", unique: true
t.index ["home_mid_id"], name: "index_users_on_home_mid_id"
t.index ["home_return_address_id"], name: "index_users_on_home_return_address_id"
end