mirror of
https://github.com/discourse/discourse.git
synced 2025-05-24 03:36:18 +08:00
FEATURE: Restrict link invites to email domain (#15211)
Allow multiple emails to redeem a link invite only if the email domain name matches the one specified in the link invite.
This commit is contained in:
@ -1,9 +1,10 @@
|
|||||||
import Controller from "@ember/controller";
|
import Controller from "@ember/controller";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { empty, notEmpty } from "@ember/object/computed";
|
import { not } from "@ember/object/computed";
|
||||||
import discourseComputed from "discourse-common/utils/decorators";
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
import { extractError } from "discourse/lib/ajax-error";
|
import { extractError } from "discourse/lib/ajax-error";
|
||||||
import { getNativeContact } from "discourse/lib/pwa-utils";
|
import { getNativeContact } from "discourse/lib/pwa-utils";
|
||||||
|
import { emailValid, hostnameValid } from "discourse/lib/utilities";
|
||||||
import { bufferedProperty } from "discourse/mixins/buffered-content";
|
import { bufferedProperty } from "discourse/mixins/buffered-content";
|
||||||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||||
import Group from "discourse/models/group";
|
import Group from "discourse/models/group";
|
||||||
@ -28,8 +29,17 @@ export default Controller.extend(
|
|||||||
inviteToTopic: false,
|
inviteToTopic: false,
|
||||||
limitToEmail: false,
|
limitToEmail: false,
|
||||||
|
|
||||||
isLink: empty("buffered.email"),
|
@discourseComputed("buffered.emailOrDomain")
|
||||||
isEmail: notEmpty("buffered.email"),
|
isEmail(emailOrDomain) {
|
||||||
|
return emailValid(emailOrDomain);
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("buffered.emailOrDomain")
|
||||||
|
isDomain(emailOrDomain) {
|
||||||
|
return hostnameValid(emailOrDomain);
|
||||||
|
},
|
||||||
|
|
||||||
|
isLink: not("isEmail"),
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
Group.findAll().then((groups) => {
|
Group.findAll().then((groups) => {
|
||||||
@ -67,6 +77,15 @@ export default Controller.extend(
|
|||||||
save(opts) {
|
save(opts) {
|
||||||
const data = { ...this.buffered.buffer };
|
const data = { ...this.buffered.buffer };
|
||||||
|
|
||||||
|
if (data.emailOrDomain) {
|
||||||
|
if (emailValid(data.emailOrDomain)) {
|
||||||
|
data.email = data.emailOrDomain;
|
||||||
|
} else if (hostnameValid(data.emailOrDomain)) {
|
||||||
|
data.domain = data.emailOrDomain;
|
||||||
|
}
|
||||||
|
delete data.emailOrDomain;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.groupIds !== undefined) {
|
if (data.groupIds !== undefined) {
|
||||||
data.group_ids = data.groupIds.length > 0 ? data.groupIds : "";
|
data.group_ids = data.groupIds.length > 0 ? data.groupIds : "";
|
||||||
delete data.groupIds;
|
delete data.groupIds;
|
||||||
|
@ -142,6 +142,12 @@ export function emailValid(email) {
|
|||||||
return re.test(email);
|
return re.test(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hostnameValid(hostname) {
|
||||||
|
// see: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
|
||||||
|
const re = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
|
||||||
|
return hostname && re.test(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractDomainFromUrl(url) {
|
export function extractDomainFromUrl(url) {
|
||||||
if (url.indexOf("://") > -1) {
|
if (url.indexOf("://") > -1) {
|
||||||
url = url.split("/")[2];
|
url = url.split("/")[2];
|
||||||
|
@ -49,6 +49,11 @@ const Invite = EmberObject.extend({
|
|||||||
return topicData ? Topic.create(topicData) : null;
|
return topicData ? Topic.create(topicData) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@discourseComputed("email", "domain")
|
||||||
|
emailOrDomain(email, domain) {
|
||||||
|
return email || domain;
|
||||||
|
},
|
||||||
|
|
||||||
topicId: alias("topics.firstObject.id"),
|
topicId: alias("topics.firstObject.id"),
|
||||||
topicTitle: alias("topics.firstObject.title"),
|
topicTitle: alias("topics.firstObject.title"),
|
||||||
});
|
});
|
||||||
|
@ -37,12 +37,21 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="input-group input-email">
|
<div class="input-group input-email">
|
||||||
<label for="invite-email">{{d-icon "envelope"}}{{i18n "user.invited.invite.restrict_email"}}</label>
|
<label for="invite-email">
|
||||||
|
{{d-icon "envelope"}}
|
||||||
|
{{#if isEmail}}
|
||||||
|
{{i18n "user.invited.invite.restrict_email"}}
|
||||||
|
{{else if isDomain}}
|
||||||
|
{{i18n "user.invited.invite.restrict_domain"}}
|
||||||
|
{{else}}
|
||||||
|
{{i18n "user.invited.invite.restrict_email_or_domain"}}
|
||||||
|
{{/if}}
|
||||||
|
</label>
|
||||||
<div class="invite-email-container">
|
<div class="invite-email-container">
|
||||||
{{input
|
{{input
|
||||||
id="invite-email"
|
id="invite-email"
|
||||||
value=buffered.email
|
value=buffered.emailOrDomain
|
||||||
placeholderKey="topic.invite_reply.email_placeholder"
|
placeholderKey="user.invited.invite.email_or_domain_placeholder"
|
||||||
}}
|
}}
|
||||||
{{#if capabilities.hasContactPicker}}
|
{{#if capabilities.hasContactPicker}}
|
||||||
{{d-button
|
{{d-button
|
||||||
|
@ -134,6 +134,7 @@ class InvitesController < ApplicationController
|
|||||||
begin
|
begin
|
||||||
invite = Invite.generate(current_user,
|
invite = Invite.generate(current_user,
|
||||||
email: params[:email],
|
email: params[:email],
|
||||||
|
domain: params[:domain],
|
||||||
skip_email: params[:skip_email],
|
skip_email: params[:skip_email],
|
||||||
invited_by: current_user,
|
invited_by: current_user,
|
||||||
custom_message: params[:custom_message],
|
custom_message: params[:custom_message],
|
||||||
@ -210,6 +211,17 @@ class InvitesController < ApplicationController
|
|||||||
Invite.emailed_status_types[:not_required]
|
Invite.emailed_status_types[:not_required]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
invite.domain = nil if invite.email.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
if params.has_key?(:domain)
|
||||||
|
invite.domain = params[:domain]
|
||||||
|
|
||||||
|
if invite.domain.present?
|
||||||
|
invite.email = nil
|
||||||
|
invite.emailed_status = Invite.emailed_status_types[:not_required]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:send_email]
|
if params[:send_email]
|
||||||
|
@ -15,6 +15,7 @@ class Invite < ActiveRecord::Base
|
|||||||
}
|
}
|
||||||
|
|
||||||
BULK_INVITE_EMAIL_LIMIT = 200
|
BULK_INVITE_EMAIL_LIMIT = 200
|
||||||
|
HOSTNAME_REGEX = /\A(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])\z/
|
||||||
|
|
||||||
rate_limit :limit_invites_per_day
|
rate_limit :limit_invites_per_day
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ class Invite < ActiveRecord::Base
|
|||||||
validates_presence_of :invited_by_id
|
validates_presence_of :invited_by_id
|
||||||
validates :email, email: true, allow_blank: true
|
validates :email, email: true, allow_blank: true
|
||||||
validate :ensure_max_redemptions_allowed
|
validate :ensure_max_redemptions_allowed
|
||||||
|
validate :valid_domain, if: :will_save_change_to_domain?
|
||||||
validate :user_doesnt_already_exist
|
validate :user_doesnt_already_exist
|
||||||
|
|
||||||
before_create do
|
before_create do
|
||||||
@ -143,7 +145,7 @@ class Invite < ActiveRecord::Base
|
|||||||
emailed_status: emailed_status
|
emailed_status: emailed_status
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
create_args = opts.slice(:email, :moderator, :custom_message, :max_redemptions_allowed)
|
create_args = opts.slice(:email, :domain, :moderator, :custom_message, :max_redemptions_allowed)
|
||||||
create_args[:invited_by] = invited_by
|
create_args[:invited_by] = invited_by
|
||||||
create_args[:email] = email
|
create_args[:email] = email
|
||||||
create_args[:emailed_status] = emailed_status
|
create_args[:emailed_status] = emailed_status
|
||||||
@ -236,12 +238,10 @@ class Invite < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.invalidate_for_email(email)
|
def self.invalidate_for_email(email)
|
||||||
i = Invite.find_by(email: Email.downcase(email))
|
invite = Invite.find_by(email: Email.downcase(email))
|
||||||
if i
|
invite.update!(invalidated_at: Time.zone.now) if invite
|
||||||
i.invalidated_at = Time.zone.now
|
|
||||||
i.save
|
invite
|
||||||
end
|
|
||||||
i
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def resend_invite
|
def resend_invite
|
||||||
@ -286,6 +286,16 @@ class Invite < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def valid_domain
|
||||||
|
return if self.domain.blank?
|
||||||
|
|
||||||
|
self.domain.downcase!
|
||||||
|
|
||||||
|
if self.domain !~ Invite::HOSTNAME_REGEX
|
||||||
|
self.errors.add(:base, I18n.t('invite.domain_not_allowed', domain: self.domain))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
@ -308,6 +318,7 @@ end
|
|||||||
# redemption_count :integer default(0), not null
|
# redemption_count :integer default(0), not null
|
||||||
# expires_at :datetime not null
|
# expires_at :datetime not null
|
||||||
# email_token :string
|
# email_token :string
|
||||||
|
# domain :string
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
@ -19,6 +19,13 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
|
|||||||
available_username = UserNameSuggester.suggest(email)
|
available_username = UserNameSuggester.suggest(email)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if email.present? && invite.domain.present?
|
||||||
|
username, domain = email.split('@')
|
||||||
|
if domain.present? && invite.domain != domain
|
||||||
|
raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
user = User.where(staged: true).with_email(email.strip.downcase).first
|
user = User.where(staged: true).with_email(email.strip.downcase).first
|
||||||
user.unstage! if user
|
user.unstage! if user
|
||||||
user ||= User.new
|
user ||= User.new
|
||||||
|
@ -5,6 +5,7 @@ class InviteSerializer < ApplicationSerializer
|
|||||||
:invite_key,
|
:invite_key,
|
||||||
:link,
|
:link,
|
||||||
:email,
|
:email,
|
||||||
|
:domain,
|
||||||
:emailed,
|
:emailed,
|
||||||
:max_redemptions_allowed,
|
:max_redemptions_allowed,
|
||||||
:redemption_count,
|
:redemption_count,
|
||||||
|
@ -1621,7 +1621,10 @@ en:
|
|||||||
show_advanced: "Show Advanced Options"
|
show_advanced: "Show Advanced Options"
|
||||||
hide_advanced: "Hide Advanced Options"
|
hide_advanced: "Hide Advanced Options"
|
||||||
|
|
||||||
|
restrict_email_or_domain: "Restrict to email or domain"
|
||||||
|
email_or_domain_placeholder: "name@example.com or example.com"
|
||||||
restrict_email: "Restrict to email"
|
restrict_email: "Restrict to email"
|
||||||
|
restrict_domain: "Restrict to domain"
|
||||||
|
|
||||||
max_redemptions_allowed: "Max uses"
|
max_redemptions_allowed: "Max uses"
|
||||||
|
|
||||||
|
@ -259,6 +259,7 @@ en:
|
|||||||
discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled."
|
discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled."
|
||||||
invalid_access: "You are not permitted to view the requested resource."
|
invalid_access: "You are not permitted to view the requested resource."
|
||||||
requires_groups: "Invite saved. To give access to the specified topic, add one of the following groups: %{groups}."
|
requires_groups: "Invite saved. To give access to the specified topic, add one of the following groups: %{groups}."
|
||||||
|
domain_not_allowed: "Your email cannot be used to redeem this invite."
|
||||||
|
|
||||||
bulk_invite:
|
bulk_invite:
|
||||||
file_should_be_csv: "The uploaded file should be of csv format."
|
file_should_be_csv: "The uploaded file should be of csv format."
|
||||||
|
7
db/migrate/20211207130646_add_domain_to_invites.rb
Normal file
7
db/migrate/20211207130646_add_domain_to_invites.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddDomainToInvites < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :invites, :domain, :string
|
||||||
|
end
|
||||||
|
end
|
@ -677,6 +677,31 @@ describe InvitesController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with a domain invite' do
|
||||||
|
fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required], domain: 'example.com') }
|
||||||
|
|
||||||
|
it 'creates an user if email matches domain' do
|
||||||
|
expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' } }
|
||||||
|
.to change { User.count }
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body['message']).to eq(I18n.t('invite.confirm_email'))
|
||||||
|
expect(invite.reload.redemption_count).to eq(1)
|
||||||
|
|
||||||
|
invited_user = User.find_by_email('test@example.com')
|
||||||
|
expect(invited_user).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create an user if email does not match domain' do
|
||||||
|
expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example2.com', password: 'verystrongpassword' } }
|
||||||
|
.not_to change { User.count }
|
||||||
|
|
||||||
|
expect(response.status).to eq(412)
|
||||||
|
expect(response.parsed_body['message']).to eq(I18n.t('invite.domain_not_allowed'))
|
||||||
|
expect(invite.reload.redemption_count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'with an invite link' do
|
context 'with an invite link' do
|
||||||
fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required]) }
|
fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required]) }
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user