Skip to content

Commit 297608d

Browse files
authored
FEATURE: Discourse ID setting page (#36316)
Internal ref - t/169410 Dependent on discourse-org/discourse-login#98
1 parent f28e56c commit 297608d

File tree

20 files changed

+736
-91
lines changed

20 files changed

+736
-91
lines changed

app/assets/stylesheets/admin/admin_base.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,3 +1298,4 @@ a.inline-editable-field {
12981298
@import "admin/admin_config_color_palettes";
12991299
@import "admin/admin_config_components";
13001300
@import "admin/upcoming-changes";
1301+
@import "admin/discourse_id";
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
.discourse-id-admin {
2+
.discourse-id-header {
3+
display: flex;
4+
align-items: center;
5+
justify-content: space-between;
6+
margin-bottom: 1em;
7+
8+
.admin-config-area-card__title {
9+
margin: 0;
10+
}
11+
}
12+
13+
.discourse-id-stats {
14+
display: flex;
15+
gap: 1em;
16+
17+
&__item {
18+
display: flex;
19+
flex-direction: column;
20+
align-items: center;
21+
flex: 1;
22+
padding: 1em 1.5em;
23+
background: var(--primary-very-low);
24+
border-radius: var(--d-border-radius);
25+
}
26+
27+
&__value {
28+
font-size: var(--font-up-4);
29+
font-weight: 700;
30+
color: var(--primary);
31+
}
32+
33+
&__label {
34+
font-size: var(--font-down-1);
35+
color: var(--primary-medium);
36+
text-align: center;
37+
margin-top: 0.25em;
38+
}
39+
}
40+
41+
.discourse-id-footer {
42+
margin-top: 1em;
43+
}
44+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
class Admin::Config::DiscourseIdController < Admin::AdminController
4+
def show
5+
render json: {
6+
enabled: SiteSetting.enable_discourse_id,
7+
configured: credentials_configured?,
8+
stats: {
9+
total_users: total_users_count,
10+
signups_30_days: signups_last_30_days,
11+
logins_30_days: logins_last_30_days,
12+
},
13+
}
14+
end
15+
16+
def regenerate_credentials
17+
DiscourseId::RegenerateCredentials.call(guardian:) do
18+
on_success { render json: success_json }
19+
on_failed_policy(:credentials_configured?) do
20+
render json: failed_json.merge(error: I18n.t("discourse_id.errors.not_configured")),
21+
status: :unprocessable_entity
22+
end
23+
on_failed_step(:request_challenge) do |step|
24+
render json: failed_json.merge(error: step.error), status: :unprocessable_entity
25+
end
26+
on_failed_step(:regenerate_with_challenge) do |step|
27+
render json: failed_json.merge(error: step.error), status: :unprocessable_entity
28+
end
29+
on_failure do
30+
render json: failed_json.merge(error: I18n.t("discourse_id.errors.regenerate_failed")),
31+
status: :unprocessable_entity
32+
end
33+
end
34+
end
35+
36+
def update_settings
37+
params.permit(:enabled)
38+
39+
SiteSetting.enable_discourse_id = params[:enabled] if params.key?(:enabled)
40+
41+
render json: success_json
42+
end
43+
44+
private
45+
46+
def credentials_configured?
47+
SiteSetting.discourse_id_client_id.present? && SiteSetting.discourse_id_client_secret.present?
48+
end
49+
50+
def total_users_count
51+
UserAssociatedAccount.where(provider_name: "discourse_id").count
52+
end
53+
54+
def signups_last_30_days
55+
UserAssociatedAccount
56+
.where(provider_name: "discourse_id")
57+
.where("created_at > ?", 30.days.ago)
58+
.count
59+
end
60+
61+
def logins_last_30_days
62+
UserAssociatedAccount
63+
.where(provider_name: "discourse_id")
64+
.where("last_used > ?", 30.days.ago)
65+
.count
66+
end
67+
end
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseId
4+
module Concerns
5+
module ChallengeFlow
6+
extend ActiveSupport::Concern
7+
8+
private
9+
10+
def request_challenge
11+
response =
12+
post_json(
13+
"/challenge",
14+
{ domain: Discourse.current_hostname }.tap do |body|
15+
body[:path] = Discourse.base_path if Discourse.base_path.present?
16+
end,
17+
)
18+
19+
return fail!(response[:error]) if response[:error]
20+
21+
json = response[:data]
22+
23+
if json["domain"] != Discourse.current_hostname
24+
error =
25+
"Domain mismatch in challenge response (expected: #{Discourse.current_hostname}, got: #{json["domain"]})"
26+
log_error("request_challenge", error)
27+
return fail!(error)
28+
end
29+
30+
if Discourse.base_path.present? && json["path"] != Discourse.base_path
31+
error =
32+
"Path mismatch in challenge response (expected: #{Discourse.base_path}, got: #{json["path"]})"
33+
log_error("request_challenge", error)
34+
return fail!(error)
35+
end
36+
37+
context[:token] = json["token"]
38+
end
39+
40+
def store_challenge_token(token:)
41+
Discourse.redis.setex("discourse_id_challenge_token", 600, token)
42+
end
43+
44+
def post_json(path, body)
45+
uri = URI("#{discourse_id_url}#{path}")
46+
use_ssl = Rails.env.production? || uri.scheme == "https"
47+
48+
request = Net::HTTP::Post.new(uri)
49+
request.content_type = "application/json"
50+
request.body = body.to_json
51+
52+
begin
53+
response =
54+
Net::HTTP.start(uri.hostname, uri.port, use_ssl:) { |http| http.request(request) }
55+
rescue StandardError => e
56+
error = "Request to '#{uri}' failed: #{e.message}."
57+
log_error(path, error)
58+
return { error: }
59+
end
60+
61+
if response.code.to_i != 200
62+
error = "Request to '#{path}' failed: #{response.code}\nError: #{response.body}"
63+
log_error(path, error)
64+
return { error: }
65+
end
66+
67+
begin
68+
{ data: JSON.parse(response.body) }
69+
rescue JSON::ParserError => e
70+
error = "Response from '#{path}' invalid JSON: #{e.message}"
71+
log_error(path, error)
72+
{ error: }
73+
end
74+
end
75+
76+
def discourse_id_url
77+
@discourse_id_url ||= DiscourseId.provider_url
78+
end
79+
80+
def log_error(step, message)
81+
Rails.logger.error(
82+
"Discourse ID #{service_name} failed at step '#{step}'. Error: #{message}",
83+
)
84+
end
85+
86+
def service_name
87+
self.class.name.demodulize.underscore.humanize.downcase
88+
end
89+
end
90+
end
91+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# frozen_string_literal: true
2+
3+
class DiscourseId::RegenerateCredentials
4+
include Service::Base
5+
include DiscourseId::Concerns::ChallengeFlow
6+
7+
policy :credentials_configured?
8+
step :request_challenge
9+
step :store_challenge_token
10+
step :regenerate_with_challenge
11+
step :store_new_credentials
12+
step :log_action
13+
14+
private
15+
16+
def credentials_configured?
17+
SiteSetting.discourse_id_client_id.present? && SiteSetting.discourse_id_client_secret.present?
18+
end
19+
20+
def regenerate_with_challenge(token:)
21+
response =
22+
post_json(
23+
"/regenerate",
24+
{
25+
client_id: SiteSetting.discourse_id_client_id,
26+
client_secret: SiteSetting.discourse_id_client_secret,
27+
challenge_token: token,
28+
},
29+
)
30+
31+
return fail!(response[:error]) if response[:error]
32+
33+
context[:data] = response[:data]
34+
end
35+
36+
def store_new_credentials(data:)
37+
SiteSetting.discourse_id_client_secret = data["client_secret"]
38+
end
39+
40+
def log_action(guardian:)
41+
StaffActionLogger.new(guardian.user).log_custom(
42+
"discourse_id_regenerate_credentials",
43+
client_id: SiteSetting.discourse_id_client_id,
44+
)
45+
end
46+
end

app/services/discourse_id/register.rb

Lines changed: 12 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
class DiscourseId::Register
44
include Service::Base
5+
include DiscourseId::Concerns::ChallengeFlow
56

67
params do
78
attribute :force, :boolean, default: false
@@ -13,6 +14,7 @@ class DiscourseId::Register
1314
step :store_challenge_token
1415
step :register_with_challenge
1516
step :store_credentials
17+
step :log_action
1618

1719
private
1820

@@ -23,64 +25,7 @@ def not_already_registered?(params:)
2325
SiteSetting.discourse_id_client_id.blank? && SiteSetting.discourse_id_client_secret.blank?
2426
end
2527

26-
def request_challenge
27-
uri = URI("#{discourse_id_url}/challenge")
28-
use_ssl = Rails.env.production? || uri.scheme == "https"
29-
30-
body = { domain: Discourse.current_hostname }
31-
body[:path] = Discourse.base_path if Discourse.base_path.present?
32-
33-
request = Net::HTTP::Post.new(uri)
34-
request.content_type = "application/json"
35-
request.body = body.to_json
36-
37-
begin
38-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl:) { |http| http.request(request) }
39-
rescue StandardError => e
40-
error = "Challenge request to '#{uri}' failed: #{e.message}."
41-
log_error("request_challenge", error)
42-
return fail!(error:)
43-
end
44-
45-
if response.code.to_i != 200
46-
error = "Failed to request challenge: #{response.code}\nError: #{response.body}"
47-
log_error("request_challenge", error)
48-
return fail!(error:)
49-
end
50-
51-
begin
52-
json = JSON.parse(response.body)
53-
rescue JSON::ParserError => e
54-
error = "Challenge response invalid JSON: #{e.message}"
55-
log_error("request_challenge", error)
56-
return fail!(error:)
57-
end
58-
59-
if json["domain"] != Discourse.current_hostname
60-
error =
61-
"Domain mismatch in challenge response (expected: #{Discourse.current_hostname}, got: #{json["domain"]})"
62-
log_error("request_challenge", error)
63-
return fail!(error:)
64-
end
65-
66-
if Discourse.base_path.present? && json["path"] != Discourse.base_path
67-
error =
68-
"Path mismatch in challenge response (expected: #{Discourse.base_path}, got: #{json["path"]})"
69-
log_error("request_challenge", error)
70-
return fail!(error:)
71-
end
72-
73-
context[:token] = json["token"]
74-
end
75-
76-
def store_challenge_token(token:)
77-
Discourse.redis.setex("discourse_id_challenge_token", 600, token)
78-
end
79-
8028
def register_with_challenge(token:, params:)
81-
uri = URI("#{discourse_id_url}/register")
82-
use_ssl = Rails.env.production? || uri.scheme == "https"
83-
8429
body = {
8530
client_name: SiteSetting.title,
8631
redirect_uri: "#{Discourse.base_url}/auth/discourse_id/callback",
@@ -96,31 +41,11 @@ def register_with_challenge(token:, params:)
9641
body[:client_secret] = SiteSetting.discourse_id_client_secret
9742
end
9843

99-
request = Net::HTTP::Post.new(uri)
100-
request.content_type = "application/json"
101-
request.body = body.compact.to_json
44+
response = post_json("/register", body.compact)
10245

103-
begin
104-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl:) { |http| http.request(request) }
105-
rescue StandardError => e
106-
error = "Registration request to '#{uri}' failed: #{e.message}."
107-
log_error("register_with_challenge", error)
108-
return fail!(error:)
109-
end
110-
111-
if response.code.to_i != 200
112-
error = "Registration failed: #{response.code}\nError: #{response.body}"
113-
log_error("register_with_challenge", error)
114-
return fail!(error:)
115-
end
46+
return fail!(response[:error]) if response[:error]
11647

117-
begin
118-
context[:data] = JSON.parse(response.body)
119-
rescue JSON::ParserError => e
120-
error = "Registration response invalid JSON: #{e.message}"
121-
log_error("register_with_challenge", error)
122-
fail!(error:)
123-
end
48+
context[:data] = response[:data]
12449
end
12550

12651
def store_credentials(data:, params:)
@@ -130,11 +55,13 @@ def store_credentials(data:, params:)
13055
SiteSetting.discourse_id_client_secret = data["client_secret"]
13156
end
13257

133-
def discourse_id_url
134-
@url ||= SiteSetting.discourse_id_provider_url.presence || "https://id.discourse.com"
135-
end
58+
def log_action(guardian:, params:, data:)
59+
return if params.update
60+
return if guardian.blank?
13661

137-
def log_error(step, message)
138-
Rails.logger.error("Discourse ID registration failed at step '#{step}'. Error: #{message}")
62+
StaffActionLogger.new(guardian.user).log_custom(
63+
"discourse_id_register",
64+
client_id: data["client_id"],
65+
)
13966
end
14067
end

0 commit comments

Comments
 (0)