2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

DEV: Add Discourse ID authenticator (#33186)

Adds a Discourse ID authenticator. Not available for use in production
just yet, but soon communities will be able to use this service to let
users authenticate using a central Discourse ID account.

Includes a support for a `/revoke` action, allowing users to log out of
multiple client instances from a central auth service.

Internal ticket: t/155397
---------

Co-authored-by: Loïc Guitaut <loic@discourse.org>
This commit is contained in:
Penar Musaraj 2025-06-17 09:47:00 -04:00 committed by GitHub
parent 2362bf740d
commit d45ebd746c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 572 additions and 0 deletions

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true

class Users::DiscourseIdController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:revoke]

def revoke
RateLimiter.new(nil, "discourse_id_revoke_#{params[:identifier]}", 5, 1.minute).performed!

DiscourseId::Revoke.call(service_params) do |result|
on_success { render json: { success: true } }
on_failed_contract do |contract|
logger.warn(result.inspect_steps) if SiteSetting.discourse_id_verbose_logging
render json: { error: contract.errors.full_messages.join(", ") }, status: 400
end
on_failure do
logger.warn(result.inspect_steps) if SiteSetting.discourse_id_verbose_logging
render json: { error: "Invalid request" }, status: 400
end
end
end
end

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true

class DiscourseId::Revoke
include Service::Base

policy :discourse_id_properly_configured

params do
attribute :identifier, :string
attribute :timestamp, :integer
attribute :signature, :string

validates :identifier, :timestamp, :signature, presence: true

with_options if: -> { identifier.present? && timestamp.present? && signature.present? } do
validate :timestamp_expired?
validate :proper_signature?
end

private

def timestamp_expired?
time_diff = (Time.current.to_i - timestamp).abs
return if time_diff <= 5.minutes.to_i

errors.add(:timestamp, "is expired: #{time_diff} seconds old")
end

def proper_signature?
expected_signature =
OpenSSL::HMAC.hexdigest(
"sha256",
Digest::SHA256.hexdigest(SiteSetting.discourse_id_client_secret),
"#{SiteSetting.discourse_id_client_id}:#{identifier}:#{timestamp}",
)
return if ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
errors.add(:signature, "is invalid for user id #{identifier}")
end
end

model :associated_account
step :revoke_auth_tokens

private

def discourse_id_properly_configured
SiteSetting.enable_discourse_id && SiteSetting.discourse_id_client_id.present? &&
SiteSetting.discourse_id_client_secret.present?
end

def fetch_associated_account(params:)
UserAssociatedAccount.find_by(provider_name: "discourse_id", provider_uid: params.identifier)
end

def revoke_auth_tokens(associated_account:)
UserAuthToken.where(user_id: associated_account.user_id).destroy_all
end
end

View file

@ -2521,6 +2521,9 @@ en:
to_continue: "Please Log In"
preferences: "You need to be logged in to change your user preferences."
not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in."
discourse_id:
name: "Discourse ID"
title: "Log in with Discourse ID"
google_oauth2:
name: "Google"
title: "Sign in with Google"

View file

@ -1271,6 +1271,7 @@ Discourse::Application.routes.draw do

match "/auth/failure", to: "users/omniauth_callbacks#failure", via: %i[get post]
get "/auth/:provider", to: "users/omniauth_callbacks#confirm_request"
post "/auth/discourse_id/revoke" => "users/discourse_id#revoke"
match "/auth/:provider/callback", to: "users/omniauth_callbacks#complete", via: %i[get post]
get "/associate/:token",
to: "users/associate_accounts#connect_info",

View file

@ -592,6 +592,22 @@ login:
enable_signup_cta:
client: true
default: true
enable_discourse_id:
default: false
hidden: true
discourse_id_client_id:
default: ""
hidden: true
discourse_id_client_secret:
default: ""
hidden: true
secret: true
discourse_id_verbose_logging:
default: false
hidden: true
discourse_id_provider_url:
default: ""
hidden: true
enable_google_oauth2_logins:
default: false
google_oauth2_client_id: ""

View file

@ -15,3 +15,4 @@ require "auth/twitter_authenticator"
require "auth/linkedin_oidc_authenticator"
require "auth/google_oauth2_authenticator"
require "auth/discord_authenticator"
require "auth/discourse_id_authenticator"

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true

class Auth::DiscourseIdAuthenticator < Auth::ManagedAuthenticator
class DiscourseIdStrategy < ::OmniAuth::Strategies::OAuth2
option :name, "discourse_id"

option :client_options, auth_scheme: :basic_auth

def authorize_params
super.tap { _1[:intent] = "signup" if request.params["signup"] == "true" }
end

def callback_url
Discourse.base_url_no_prefix + callback_path
end

uid { access_token.params["info"]["uuid"] }

info do
{
nickname: access_token.params["info"]["username"],
email: access_token.params["info"]["email"],
image: access_token.params["info"]["image"],
}
end
end

def name
"discourse_id"
end

def display_name
"Discourse ID"
end

def enabled?
SiteSetting.enable_discourse_id && SiteSetting.discourse_id_client_id.present? &&
SiteSetting.discourse_id_client_secret.present?
end

def site
SiteSetting.discourse_id_provider_url.presence || "https://id.discourse.com"
end

def register_middleware(omniauth)
omniauth.provider DiscourseIdStrategy,
scope: "read",
setup: ->(env) do
env["omniauth.strategy"].options.merge!(
client_id: SiteSetting.discourse_id_client_id,
client_secret: SiteSetting.discourse_id_client_secret,
client_options: {
site:,
},
)
end
end

def primary_email_verified?(auth_token)
true # email will be verified at source
end
end

View file

@ -487,6 +487,10 @@ module Discourse
end

BUILTIN_AUTH = [
Auth::AuthProvider.new(
authenticator: Auth::DiscourseIdAuthenticator.new,
icon: "fab-discourse",
),
Auth::AuthProvider.new(
authenticator: Auth::FacebookAuthenticator.new,
frame_width: 580,

View file

@ -0,0 +1,115 @@
# frozen_string_literal: true

require "rails_helper"

describe Auth::DiscourseIdAuthenticator do
let(:authenticator) { described_class.new }
let(:user) { Fabricate(:user) }

context "with default settings" do
before do
SiteSetting.enable_discourse_id = true
SiteSetting.discourse_id_client_id = "client_id"
SiteSetting.discourse_id_client_secret = "client_secret"
end

it "has the right name" do
expect(authenticator.name).to eq("discourse_id")
end

it "can connect to existing user" do
expect(authenticator.can_connect_existing_user?).to eq(true)
end

it "can be revoked" do
expect(authenticator.can_revoke?).to eq(true)
end

it "verifies email by default" do
expect(authenticator.primary_email_verified?({})).to eq(true)
end

it "does not always update user email" do
expect(authenticator.always_update_user_email?).to eq(false)
end

describe "#enabled?" do
it "is enabled with proper settings" do
expect(authenticator.enabled?).to eq(true)
end

it "is disabled without client id" do
SiteSetting.discourse_id_client_id = ""
expect(authenticator.enabled?).to eq(false)
end

it "is disabled without client secret" do
SiteSetting.discourse_id_client_secret = ""
expect(authenticator.enabled?).to eq(false)
end

it "is disabled when `discourse_login_client_enabled` is false" do
SiteSetting.enable_discourse_id = false
expect(authenticator.enabled?).to eq(false)
end
end

describe "#site" do
it "returns default URL when setting is blank" do
SiteSetting.discourse_id_provider_url = ""
expect(authenticator.site).to eq("https://id.discourse.com")
end

it "returns configured URL when setting is present" do
SiteSetting.discourse_id_provider_url = "https://custom.example.com"
expect(authenticator.site).to eq("https://custom.example.com")
end
end
end

describe "DiscourseIdStrategy" do
let(:strategy) { Auth::DiscourseIdAuthenticator::DiscourseIdStrategy.new({}) }

it "uses 'discourse_id' name" do
expect(strategy.options.name).to eq("discourse_id")
end

it "uses basic auth as authentication scheme" do
expect(strategy.options.client_options.auth_scheme).to eq(:basic_auth)
end

it "defines callback_url" do
expect(strategy.callback_url).to eq("http://test.localhost/auth/discourse_id/callback")
end
end

let(:hash) do
OmniAuth::AuthHash.new(
provider: "discourse_id",
info: {
"nickname" => "test_user",
"email" => user.email,
"image" => "http://example.com/avatar.png",
"uuid" => "12345",
},
uid: "99",
)
end

describe "after_authenticate" do
it "works and syncs username, email, avatar" do
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(user)
expect(result.failed).to eq(false)

expect(result.username).to eq("test_user")
expect(result.email).to eq(user.email)

associated_record =
UserAssociatedAccount.find_by(provider_name: "discourse_id", user_id: user.id)

expect(associated_record[:info]["image"]).to eq("http://example.com/avatar.png")
expect(associated_record[:info]["uuid"]).to eq("12345")
end
end
end

View file

@ -0,0 +1,135 @@
# frozen_string_literal: true

RSpec.describe Users::DiscourseIdController do
let(:client_id) { SiteSetting.discourse_id_client_id }
let(:hashed_secret) { Digest::SHA256.hexdigest(SiteSetting.discourse_id_client_secret) }
let(:identifier) { SecureRandom.hex }
let(:provider_name) { "discourse_id" }

let!(:user) { Fabricate(:user) }
let!(:user_associated_account) do
Fabricate(:user_associated_account, user:, provider_name:, provider_uid: identifier)
end

before do
SiteSetting.enable_discourse_id = true
SiteSetting.discourse_id_client_id = SecureRandom.hex
SiteSetting.discourse_id_client_secret = SecureRandom.hex
end

describe "#revoke" do
context "with valid parameters" do
it "revokes all auth tokens for the user" do
UserAuthToken.generate!(user_id: user.id)
UserAuthToken.generate!(user_id: user.id)

expect(UserAuthToken.where(user_id: user.id).count).to eq(2)

timestamp = Time.now.to_i
signature =
OpenSSL::HMAC.hexdigest(
"sha256",
hashed_secret,
"#{client_id}:#{identifier}:#{timestamp}",
)

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }

expect(response.status).to eq(200)
expect(response.parsed_body["success"]).to eq(true)

expect(UserAuthToken.where(user_id: user.id).count).to eq(0)
end
end

context "with invalid parameters" do
it "returns 400 when signature is invalid" do
timestamp = Time.now.to_i
signature = SecureRandom.hex

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }

expect(response.status).to eq(400)
expect(response.parsed_body["error"]).to match(/Signature is invalid/)

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }

expect(response.status).to eq(400)
end

it "returns 400 when timestamp is too old" do
timestamp = Time.now.to_i - 6.minutes.to_i
signature =
OpenSSL::HMAC.hexdigest(
"sha256",
hashed_secret,
"#{client_id}:#{identifier}:#{timestamp}",
)

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }

expect(response.status).to eq(400)
expect(response.parsed_body["error"]).to match(/Timestamp is expired/)
end

it "returns 400 when user_id is not found" do
identifier = "non_existent_user_id"
timestamp = Time.now.to_i
signature =
OpenSSL::HMAC.hexdigest(
"sha256",
hashed_secret,
"#{client_id}:#{identifier}:#{timestamp}",
)

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }

expect(response.status).to eq(400)
expect(response.parsed_body["error"]).to eq("Invalid request")
end

it "returns 400 when client_id or client_secret is blank" do
SiteSetting.discourse_id_client_id = ""

timestamp = Time.now.to_i
signature =
OpenSSL::HMAC.hexdigest(
"sha256",
hashed_secret,
"#{client_id}:#{identifier}:#{timestamp}",
)

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }

expect(response.status).to eq(400)
expect(response.parsed_body["error"]).to eq("Invalid request")

SiteSetting.discourse_id_client_id = SecureRandom.hex
SiteSetting.discourse_id_client_secret = ""

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }

expect(response.status).to eq(400)
expect(response.parsed_body["error"]).to eq("Invalid request")
end
end

context "with rate limiting" do
before { RateLimiter.enable }

it "rate limits after 5 requests per minute for the same user_id" do
identifier = "non_existent_user_id"
timestamp = Time.now.to_i
signature = SecureRandom.hex

5.times do
post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }
expect(response.status).to eq(400)
end

post "/auth/discourse_id/revoke.json", params: { signature:, identifier:, timestamp: }
expect(response.status).to eq(429)
end
end
end
end

View file

@ -0,0 +1,94 @@
# frozen_string_literal: true

RSpec.describe DiscourseId::Revoke do
describe described_class::Contract, type: :model do
subject(:contract) { described_class.new(identifier:, timestamp:, signature:) }

let(:identifier) { SecureRandom.hex }
let(:signature) { SecureRandom.hex }
let(:timestamp) { Time.current.to_i }

it { is_expected.to validate_presence_of(:identifier) }
it { is_expected.to validate_presence_of(:timestamp) }
it { is_expected.to validate_presence_of(:signature) }

context "when the timestamp is expired" do
let(:timestamp) { Time.current.to_i - 3600 }

it do
is_expected.not_to allow_value(timestamp).for(:timestamp).with_message(
"is expired: 3600 seconds old",
)
end
end

context "when the signature is not valid" do
it do
is_expected.not_to allow_value(signature).for(:signature).with_message(
"is invalid for user id #{identifier}",
)
end
end
end

describe "#call" do
subject(:result) { described_class.call(params:) }

fab!(:user)

let(:params) { { identifier: uaa.provider_uid, timestamp:, signature: } }
let(:client_id) { SiteSetting.discourse_id_client_id }
let(:hashed_secret) { Digest::SHA256.hexdigest(SiteSetting.discourse_id_client_secret) }
let(:identifier) { SecureRandom.hex }
let(:provider_name) { "discourse_id" }
let(:timestamp) { Time.current.to_i }
let(:signature) do
OpenSSL::HMAC.hexdigest("sha256", hashed_secret, "#{client_id}:#{identifier}:#{timestamp}")
end
let!(:uaa) do
Fabricate(
:user_associated_account,
user:,
provider_name: "discourse_id",
provider_uid: identifier,
)
end

before do
SiteSetting.enable_discourse_id = true
SiteSetting.discourse_id_client_id = SecureRandom.hex
SiteSetting.discourse_id_client_secret = SecureRandom.hex
end

context "when discourse id is not properly configured" do
before do
SiteSetting.discourse_id_client_id = nil
SiteSetting.discourse_id_client_secret = nil
end

it { is_expected.to fail_a_policy(:discourse_id_properly_configured) }
end

context "when contract is not valid" do
let(:signature) { nil }

it { is_expected.to fail_a_contract }
end

context "when the associated account is not found" do
before { uaa.destroy! }

it { is_expected.to fail_to_find_a_model(:associated_account) }
end

context "when everything is ok" do
before { UserAuthToken.generate!(user_id: user.id) }

it { is_expected.to run_successfully }

it "destroys user auth tokens" do
expect { result }.to change { UserAuthToken.where(user_id: user.id).count }.by(-1)
end
end
end
end

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true

describe "discourse login client auth" do
include OmniauthHelpers

before do
OmniAuth.config.test_mode = true
SiteSetting.enable_discourse_id = true
SiteSetting.discourse_id_client_id = "asdasd"
SiteSetting.discourse_id_client_secret = "wadayathink"

OmniAuth.config.mock_auth[:discourse_id] = OmniAuth::AuthHash.new(
provider: "discourse_id",
uid: OmniauthHelpers::UID,
info:
OmniAuth::AuthHash::InfoHash.new(
email: OmniauthHelpers::EMAIL,
username: OmniauthHelpers::USERNAME,
),
)

Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:github]
end

after { reset_omniauth_config(:discourse_id) }

let(:signup_form) { PageObjects::Pages::Signup.new }

context "when user does not exist" do
context "when auth_skip_create_confirm is false" do
before { SiteSetting.auth_skip_create_confirm = false }

it "skips the signup form and creates the account directly" do
visit("/")
signup_form.open.click_social_button("discourse_id")
expect(page).to have_css(".login-welcome-header")
end
end

context "when auth_skip_create_confirm is true" do
before { SiteSetting.auth_skip_create_confirm = true }

it "skips the signup form and creates the account directly" do
visit("/")
signup_form.open.click_social_button("discourse_id")
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
end
end

context "when user exists" do
fab!(:user) do
Fabricate(:user, email: OmniauthHelpers::EMAIL, username: OmniauthHelpers::USERNAME)
end

it "logs in user" do
visit("/")
signup_form.open.click_social_button("discourse_id")
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
end
end