Skip to content

Commit

Permalink
Begin work on recaptcha for api key signups.
Browse files Browse the repository at this point in the history
  • Loading branch information
GUI committed Nov 18, 2023
1 parent ca4cc30 commit d1e44b0
Show file tree
Hide file tree
Showing 9 changed files with 321 additions and 40 deletions.
3 changes: 3 additions & 0 deletions config/schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ import "path"
default_host?: string
send_notify_email?: bool
admin_notify_email?: string
recaptcha_v2_secret_key?: string
recaptcha_v3_secret_key?: string
}

static_site: {
Expand All @@ -379,6 +381,7 @@ import "path"
port: uint16 | *14013
}
build_dir: string | *path.Join([_embedded_root_dir, "app/build/dist/example-website"])
api_key?: string
}

router: {
Expand Down
18 changes: 17 additions & 1 deletion db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,14 @@ CREATE TABLE api_umbrella.api_users (
updated_at timestamp with time zone NOT NULL,
updated_by_id uuid NOT NULL,
updated_by_username character varying(255) NOT NULL,
cached_api_role_ids jsonb
cached_api_role_ids jsonb,
registration_key_creator_api_user_id uuid,
registration_recaptcha_v2_success boolean,
registration_recaptcha_v2_error_codes character varying(50)[],
registration_recaptcha_v3_success boolean,
registration_recaptcha_v3_score numeric(2,1),
registration_recaptcha_v3_action character varying(255),
registration_recaptcha_v3_error_codes character varying(50)[]
);


Expand Down Expand Up @@ -2753,6 +2760,14 @@ ALTER TABLE ONLY api_umbrella.api_user_settings
ADD CONSTRAINT api_user_settings_api_user_id_fkey FOREIGN KEY (api_user_id) REFERENCES api_umbrella.api_users(id) ON DELETE CASCADE DEFERRABLE;


--
-- Name: api_users api_users_registration_key_creator_api_user_id_fkey; Type: FK CONSTRAINT; Schema: api_umbrella; Owner: -
--

ALTER TABLE ONLY api_umbrella.api_users
ADD CONSTRAINT api_users_registration_key_creator_api_user_id_fkey FOREIGN KEY (registration_key_creator_api_user_id) REFERENCES api_umbrella.api_users(id) ON DELETE RESTRICT;


--
-- Name: api_users_roles api_users_roles_api_role_id_fkey; Type: FK CONSTRAINT; Schema: api_umbrella; Owner: -
--
Expand Down Expand Up @@ -2802,3 +2817,4 @@ INSERT INTO api_umbrella.lapis_migrations (name) VALUES ('1651280172');
INSERT INTO api_umbrella.lapis_migrations (name) VALUES ('1699559596');
INSERT INTO api_umbrella.lapis_migrations (name) VALUES ('1699559696');
INSERT INTO api_umbrella.lapis_migrations (name) VALUES ('1699650325');
INSERT INTO api_umbrella.lapis_migrations (name) VALUES ('1700281762');
177 changes: 149 additions & 28 deletions src/api-umbrella/example-website/assets/javascripts/signup_embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ function insertLink(root, options) {
root.appendChild(link);
}

function insertScript(root, options) {
const script = document.createElement("script");
script.type = options.type;
script.src = options.src;
root.appendChild(script);
}

const webSiteRoot = params.webSiteRoot.replace(/\/$/, "");

const defaultOptions = {
Expand All @@ -37,6 +44,8 @@ const defaultOptions = {
showTermsInput: true,
termsUrl: `${webSiteRoot}/terms/`,
verifyEmail: false,
recaptchaV2SiteKey: undefined,
recaptchaV3SiteKey: undefined,
};

const embedOptions = window.apiUmbrellaSignupOptions || {};
Expand Down Expand Up @@ -71,6 +80,10 @@ if (!options.registrationSource) {
}

let signupFormTemplate = "";
let recaptchaV2WidgetId;
let recaptchaV2Response;
let recaptchaV3WidgetId;
let recaptchaV3Response;

if (options.showIntroText) {
signupFormTemplate += `
Expand Down Expand Up @@ -98,7 +111,7 @@ if (options.showFirstNameInput) {
`;
} else {
signupFormTemplate += `<input type="hidden" name="user[first_name]" value="${escapeHtml(
options.registrationSource
options.registrationSource,
)} User" />`;
}

Expand All @@ -112,7 +125,7 @@ if (options.showLastNameInput) {
`;
} else {
signupFormTemplate += `<input type="hidden" name="user[last_name]" value="${escapeHtml(
options.registrationSource
options.registrationSource,
)} User" />`;
}

Expand Down Expand Up @@ -149,7 +162,7 @@ if (options.showTermsInput) {
<div class="form-check">
<input id="user_terms_and_conditions" aria-describedby="user_terms_and_conditions_feedback" name="user[terms_and_conditions]" type="checkbox" class="form-check-input" value="true" required />
<label class="form-check-label" for="user_terms_and_conditions">I have read and agree to the <a href="${escapeHtml(
options.termsUrl
options.termsUrl,
)}" onclick="window.open(this.href, &#x27;api_umbrella_terms&#x27;, &#x27;height=500,width=790,menubar=no,toolbar=no,location=no,personalbar=no,status=no,resizable=yes,scrollbars=yes&#x27;); return false;" title="Opens new window to terms and conditions">terms and conditions</a>.</label>
<div id="user_terms_and_conditions_feedback" class="invalid-feedback">You must agree to the terms and conditions to signup.</div>
</div>
Expand All @@ -160,15 +173,20 @@ if (options.showTermsInput) {
}

signupFormTemplate += `
<div class="submit">
<input type="hidden" name="user[registration_source]" value="${escapeHtml(
options.registrationSource
)}" />
<button type="submit" class="btn btn-lg btn-primary" data-loading-text="Loading...">Signup</button>
</div>
</form>
<div class="submit">
<input type="hidden" name="user[registration_source]" value="${escapeHtml(
options.registrationSource,
)}" />
<button type="submit" class="btn btn-lg btn-primary" data-loading-text="Loading...">Signup</button>
</div>
`;

if (options.recaptchaV2SiteKey || options.recaptchaV3SiteKey) {
signupFormTemplate += `<div class="recaptcha-notice">This site is protected by reCAPTCHA and the Google <a href="https://policies.google.com/privacy">Privacy Policy</a> and <a href="https://policies.google.com/terms">Terms of Service</a> apply.</div>`;
}

signupFormTemplate += `</form>`;

const modalTemplate = `
<div id="alert_modal" class="dialog-container" aria-describedby="alert_modal_message" aria-hidden="true">
<div class="modal-backdrop show" data-a11y-dialog-hide></div>
Expand All @@ -189,18 +207,36 @@ const modalTemplate = `
`;

const containerEl = document.querySelector(options.containerSelector);
const containerShadowRootEl = containerEl.attachShadow({ mode: "open" });
containerEl.textContent = "";
const containerContentEl = document.createElement("div");
containerEl.appendChild(containerContentEl);
const containerShadowRootEl = containerContentEl.attachShadow({ mode: "open" });

// The recaptcha elements need to exist outside of the shadow DOM for recaptcha
// compatibility.
let recaptchaV2El;
if (options.recaptchaV2SiteKey) {
recaptchaV2El = document.createElement("div");
recaptchaV2El.style = "visibility: hidden;";
containerEl.appendChild(recaptchaV2El);
}
let recaptchaV3El;
if (options.recaptchaV3SiteKey) {
recaptchaV3El = document.createElement("div");
recaptchaV3El.style = "visibility: hidden;";
containerEl.appendChild(recaptchaV3El);
}

// Compute how big the font size is wherever the container is being injected
// and then compare that to the root font size, so we can fix `rem` units with
// the help of https://github.com/GUI/postcss-relative-rem
const rootFontSize = parseFloat(
window
.getComputedStyle(document.documentElement)
.getPropertyValue("font-size")
.getPropertyValue("font-size"),
);
const containerFontSize = parseFloat(
window.getComputedStyle(containerEl).getPropertyValue("font-size")
window.getComputedStyle(containerEl).getPropertyValue("font-size"),
);
const remRelativeBaseSize = `${containerFontSize / rootFontSize}rem`;

Expand All @@ -209,7 +245,7 @@ containerStyleRootEl.className = "app-style-root";
containerStyleRootEl.innerHTML = signupFormTemplate;
containerStyleRootEl.style.setProperty(
"--api-umbrella-rem-relative-base",
remRelativeBaseSize
remRelativeBaseSize,
);
containerShadowRootEl.appendChild(containerStyleRootEl);

Expand All @@ -223,7 +259,7 @@ bodyContainerStyleRootEl.className = "app-style-root";
bodyContainerStyleRootEl.innerHTML = modalTemplate;
bodyContainerStyleRootEl.style.setProperty(
"--api-umbrella-rem-relative-base",
remRelativeBaseSize
remRelativeBaseSize,
);
bodyContainerShadowRootEl.appendChild(bodyContainerStyleRootEl);
document.body.appendChild(bodyContainerEl);
Expand All @@ -243,14 +279,7 @@ const modalMessageEl = modalEl.querySelector("#alert_modal_message");
const modal = new A11yDialog(modalEl);

const formEl = containerShadowRootEl.querySelector("form");
formEl.addEventListener("submit", (event) => {
event.preventDefault();

if (!formEl.checkValidity()) {
formEl.classList.add("was-validated");
return false;
}

function submitFetch() {
const submitButtonEl = formEl.querySelector("button[type=submit]");
const submitButtonOrig = submitButtonEl.innerHTML;
setTimeout(() => {
Expand All @@ -275,13 +304,23 @@ formEl.addEventListener("submit", (event) => {
formData.user.terms_and_conditions = true;
}

if (options.recaptchaV2SiteKey) {
formData["g-recaptcha-response-v2"] = recaptchaV2Response;
}

if (options.recaptchaV3SiteKey) {
formData["g-recaptcha-response-v3"] = recaptchaV3Response;
}

return fetch(`${options.apiUrlRoot}/v1/users.json`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": options.apiKey,
},
body: JSON.stringify(formData),
// Ensure admin credentials aren't sent in for signed in admins.
credentials: "omit",
})
.then((response) => {
const contentType = response.headers.get("Content-Type");
Expand All @@ -306,10 +345,10 @@ formEl.addEventListener("submit", (event) => {
if (data.options.verify_email) {
confirmationTemplate += `
<p>Your API key for <strong>${escapeHtml(
user.email
user.email,
)}</strong> has been e-mailed to you. You can use your API key to begin making web service requests immediately.</p>
<p>If you don't receive your API Key via e-mail within a few minutes, please <a href="${escapeHtml(
data.options.contact_url
data.options.contact_url,
)}">contact us</a>.</p>
`;
} else {
Expand All @@ -318,7 +357,7 @@ formEl.addEventListener("submit", (event) => {
<pre class="signup-key"><code>${escapeHtml(user.api_key)}</code></pre>
<p>You can start using this key to make web service requests. Simply pass your key in the URL when making a web request. Here's an example:</p>
<pre class="signup-example"><a href="${escapeHtml(
data.options.example_api_url
data.options.example_api_url,
)}">${data.options.example_api_url_formatted_html}</a></pre>
`;
}
Expand All @@ -327,7 +366,7 @@ formEl.addEventListener("submit", (event) => {
${options.signupConfirmationMessage}
<div class="signup-footer">
<p>For additional support, please <a href="${escapeHtml(
data.options.contact_url
data.options.contact_url,
)}">contact us</a>. When contacting us, please tell us what API you're accessing and provide the following account details so we can quickly find you:</p>
Account Email: ${escapeHtml(user.email)}<br>
Account ID: ${escapeHtml(user.id)}
Expand Down Expand Up @@ -366,12 +405,94 @@ formEl.addEventListener("submit", (event) => {
}

modalMessageEl.innerHTML = `API key signup unexpectedly failed.${messageStr}<br>Please try again or <a href="${escapeHtml(
options.issuesUrl
options.issuesUrl,
)}">file an issue</a> for assistance.`;
modal.show();
})
.finally(() => {
submitButtonEl.disabled = false;
submitButtonEl.innerHTML = submitButtonOrig;
});
}

window.apiUmbrellaRecaptchaLoadCallback =
function apiUmbrellaRecaptchaLoadCallback() {
if (options.recaptchaV2SiteKey) {
recaptchaV2WidgetId = window.grecaptcha.render(recaptchaV2El, {
sitekey: options.recaptchaV2SiteKey,
size: "invisible",
isolated: true,
callback: () => {
recaptchaV2Response =
window.grecaptcha.getResponse(recaptchaV2WidgetId);
if (
(options.recaptchaV3SiteKey && recaptchaV3Response) ||
!options.recaptchaV3SiteKey
) {
submitFetch();
}
},
});
}

if (options.recaptchaV3SiteKey) {
recaptchaV3WidgetId = window.grecaptcha.render(recaptchaV3El, {
sitekey: options.recaptchaV3SiteKey,
size: "invisible",
isolated: true,
callback: () => {
recaptchaV3Response =
window.grecaptcha.getResponse(recaptchaV3WidgetId);
if (
(options.recaptchaV2SiteKey && recaptchaV2Response) ||
!options.recaptchaV2SiteKey
) {
submitFetch();
}
},
});
}
};

if (options.recaptchaV2SiteKey || options.recaptchaV3SiteKey) {
insertScript(document.body, {
src: `https://www.google.com/recaptcha/api.js?render=explicit&onload=apiUmbrellaRecaptchaLoadCallback`,
type: "text/javascript",
async: true,
defer: true,
});
}

formEl.addEventListener("submit", (event) => {
event.preventDefault();

if (!formEl.checkValidity()) {
formEl.classList.add("was-validated");
return false;
}

if (options.recaptchaV2SiteKey || options.recaptchaV3SiteKey) {
try {
if (options.recaptchaV2SiteKey) {
recaptchaV2Response = null;
window.grecaptcha.execute(recaptchaV2WidgetId);
}

if (options.recaptchaV3SiteKey) {
recaptchaV3Response = null;
window.grecaptcha.execute(recaptchaV3WidgetId);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
// eslint-disable-next-line no-alert
alert(
"Unexpected error occurred while validating CAPTCHA. Please try again or contact us for assistance",
);
}
} else {
submitFetch();
}

return undefined;
});
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,10 @@ pre.signup-example {
.form-group {
margin-bottom: 1rem;
}

.recaptcha-notice {
margin-top: 1rem;
text-align: right;
font-size: 0.7rem;
color: #767676;
}
Loading

0 comments on commit d1e44b0

Please sign in to comment.