├── .eslintrc ├── .gitignore ├── test ├── modules │ └── @apostrophecms │ │ └── home-page │ │ └── views │ │ └── page.html ├── package.json └── test.js ├── i18n └── AposRecap │ └── en.json ├── CHANGELOG.md ├── package.json ├── LICENSE.md ├── ui └── apos │ └── components │ └── AposRecaptcha.vue ├── .github └── workflows │ └── main.yml ├── index.js └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ] 3 | } -------------------------------------------------------------------------------- /.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 | test/data 14 | test/public 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /i18n/AposRecap/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "invalidToken": "There was a problem validating your reCAPTCHA verification submission.", 3 | "missingConfig": "The reCAPTCHA token was missing while verifying login.", 4 | "recaptchaErr": "The reCAPTCHA verification system may be down or incorrectly configured. Please try again or notify the site owner." 5 | } -------------------------------------------------------------------------------- /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.62.0", 7 | "@apostrophecms/login-recaptcha": "git://github.com/apostrophecms/login-recaptcha.git" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.0 (2024-02-21) 4 | 5 | ### Changes 6 | 7 | - Switch from `beforeSubmit` phase to the new `uponSubmit` phase. 8 | 9 | ## 1.1.0 - 2023-08-16 10 | 11 | ### Adds 12 | 13 | - Add `recaptcha-complete` and `recaptcha-invalid-token` structured logging events. 14 | 15 | ## 1.0.1 - 2023-02-17 16 | 17 | Remove `apostrophe` as a peer dependency. 18 | 19 | ## 1.0.0 - 2023-01-16 20 | 21 | Declared stable. No code changes. 22 | 23 | ## 1.0.0-beta - 2022-02-04 24 | 25 | Beta release of a reCAPTCHA login requirement module. 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/login-recaptcha", 3 | "version": "1.2.0", 4 | "description": " Adds reCAPTCHA v3 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-recaptcha.git" 14 | }, 15 | "homepage": "https://github.com/apostrophecms/login-recaptcha#readme", 16 | "author": "Apostrophe Technologies", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "apostrophe": "github:apostrophecms/apostrophe", 20 | "eslint": "^7.9.0", 21 | "eslint-config-apostrophe": "^3.4.0", 22 | "eslint-config-standard": "^14.1.1", 23 | "eslint-plugin-import": "^2.22.0", 24 | "eslint-plugin-node": "^11.1.0", 25 | "eslint-plugin-promise": "^4.2.1", 26 | "eslint-plugin-standard": "^4.0.1", 27 | "mocha": "^7.2.0" 28 | } 29 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 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 | -------------------------------------------------------------------------------- /ui/apos/components/AposRecaptcha.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: '@apostrophecms/login', 3 | requirements(self) { 4 | 5 | if (!self.options.recaptcha.site || !self.options.recaptcha.secret) { 6 | // Not playing around. No point in instantiating this module if you don't 7 | // configure it. 8 | 9 | // Unfortunately we're too early here to localize the error message. 10 | throw new Error('The login reCAPTCHA site key, secret key, or both are not configured'); 11 | } 12 | 13 | return { 14 | add: { 15 | AposRecaptcha: { 16 | phase: 'uponSubmit', 17 | async props(req) { 18 | return { 19 | sitekey: self.options.recaptcha.site 20 | }; 21 | }, 22 | async verify(req, data) { 23 | if (!data) { 24 | throw self.apos.error('invalid', req.t('AposRecap:missingConfig')); 25 | } 26 | 27 | await self.checkRecaptcha(req, data); 28 | } 29 | } 30 | } 31 | }; 32 | }, 33 | methods(self) { 34 | return { 35 | async checkRecaptcha (req, token) { 36 | const secret = self.options.recaptcha.secret; 37 | 38 | if (!secret) { 39 | return; 40 | } 41 | 42 | try { 43 | const url = 'https://www.google.com/recaptcha/api/siteverify'; 44 | const recaptchaUri = `${url}?secret=${secret}&response=${token}`; 45 | 46 | const response = await self.apos.http.post(recaptchaUri); 47 | 48 | if (!response.success) { 49 | self.logInfo(req, 'recaptcha-invalid-token', { 50 | data: response 51 | }); 52 | throw self.apos.error('invalid', req.t('AposRecap:invalidToken')); 53 | } 54 | self.logInfo(req, 'recaptcha-complete'); 55 | } catch (e) { 56 | self.apos.util.error(e); 57 | throw self.apos.error('error', req.t('AposRecap:recaptchaErr')); 58 | } 59 | } 60 | }; 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | ApostropheCMS logo 3 | 4 |

Apostrophe reCAPTCHA Login Verification

5 |

6 | 7 | 8 | 9 | 10 | GitHub Workflow Status (branch) 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 |
20 | 21 | This login verification module adds a [reCAPTCHA](https://developers.google.com/recaptcha/intro) check when any user logs into the site. It uses reCAPTCHA v3, which means that the test is invisible aside from a reCAPTCHA logo at the bottom of the screen. 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-recaptcha 29 | ``` 30 | 31 | ## Usage 32 | 33 | Instantiate the reCAPTCHA login module in the `app.js` file: 34 | 35 | ```javascript 36 | require('apostrophe')({ 37 | shortName: 'my-project', 38 | modules: { 39 | '@apostrophecms/login-recaptcha': {} 40 | } 41 | }); 42 | ``` 43 | 44 | The other requirement is to add [reCAPTCHA site and secret keys](https://developers.google.com/recaptcha/intro#recaptcha-overview) 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 | recaptcha: { 52 | site: 'ADD YOUR SITE KEY', 53 | secret: 'ADD YOUR SECRET KEY' 54 | } 55 | } 56 | }; 57 | ``` 58 | 59 | Once configured, reCAPTCHA 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 reCAPTCHA code. That reCAPTCHA code also constructs an iframe. The external script and iframe use the `www.google.com` and `www.gstatic.com` domains, 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-recaptcha': { 72 | 'script-src': 'www.google.com www.gstatic.com', 73 | 'frame-src': 'www.google.com' 74 | }, 75 | // Any other policies... 76 | } 77 | } 78 | }; 79 | ``` 80 | 81 | **If your content security policy is configured some other way**, add `www.google.com` to the `frame-src` directive and `www.google.com www.gstatic.com` to the `script-src` directive. 82 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const testUtil = require('apostrophe/test-lib/test'); 3 | 4 | const getSiteConfig = function () { 5 | return { 6 | // reCAPTCHA test keys 7 | // https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha-what-should-i-do 8 | site: '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', 9 | secret: '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe', 10 | // The reCAPTCHA test keys accept any token value. 11 | response: 'valid-token' 12 | }; 13 | }; 14 | 15 | const getAppConfig = function (siteConfig = getSiteConfig()) { 16 | return { 17 | '@apostrophecms/express': { 18 | options: { 19 | port: 4242, 20 | // csrf: { 21 | // exceptions: [ '/api/v1/@apostrophecms/form/submit' ] 22 | // }, 23 | session: { 24 | secret: 'test-this-module' 25 | }, 26 | apiKeys: { 27 | skeleton_key: { role: 'admin' } 28 | } 29 | } 30 | }, 31 | '@apostrophecms/login-recaptcha': { 32 | options: { 33 | testOption: 'surprise' 34 | } 35 | }, 36 | '@apostrophecms/login': { 37 | options: { 38 | recaptcha: { 39 | site: siteConfig.site, 40 | secret: siteConfig.secret 41 | } 42 | } 43 | } 44 | }; 45 | }; 46 | 47 | const getUserConfig = function () { 48 | return { 49 | username: 'marygold', 50 | pw: 'asdfjkl;' 51 | }; 52 | }; 53 | 54 | describe.only('@apostrophecms/login-recaptcha', function () { 55 | let apos; 56 | 57 | this.timeout(25000); 58 | 59 | before(async function () { 60 | apos = await testUtil.create({ 61 | shortname: 'loginTest', 62 | testModule: true, 63 | modules: getAppConfig() 64 | }); 65 | }); 66 | 67 | after(async function () { 68 | await testUtil.destroy(apos); 69 | }); 70 | 71 | // Improving 72 | it('should improve the login module', function () { 73 | const login = apos.modules['@apostrophecms/login']; 74 | 75 | const actual = login.options.testOption; 76 | const expected = 'surprise'; 77 | 78 | assert.equal(actual, expected); 79 | }); 80 | 81 | it('should be able to insert test user', async function () { 82 | const mary = getUserConfig(); 83 | 84 | const user = apos.user.newInstance(); 85 | user.title = 'Mary Gold'; 86 | user.username = mary.username; 87 | user.password = mary.pw; 88 | user.email = 'mary@gold.rocks'; 89 | user.role = 'editor'; 90 | 91 | const doc = await apos.user.insert(apos.task.getReq(), user); 92 | 93 | const actual = !!doc._id; 94 | const expected = true; 95 | 96 | assert.equal(actual, expected); 97 | }); 98 | 99 | it('should not be able to login a user without meeting the uponSubmit requirement', async function () { 100 | const jar = apos.http.jar(); 101 | const siteConfig = getSiteConfig(); 102 | const mary = getUserConfig(); 103 | 104 | const actual = async function () { 105 | // establish session 106 | const page = await apos.http.get('/', { jar }); 107 | assert.ok(page.match(/logged out/), 'page contains logged out in body'); 108 | 109 | const context = await apos.http.post( 110 | '/api/v1/@apostrophecms/login/context', 111 | { 112 | method: 'POST', 113 | body: {}, 114 | jar 115 | } 116 | ); 117 | 118 | assert.equal(context.requirementProps.AposRecaptcha.sitekey, siteConfig.site); 119 | 120 | const post = apos.http.post; 121 | try { 122 | // intercept http 123 | apos.http.post = async function () { 124 | return { 125 | success: false 126 | }; 127 | }; 128 | await apos.login.checkRecaptcha( 129 | apos.task.getReq({ 130 | ip: '1.1.1.1' 131 | }) 132 | ); 133 | } finally { 134 | apos.http.post = post; 135 | } 136 | 137 | await apos.http.post( 138 | '/api/v1/@apostrophecms/login/login', 139 | { 140 | method: 'POST', 141 | body: { 142 | username: mary.username, 143 | password: mary.pw, 144 | session: true 145 | }, 146 | jar 147 | } 148 | ); 149 | }; 150 | const expected = { 151 | message: 'The reCAPTCHA verification system may be down or incorrectly configured. Please try again or notify the site owner.', 152 | name: 'error' 153 | }; 154 | 155 | await assert.rejects(actual, expected); 156 | 157 | // Make sure it really didn't work 158 | const page = await apos.http.get('/', { jar }); 159 | assert.ok(page.match(/logged out/), 'page contains logged out in body'); 160 | }); 161 | 162 | it('should log in with a recaptcha token', async function () { 163 | const mary = getUserConfig(); 164 | const siteConfig = getSiteConfig(); 165 | 166 | const jar = apos.http.jar(); 167 | 168 | // establish session 169 | let page = await apos.http.get('/', { jar }); 170 | assert.ok(page.match(/logged out/), 'page contains logged out in body'); 171 | 172 | // intecept the logger 173 | let savedArgs = []; 174 | apos.login.logInfo = (...args) => { 175 | if (args[1] === 'recaptcha-complete') { 176 | // Do not get confused by unrelated events 177 | savedArgs = args; 178 | } 179 | }; 180 | 181 | await apos.login.checkRecaptcha( 182 | apos.task.getReq({ 183 | ip: '1.1.1.1' 184 | }), 185 | 'valid-token' 186 | ); 187 | 188 | await apos.http.post( 189 | '/api/v1/@apostrophecms/login/login', 190 | { 191 | method: 'POST', 192 | body: { 193 | username: mary.username, 194 | password: mary.pw, 195 | session: true, 196 | requirements: { 197 | AposRecaptcha: siteConfig.response 198 | } 199 | }, 200 | jar 201 | } 202 | ); 203 | 204 | // the fancy way to detect `req` 205 | assert.equal(typeof savedArgs[0].t, 'function'); 206 | assert.equal(savedArgs[1], 'recaptcha-complete'); 207 | 208 | page = await apos.http.get('/', { jar }); 209 | assert.ok(page.match(/logged in/), 'page contains logged in in body'); 210 | }); 211 | 212 | it('should log bad token request', async function () { 213 | // intercept http 214 | const post = apos.http.post; 215 | try { 216 | apos.http.post = async function () { 217 | return { 218 | success: false, 219 | foo: 'bar' 220 | }; 221 | }; 222 | 223 | // intecept the logger 224 | let savedArgs = []; 225 | apos.login.logInfo = (...args) => { 226 | savedArgs = args; 227 | }; 228 | 229 | try { 230 | await apos.login.checkRecaptcha( 231 | apos.task.getReq({ 232 | ip: '1.1.1.1' 233 | }), 234 | 'invalid-token' 235 | ); 236 | } catch (e) { 237 | // 238 | } 239 | // the fancy way to detect `req` 240 | assert.equal(typeof savedArgs[0].t, 'function'); 241 | assert.equal(savedArgs[1], 'recaptcha-invalid-token'); 242 | assert.deepEqual(savedArgs[2], { 243 | data: { 244 | success: false, 245 | foo: 'bar' 246 | } 247 | }); 248 | } finally { 249 | apos.http.post = post; 250 | } 251 | }); 252 | }); 253 | --------------------------------------------------------------------------------