├── .eslintrc
├── test
├── modules
│ └── @apostrophecms
│ │ └── home-page
│ │ └── views
│ │ └── page.html
├── package.json
└── test.js
├── .gitignore
├── i18n
└── AposHcaptcha
│ ├── en.json
│ ├── sk.json
│ ├── es.json
│ ├── pt-BR.json
│ ├── fr.json
│ ├── it.json
│ └── de.json
├── CHANGELOG.md
├── LICENSE.md
├── package.json
├── .github
└── workflows
│ └── main.yml
├── ui
└── apos
│ └── components
│ └── AposHcaptcha.vue
├── index.js
└── README.md
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [ "apostrophe" ]
3 | }
--------------------------------------------------------------------------------
/test/modules/@apostrophecms/home-page/views/page.html:
--------------------------------------------------------------------------------
1 |
{{ data.page.title }}
2 | Home Page Template
3 | {# Used for the login tests. #}
4 | {% if data.user %}
5 | logged in
6 | {% else %}
7 | logged out
8 | {% endif %}
9 |
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore MacOS X metadata forks (fusefs)
2 | ._*
3 | package-lock.json
4 | *.DS_Store
5 | node_modules
6 |
7 | # Never commit a CSS map file, anywhere
8 | *.css.map
9 |
10 | # vim swp files
11 | .*.sw*
12 |
13 |
14 | test/data
15 | test/public
16 |
--------------------------------------------------------------------------------
/i18n/AposHcaptcha/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "captchaErr": "The hCaptcha verification system may be down or incorrectly configured. Please try again or notify the site owner.",
3 | "invalidToken": "There was a problem validating your hCaptcha verification submission.",
4 | "missingConfig": "The hCaptcha token was missing while verifying login."
5 | }
6 |
--------------------------------------------------------------------------------
/i18n/AposHcaptcha/sk.json:
--------------------------------------------------------------------------------
1 | {
2 | "captchaErr": "Systém overovania hCaptcha môže byť nefunkčný alebo nesprávne nakonfigurovaný. Skúste to prosím znova alebo informujte vlastníka stránky.",
3 | "invalidToken": "Pri overovaní vašej hCaptcha overovacej žiadosti došlo k problému.",
4 | "missingConfig": "Token hCaptcha chýbal počas overovania prihlásenia."
5 | }
6 |
--------------------------------------------------------------------------------
/i18n/AposHcaptcha/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "captchaErr": "El sistema de verificación hCaptcha puede estar caído o mal configurado. Por favor, inténtalo de nuevo o notifica al propietario del sitio.",
3 | "invalidToken": "Hubo un problema al validar tu envío de verificación hCaptcha.",
4 | "missingConfig": "El token hCaptcha faltaba al verificar el inicio de sesión."
5 | }
6 |
--------------------------------------------------------------------------------
/i18n/AposHcaptcha/pt-BR.json:
--------------------------------------------------------------------------------
1 | {
2 | "captchaErr": "O sistema de verificação hCaptcha pode estar fora do ar ou configurado incorretamente. Por favor, tente novamente ou notifique o proprietário do site.",
3 | "invalidToken": "Houve um problema ao validar sua submissão de verificação hCaptcha.",
4 | "missingConfig": "O token hCaptcha estava ausente durante a verificação do login."
5 | }
6 |
--------------------------------------------------------------------------------
/i18n/AposHcaptcha/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "captchaErr": "Le système de vérification hCaptcha peut être hors service ou mal configuré. Veuillez réessayer ou informer le propriétaire du site.",
3 | "invalidToken": "Il y a eu un problème lors de la validation de votre soumission de vérification hCaptcha.",
4 | "missingConfig": "Le jeton hCaptcha manquait lors de la vérification de la connexion."
5 | }
6 |
--------------------------------------------------------------------------------
/i18n/AposHcaptcha/it.json:
--------------------------------------------------------------------------------
1 | {
2 | "captchaErr": "Il sistema di verifica hCaptcha potrebbe essere inattivo o configurato in modo errato. Per favore, riprova o informa il proprietario del sito.",
3 | "invalidToken": "Si è verificato un problema durante la convalida della tua sottomissione di verifica hCaptcha.",
4 | "missingConfig": "Il token hCaptcha era mancante durante la verifica del login."
5 | }
6 |
--------------------------------------------------------------------------------
/i18n/AposHcaptcha/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "captchaErr": "Das hCaptcha-Verifizierungssystem könnte nicht verfügbar oder fehlerhaft konfiguriert sein. Bitte versuchen Sie es erneut oder benachrichtigen Sie den Website-Eigentümer.",
3 | "invalidToken": "Es gab ein Problem bei der Validierung Ihrer hCaptcha-Verifizierungseinreichung.",
4 | "missingConfig": "Das hCaptcha-Token fehlte bei der Verifizierung des Logins."
5 | }
6 |
--------------------------------------------------------------------------------
/test/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "//": "This package.json file is not actually installed.",
3 | "//": "Apostrophe requires that all npm modules to be loaded by moog",
4 | "//": "exist in package.json at project level, which for a test is here",
5 | "dependencies": {
6 | "apostrophe": "^3.25.0",
7 | "@apostrophecms/login-hcaptcha": "git://github.com/apostrophecms/login-hcaptcha.git"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 1.2.1 (2024-10-03)
4 |
5 | - Adds translation strings
6 |
7 | ## 1.2.0 - 2023-08-16
8 |
9 | ### Adds
10 |
11 | - Add `hcaptcha-complete` and `hcaptcha-invalid-token` structured logging events.
12 |
13 | ## 1.1.1 - 2023-02-17
14 |
15 | - Remove `apostrophe` as a peer dependency.
16 |
17 | ## 1.1.0 - 2023-01-18
18 |
19 | ### Fixes
20 |
21 | - Remove auto trigger hCaptcha workflow. The user now needs to manually check the box to start the hCaptcha workflow.
22 |
23 | ## 1.0.0 2022-09-15
24 |
25 | ### Adds
26 |
27 | - Initial release
28 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2022 Apostrophe Technologies
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@apostrophecms/login-hcaptcha",
3 | "version": "1.2.1",
4 | "description": "Adds hCaptcha to Apostrophe login pages",
5 | "main": "index.js",
6 | "scripts": {
7 | "lint": "npm run eslint",
8 | "eslint": "eslint .",
9 | "test": "npm run lint && mocha"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/apostrophecms/login-hcaptcha.git"
14 | },
15 | "keywords": [
16 | "apostrophecms",
17 | "captcha",
18 | "hcaptcha"
19 | ],
20 | "homepage": "https://github.com/apostrophecms/login-hcaptcha#readme",
21 | "author": "Apostrophe Technologies",
22 | "license": "MIT",
23 | "devDependencies": {
24 | "apostrophe": "github:apostrophecms/apostrophe",
25 | "eslint": "^7.9.0",
26 | "eslint-config-apostrophe": "^3.4.0",
27 | "eslint-config-standard": "^14.1.1",
28 | "eslint-plugin-import": "^2.22.0",
29 | "eslint-plugin-node": "^11.1.0",
30 | "eslint-plugin-promise": "^4.2.1",
31 | "eslint-plugin-standard": "^4.0.1",
32 | "mocha": "^7.2.0"
33 | }
34 | }
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 |
3 | name: tests
4 |
5 | # Controls when the action will run.
6 | on:
7 | push:
8 | branches: ["main"]
9 | pull_request:
10 | branches: ["*"]
11 |
12 | # Allows you to run this workflow manually from the Actions tab
13 | workflow_dispatch:
14 |
15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
16 | jobs:
17 | # This workflow contains a single job called "build"
18 | build:
19 | # The type of runner that the job will run on
20 | runs-on: ubuntu-latest
21 | strategy:
22 | matrix:
23 | node-version: [16, 18]
24 | mongodb-version: [4.4, 5.0, 6.0]
25 |
26 | # Steps represent a sequence of tasks that will be executed as part of the job
27 | steps:
28 | - name: Git checkout
29 | uses: actions/checkout@v2
30 |
31 | - name: Use Node.js ${{ matrix.node-version }}
32 | uses: actions/setup-node@v1
33 | with:
34 | node-version: ${{ matrix.node-version }}
35 |
36 | - name: Start MongoDB
37 | uses: supercharge/mongodb-github-action@1.3.0
38 | with:
39 | mongodb-version: ${{ matrix.mongodb-version }}
40 |
41 | - run: npm install
42 |
43 | - run: npm test
44 | env:
45 | CI: true
46 |
--------------------------------------------------------------------------------
/ui/apos/components/AposHcaptcha.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
75 |
76 |
78 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | improve: '@apostrophecms/login',
3 | requirements(self) {
4 | if (!self.options.hcaptcha || !self.options.hcaptcha.site || !self.options.hcaptcha.secret) {
5 | throw new Error('The login hCaptcha site key, secret key, or both are not configured');
6 | }
7 |
8 | return {
9 | add: {
10 | AposHcaptcha: {
11 | phase: 'beforeSubmit',
12 | async props(req) {
13 | return {
14 | sitekey: self.options.hcaptcha.site
15 | };
16 | },
17 | async verify(req, data) {
18 | if (!data) {
19 | throw self.apos.error('invalid', req.t('AposHcaptcha:missingConfig'));
20 | }
21 |
22 | await self.checkHcaptcha(req, data);
23 | }
24 | }
25 | }
26 | };
27 | },
28 | methods(self) {
29 | return {
30 | async checkHcaptcha(req, token) {
31 | const { secret } = self.options.hcaptcha;
32 |
33 | if (!secret) {
34 | return;
35 | }
36 |
37 | try {
38 | const url = 'https://hcaptcha.com/siteverify';
39 | const options = {
40 | body: `response=${token}&secret=${secret}`,
41 | headers: {
42 | 'Content-Type': 'application/x-www-form-urlencoded'
43 | }
44 | };
45 |
46 | const response = await self.apos.http.post(url, options);
47 | if (!response.success) {
48 | self.logInfo(req, 'hcaptcha-invalid-token', {
49 | data: response
50 | });
51 | throw self.apos.error('invalid', req.t('AposHcaptcha:invalidToken'));
52 | }
53 | self.logInfo(req, 'hcaptcha-complete');
54 | } catch (error) {
55 | self.apos.util.error('hCaptcha error', error);
56 | throw self.apos.error('error', req.t('AposHcaptcha:captchaErr'));
57 | }
58 | }
59 | };
60 | }
61 | };
62 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
20 |
21 | This login verification module adds a [hCaptcha](https://hcaptcha.com) check when any user logs into the site.
22 |
23 | ## Installation
24 |
25 | To install the module, use the command line to run this command in an Apostrophe project's root directory:
26 |
27 | ```
28 | npm install @apostrophecms/login-hcaptcha
29 | ```
30 |
31 | ## Usage
32 |
33 | Instantiate the hCaptcha login module in the `app.js` file:
34 |
35 | ```javascript
36 | require('apostrophe')({
37 | shortName: 'my-project',
38 | modules: {
39 | '@apostrophecms/login-hcaptcha': {}
40 | }
41 | });
42 | ```
43 |
44 | The other requirement is to add your [hCaptcha public API site key](https://docs.hcaptcha.com/configuration#hcaptcha-container-configuration) to the `@apostrophecms/login` module (*not* this module). This module adds functionality to that module (it "improves" it, in Apostrophe speak), so most configuration should be directly on the core login module.
45 |
46 |
47 | ```javascript
48 | // modules/@apostrophecms/login/index.js
49 | module.exports = {
50 | options: {
51 | hcaptcha: {
52 | site: 'ADD YOUR SITE KEY',
53 | secret: 'ADD YOUR SECRET KEY'
54 | }
55 | }
56 | };
57 | ```
58 |
59 | Once configured, hCaptcha verification should work on all login attempts.
60 |
61 | ### Content security headers
62 |
63 | If your site has a content security policy, including if you use the [Apostrophe Security Headers](https://www.npmjs.com/package/@apostrophecms/security-headers) module, you will need to add additional configuration to use this module. This module adds a script tag to the site's `head` tag fetching hCaptcha code, so we need to allow resources from that domain.
64 |
65 | **If you are using the Apostrophe Security Headers module**, add the following policy configuration for that module:
66 |
67 | ```javascript
68 | module.exports = {
69 | options: {
70 | policies: {
71 | 'login-hcaptcha': {
72 | 'script-src': 'hcaptcha.com *.hcaptcha.com',
73 | 'frame-src': 'hcaptcha.com *.hcaptcha.com',
74 | 'style-src': 'hcaptcha.com *.hcaptcha.com',
75 | 'connect-src': 'hcaptcha.com *.hcaptcha.com'
76 | },
77 | // Any other policies...
78 | }
79 | }
80 | };
81 | ```
82 |
83 | **If your content security policy is configured some other way**, add `hcaptcha.com *.hcaptcha.com` to the `script-src`, `frame-src`, `style-src` and `connect-src` directives.
84 |
85 | Please refer to the list at https://docs.hcaptcha.com/#content-security-policy-settings for any additional settings.
86 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert').strict;
2 | const testUtil = require('apostrophe/test-lib/test');
3 |
4 | const getSiteConfig = function () {
5 | return {
6 | // hCaptcha test keys
7 | // https://docs.hcaptcha.com/#integration-testing-test-keys
8 | site: '10000000-ffff-ffff-ffff-000000000001',
9 | secret: '0x0000000000000000000000000000000000000000',
10 | response: '10000000-aaaa-bbbb-cccc-000000000001'
11 | };
12 | };
13 |
14 | const getAppConfig = function (siteConfig = getSiteConfig()) {
15 | return {
16 | '@apostrophecms/express': {
17 | options: {
18 | port: 4242,
19 | // csrf: {
20 | // exceptions: [ '/api/v1/@apostrophecms/form/submit' ]
21 | // },
22 | session: {
23 | secret: 'test-this-module'
24 | },
25 | apiKeys: {
26 | skeleton_key: { role: 'admin' }
27 | }
28 | }
29 | },
30 | '@apostrophecms/login-hcaptcha': {
31 | options: {
32 | testOption: 'surprise'
33 | }
34 | },
35 | '@apostrophecms/login': {
36 | options: {
37 | hcaptcha: {
38 | site: siteConfig.site,
39 | secret: siteConfig.secret
40 | }
41 | }
42 | }
43 | };
44 | };
45 |
46 | const getUserConfig = function () {
47 | return {
48 | username: 'marygold',
49 | pw: 'asdfjkl;'
50 | };
51 | };
52 |
53 | describe('@apostrophecms/login-hcaptcha', function () {
54 | let apos;
55 |
56 | this.timeout(25000);
57 |
58 | before(async function () {
59 | apos = await testUtil.create({
60 | shortname: 'loginTest',
61 | testModule: true,
62 | modules: getAppConfig()
63 | });
64 | });
65 |
66 | after(async function () {
67 | await testUtil.destroy(apos);
68 | });
69 |
70 | // Improving
71 | it('should improve the login module', function () {
72 | const login = apos.modules['@apostrophecms/login'];
73 |
74 | const actual = login.options.testOption;
75 | const expected = 'surprise';
76 |
77 | assert.equal(actual, expected);
78 | });
79 |
80 | it('should be able to insert test user', async function () {
81 | const mary = getUserConfig();
82 |
83 | const user = apos.user.newInstance();
84 | user.title = 'Mary Gold';
85 | user.username = mary.username;
86 | user.password = mary.pw;
87 | user.email = 'mary@gold.rocks';
88 | user.role = 'editor';
89 |
90 | const doc = await apos.user.insert(apos.task.getReq(), user);
91 |
92 | const actual = !!doc._id;
93 | const expected = true;
94 |
95 | assert.equal(actual, expected);
96 | });
97 |
98 | it('should not be able to login a user without meeting the beforeSubmit requirement', async function () {
99 | const jar = apos.http.jar();
100 | const siteConfig = getSiteConfig();
101 | const mary = getUserConfig();
102 |
103 | const actual = async function () {
104 | // establish session
105 | const page = await apos.http.get('/', { jar });
106 | assert.ok(page.match(/logged out/), 'page contains logged out in body');
107 |
108 | const context = await apos.http.post(
109 | '/api/v1/@apostrophecms/login/context',
110 | {
111 | method: 'POST',
112 | body: {},
113 | jar
114 | }
115 | );
116 |
117 | assert.equal(context.requirementProps.AposHcaptcha.sitekey, siteConfig.site);
118 |
119 | await apos.http.post(
120 | '/api/v1/@apostrophecms/login/login',
121 | {
122 | method: 'POST',
123 | body: {
124 | username: mary.username,
125 | password: mary.pw,
126 | session: true
127 | },
128 | jar
129 | }
130 | );
131 | };
132 | const expected = {
133 | name: 'Error',
134 | message: 'HTTP error 400',
135 | status: 400,
136 | body: {
137 | message: 'The hCaptcha token was missing while verifying login.',
138 | name: 'invalid',
139 | data: {
140 | requirement: 'AposHcaptcha'
141 | }
142 | }
143 | };
144 |
145 | await assert.rejects(actual, expected);
146 |
147 | // Make sure it really didn't work
148 | const page = await apos.http.get('/', { jar });
149 | assert.ok(page.match(/logged out/), 'page contains logged out in body');
150 | });
151 |
152 | it('should log in with a hcaptcha token', async function () {
153 | const mary = getUserConfig();
154 | const siteConfig = getSiteConfig();
155 |
156 | const jar = apos.http.jar();
157 |
158 | // establish session
159 | let page = await apos.http.get('/', { jar });
160 | assert.ok(page.match(/logged out/), 'page contains logged out in body');
161 |
162 | // intecept the logger
163 | let savedArgs = [];
164 | apos.login.logInfo = (...args) => {
165 | // Don't get confused by unrelated events from the login module,
166 | // capture the one we care about
167 | if (args[1] === 'hcaptcha-complete') {
168 | savedArgs = args;
169 | }
170 | };
171 |
172 | await apos.http.post(
173 | '/api/v1/@apostrophecms/login/login',
174 | {
175 | method: 'POST',
176 | body: {
177 | username: mary.username,
178 | password: mary.pw,
179 | session: true,
180 | requirements: {
181 | AposHcaptcha: siteConfig.response
182 | }
183 | },
184 | jar
185 | }
186 | );
187 |
188 | // the fancy way to detect `req`
189 | assert.equal(typeof savedArgs[0].t, 'function');
190 | assert.equal(savedArgs[1], 'hcaptcha-complete');
191 |
192 | page = await apos.http.get('/', { jar });
193 | assert.ok(page.match(/logged in/), 'page contains logged in in body');
194 | });
195 |
196 | it('should log bad token request', async function () {
197 | const mary = getUserConfig();
198 |
199 | const jar = apos.http.jar();
200 |
201 | // establish session
202 | const page = await apos.http.get('/', { jar });
203 | assert.ok(page.match(/logged out/), 'page contains logged out in body');
204 |
205 | // intecept the logger
206 | let savedArgs = [];
207 | apos.login.logInfo = (...args) => {
208 | savedArgs = args;
209 | };
210 |
211 | try {
212 | await apos.http.post(
213 | '/api/v1/@apostrophecms/login/login',
214 | {
215 | method: 'POST',
216 | body: {
217 | username: mary.username,
218 | password: mary.pw,
219 | session: true,
220 | requirements: {
221 | AposHcaptcha: 'bad-token'
222 | }
223 | },
224 | jar
225 | }
226 | );
227 | } catch (error) {
228 | //
229 | }
230 |
231 | // the fancy way to detect `req`
232 | assert.equal(typeof savedArgs[0].t, 'function');
233 | assert.equal(savedArgs[1], 'hcaptcha-invalid-token');
234 | assert.deepEqual(savedArgs[2], {
235 | data: {
236 | success: false,
237 | 'error-codes': [ 'invalid-input-response' ]
238 | }
239 | });
240 | });
241 | });
242 |
--------------------------------------------------------------------------------