├── .eslintignore ├── .meteor ├── .gitignore ├── release ├── platforms ├── .id ├── .finished-upgraders ├── packages └── versions ├── test ├── e2e │ ├── .meteor │ │ ├── .gitignore │ │ ├── release │ │ ├── platforms │ │ ├── .id │ │ ├── .finished-upgraders │ │ ├── packages │ │ └── versions │ ├── .gitignore │ ├── cypress.json │ ├── server │ │ └── main.js │ ├── package.json │ ├── client │ │ ├── main.html │ │ └── main.js │ └── cypress │ │ └── integration │ │ └── index.spec.js ├── server │ ├── loginAttemptValidator │ │ ├── index.spec.js │ │ └── validators │ │ │ ├── isAllowed.spec.js │ │ │ └── shouldResumeBeAccepted.spec.js │ ├── integration │ │ ├── error.spec.js │ │ ├── collection.spec.js │ │ ├── method.spec.js │ │ └── accounts.spec.js │ └── lib │ │ ├── helpers.spec.js │ │ └── connectionLastLoginToken.spec.js └── client │ ├── lib │ ├── alerts.spec.js │ └── methodParams.spec.js │ ├── accountsConfigurator.spec.js │ ├── accountsWrapper.spec.js │ └── index.spec.js ├── .npmrc ├── .meteorignore ├── .gitignore ├── server ├── integration │ ├── error.js │ ├── collection.js │ ├── method.js │ └── accounts.js ├── lib │ ├── helpers.js │ └── connectionLastLoginToken.js ├── loginAttemptValidator │ ├── validators │ │ ├── isAllowed.js │ │ ├── validator.js │ │ └── shouldResumeBeAccepted.js │ └── index.js └── index.js ├── .npmignore ├── .github └── workflows │ ├── meteor-e2e.yml │ └── meteor.yml ├── client ├── lib │ ├── methodParams.js │ ├── alerts.js │ └── overriding.js ├── accountsWrapper.js ├── index.js └── accountsConfigurator.js ├── .eslintrc ├── LICENSE ├── .versions ├── package.js ├── doc ├── CHANGELOG.md └── CUSTOM_ACCOUNTS.md ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | test/e2e 2 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.8.1 2 | -------------------------------------------------------------------------------- /test/e2e/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /test/e2e/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea -------------------------------------------------------------------------------- /test/e2e/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.8.2 2 | -------------------------------------------------------------------------------- /test/e2e/.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.meteorignore: -------------------------------------------------------------------------------- 1 | package.js 2 | .coverage 3 | test/e2e 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .coverage 3 | node_modules/ 4 | npm-debug.log 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /test/e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "video": false, 3 | "pluginsFile": false, 4 | "supportFile": false 5 | } 6 | -------------------------------------------------------------------------------- /server/integration/error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper for the Meteor.Error 3 | * @param {Array} params 4 | * @returns {Match.Error} Meteor.error 5 | */ 6 | export const createMeteorError = (...params) => new Meteor.Error(...params); 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea 3 | .vscode 4 | .DS_Store 5 | 6 | # Git 7 | .git 8 | .gitignore 9 | 10 | # Test & Development tools 11 | .meteor 12 | .meteorignore 13 | .eslintrc 14 | package.json 15 | package-lock.json 16 | test 17 | appveyor.yml 18 | .travis.yml 19 | node_modules 20 | npm-debug.log 21 | .coverage 22 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | gjsj2r6b7maa.oqc3rq4tdqa 8 | -------------------------------------------------------------------------------- /test/e2e/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | k09qs4z69j84f.iha86yw2q4qo 8 | -------------------------------------------------------------------------------- /test/e2e/server/main.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Accounts } from 'meteor/accounts-base'; 3 | 4 | Meteor.startup(() => { 5 | const doesUserExist = Meteor.users.findOne(); 6 | 7 | if (!doesUserExist) { 8 | Accounts.createUser({ 9 | username: 'test', 10 | password: 'test' 11 | }); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /.github/workflows/meteor-e2e.yml: -------------------------------------------------------------------------------- 1 | name: Meteor CI - e2e 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | meteor: [ '1.8.2' ] 11 | name: Testing with Meteor ${{ matrix.meteor }} 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Setup meteor 15 | uses: meteorengineer/setup-meteor@v1 16 | with: 17 | meteor-release: ${{ matrix.meteor }} 18 | - run: meteor npm run test:e2e 19 | -------------------------------------------------------------------------------- /test/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "private": true, 4 | "scripts": { 5 | "start": "meteor run", 6 | "test": "start-server-and-test start http://localhost:3000 cypress:headless", 7 | "cypress:headless": "npx cypress run", 8 | "cypress:open": "npx cypress open" 9 | }, 10 | "dependencies": { 11 | "@babel/runtime": "^7.7.4", 12 | "bcrypt": "^3.0.7", 13 | "meteor-node-stubs": "^0.3.2" 14 | }, 15 | "devDependencies": { 16 | "cypress": "^3.6.1", 17 | "start-server-and-test": "^1.10.6" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | 1.4.0-remove-old-dev-bundle-link 15 | 1.4.1-add-shell-server-package 16 | 1.4.3-split-account-service-packages 17 | 1.5-add-dynamic-import-package 18 | 1.7-split-underscore-from-meteor-base 19 | -------------------------------------------------------------------------------- /test/e2e/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | 1.4.0-remove-old-dev-bundle-link 15 | 1.4.1-add-shell-server-package 16 | 1.4.3-split-account-service-packages 17 | 1.5-add-dynamic-import-package 18 | 1.7-split-underscore-from-meteor-base 19 | -------------------------------------------------------------------------------- /server/lib/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if the provided path of properties exists in the object. 3 | * If it does returns the last element from the path. 4 | * @param {Object} obj 5 | * @param {string} path 6 | * @returns {*} value 7 | */ 8 | export const getValueFromTree = (obj = {}, path = '') => { 9 | const pathSplit = path.split('.'); 10 | let currentRoot = obj; 11 | const result = pathSplit.every((step) => { 12 | const isStepAccessible = step in currentRoot; 13 | if (isStepAccessible) { 14 | currentRoot = currentRoot[step]; 15 | } 16 | return isStepAccessible; 17 | }); 18 | return result ? currentRoot : undefined; 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/meteor.yml: -------------------------------------------------------------------------------- 1 | name: Meteor CI - unit 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | meteor: [ '1.8.2' ] 11 | name: Testing with Meteor ${{ matrix.meteor }} 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Setup meteor 15 | uses: meteorengineer/setup-meteor@v1 16 | with: 17 | meteor-release: ${{ matrix.meteor }} 18 | - run: meteor npm install 19 | - run: meteor npm test 20 | - name: Coveralls 21 | uses: coverallsapp/github-action@v1.0.1 22 | with: 23 | github-token: ${{ secrets.GITHUB_TOKEN }} 24 | path-to-lcov: ./.coverage/lcov.info 25 | -------------------------------------------------------------------------------- /test/server/loginAttemptValidator/index.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import * as loginAttemptValidator from '../../../server/loginAttemptValidator/index'; 3 | 4 | describe('Given the loginAttemptValidator factory', () => { 5 | it('should be a function', () => { 6 | expect(typeof loginAttemptValidator.factory).to.be.equal('function'); 7 | }); 8 | 9 | describe('When invoked', () => { 10 | let result; 11 | 12 | beforeEach(() => { 13 | result = loginAttemptValidator.factory(); 14 | }); 15 | 16 | it('should return an instance of LoginAttemptValidator', () => { 17 | expect(result instanceof loginAttemptValidator.default).to.be.equal(true); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /server/integration/collection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Finds and returns an user containing requested login token. 3 | * @param {string} token 4 | * @returns {Object} user 5 | */ 6 | export const getUserByToken = (token) => { 7 | const query = { 'services.resume.loginTokens.hashedToken': token }; 8 | return Meteor.users.findOne(query, { 9 | 'services.resume.loginTokens': 1 10 | }); 11 | }; 12 | 13 | /** 14 | * Replaces current user's login tokens. 15 | * // TODO: Ensure that login tokens weren't changed in the meantime 16 | * @param {string} id 17 | * @param {Object[]} tokens 18 | * @returns {number} result 19 | */ 20 | export const replaceUserTokens = (id, tokens) => Meteor.users.update(id, { 21 | $set: { 22 | 'services.resume.loginTokens': tokens 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /server/loginAttemptValidator/validators/isAllowed.js: -------------------------------------------------------------------------------- 1 | import * as Validator from './validator'; 2 | 3 | /** 4 | * IsAllowed 5 | * 6 | * Validates if the login attempt was already 7 | * disallowed in one of the previous authentication processes. 8 | * 9 | * @class 10 | * @extends Validator 11 | */ 12 | class IsAllowed extends Validator.default { 13 | constructor(...params) { 14 | super(...params); 15 | super.setErrorDetails({ 16 | reason: 'Attempt disallowed by previous validation', 17 | error: this.loginAttempt.error 18 | }); 19 | } 20 | 21 | /** 22 | * Runs the validation. 23 | * @returns {boolean} isValid 24 | */ 25 | validate() { 26 | return !!(this.loginAttempt && this.loginAttempt.allowed); 27 | } 28 | } 29 | 30 | export const factory = (...params) => new IsAllowed(...params); 31 | 32 | export default IsAllowed; 33 | -------------------------------------------------------------------------------- /test/e2e/client/main.html: -------------------------------------------------------------------------------- 1 | 2 | Meteor - RememberMe - e2e 3 | 4 | 5 | 6 | {{#if currentUser}} 7 |

You are logged in as user "{{currentUser.username}}"

8 |

Press the button to log out.

9 | {{else}} 10 |

You are not logged in

11 |

Press the button to log in.

12 | {{/if}} 13 | 14 | {{> loginSystem}} 15 | {{> loginResultTemplate}} 16 | 17 | 18 | 24 | 25 | 31 | -------------------------------------------------------------------------------- /client/lib/methodParams.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks which param is the rememberMe flag 3 | * and returns it. If it's not present then 4 | * returns "true" by default. 5 | * @param {Array} params 6 | * @returns {boolean} flag 7 | */ 8 | export const exportFlagFromParams = (params = []) => { 9 | const [ 10 | firstParam, 11 | secondParam = true 12 | ] = params; 13 | return (typeof firstParam === 'boolean') 14 | ? firstParam 15 | : secondParam; 16 | }; 17 | 18 | /** 19 | * Checks if the first provided param is the callback 20 | * function. If it's not present then returns an 21 | * empty method instead. 22 | * @param {Array} params 23 | * @returns {function} callback 24 | */ 25 | export const exportCallbackFromParams = (params = []) => { 26 | const [firstParam] = params; 27 | return (typeof firstParam === 'function') 28 | ? firstParam 29 | : () => {}; 30 | }; 31 | -------------------------------------------------------------------------------- /server/loginAttemptValidator/validators/validator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validator 3 | * 4 | * @property {Object} loginAttempt 5 | * @property {Object} errorDetails 6 | * 7 | * @class 8 | * @abstract 9 | */ 10 | class Validator { 11 | constructor(attempt) { 12 | this.loginAttempt = attempt; 13 | this.errorDetails = {}; 14 | } 15 | 16 | /** 17 | * Sets the error message for failures. 18 | * @param {Object} details 19 | */ 20 | setErrorDetails(details) { 21 | this.errorDetails = details; 22 | } 23 | 24 | /** 25 | * Runs the validation. 26 | * @returns {boolean} isValid 27 | */ 28 | validate() {} 29 | 30 | /** 31 | * Returns error message for failure. 32 | * @returns {Object} errorMessage 33 | */ 34 | getError() { 35 | return { 36 | result: false, 37 | ...this.errorDetails 38 | }; 39 | } 40 | } 41 | 42 | export default Validator; 43 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:meteor/recommended" 4 | ], 5 | "plugins": [ 6 | "meteor" 7 | ], 8 | "env": { 9 | "meteor": true 10 | }, 11 | "settings": { 12 | "import/resolver": "meteor" 13 | }, 14 | "parser": "babel-eslint", 15 | "globals": { 16 | "Meteor": true, 17 | "it": true, 18 | "describe": true, 19 | "beforeEach": true, 20 | "afterEach": true 21 | }, 22 | "rules": { 23 | "comma-dangle": [ 24 | 0 25 | ], 26 | "indent": [ 27 | 2, 28 | 4, 29 | { 30 | "SwitchCase": 1 31 | } 32 | ], 33 | "import/extensions": [ 34 | "off", 35 | "never" 36 | ], 37 | "no-underscore-dangle": 0, 38 | "no-console": 0, 39 | "one-var-declaration-per-line": 0, 40 | "one-var": 0, 41 | "function-paren-newline": 0, 42 | "object-curly-newline": 0, 43 | "require-string-literals": 0, 44 | "import/prefer-default-export": 0 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tomasz Przytuła 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/e2e/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base@1.4.0 # Packages every Meteor app needs to have 8 | mobile-experience@1.0.5 # Packages for a great mobile UX 9 | mongo@1.7.0 # The database Meteor supports right now 10 | blaze-html-templates@1.0.4 # Compile .html files into Meteor Blaze views 11 | reactive-var@1.0.11 # Reactive variable for tracker 12 | tracker@1.2.0 # Meteor's client-side reactive programming library 13 | 14 | standard-minifier-css@1.5.4 # CSS minifier run for production mode 15 | standard-minifier-js@2.5.0 # JS minifier run for production mode 16 | es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers 17 | ecmascript@0.13.0 # Enable ECMAScript2015+ syntax in app code 18 | shell-server@0.4.0 # Server-side component of the `meteor shell` command 19 | 20 | tprzytula:remember-me@1.0.2 21 | reactive-dict 22 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base@1.4.0 # Packages every Meteor app needs to have 8 | mobile-experience@1.0.5 # Packages for a great mobile UX 9 | mongo@1.6.2 # The database Meteor supports right now 10 | blaze-html-templates@1.0.4 # Compile .html files into Meteor Blaze views 11 | reactive-var@1.0.11 # Reactive variable for tracker 12 | tracker@1.2.0 # Meteor's client-side reactive programming library 13 | 14 | standard-minifier-css@1.5.3 # CSS minifier run for production mode 15 | standard-minifier-js@2.4.1 # JS minifier run for production mode 16 | es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers 17 | ecmascript@0.12.4 # Enable ECMAScript2015+ syntax in app code 18 | shell-server@0.4.0 # Server-side component of the `meteor shell` command 19 | 20 | tprzytula:remember-me@1.0.2 21 | accounts-base@1.4.3 22 | meteortesting:mocha 23 | check@1.3.1 24 | -------------------------------------------------------------------------------- /server/integration/method.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Higher level wrapper for handling creation 3 | * of new Meteor methods with the possibility 4 | * to easily provide a callback which will be 5 | * triggered when the method is invoked. 6 | * 7 | * @property {string} name 8 | * @property {function} callback 9 | * @class 10 | */ 11 | class Method { 12 | constructor({ name, callback }) { 13 | this.name = name; 14 | this.callback = callback; 15 | } 16 | 17 | /** 18 | * Registers the method in Meteor. 19 | * @public 20 | */ 21 | setup() { 22 | const method = this._constructMethod(); 23 | Meteor.methods(method); 24 | } 25 | 26 | /** 27 | * Prepares object with the method configuration 28 | * in an understandable way for Meteor.methods parser. 29 | * @returns {Object} method 30 | * @private 31 | */ 32 | _constructMethod() { 33 | const { name, callback } = this; 34 | return { 35 | [name](...params) { 36 | return callback(this, ...params); 37 | } 38 | }; 39 | } 40 | } 41 | 42 | export const factory = (...params) => new Method(...params); 43 | 44 | export default Method; 45 | -------------------------------------------------------------------------------- /client/lib/alerts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Informs about the requirement of this functionality 3 | * to be activated on the server before use. 4 | * 5 | * Doesn't throw the error because the login attempt 6 | * succeeded and the inappropriate behaviour of RememberMe dependency 7 | * should not affect the core login behaviour. 8 | */ 9 | export const rememberMeNotActive = () => { 10 | console.warn( 11 | 'Dependency meteor/tprzytula:remember-me is not present on the server!\n', 12 | '\nMake sure you have installed this dependency on the server.', 13 | '\nRememberMe setting will not be taken into account' 14 | ); 15 | }; 16 | 17 | /** 18 | * Prints received error from an attempt to send flag state update 19 | * to the server. 20 | * 21 | * Doesn't throw the error because the login attempt 22 | * succeeded and the inappropriate behaviour of RememberMe dependency 23 | * should not affect the core login behaviour. 24 | * @param {Meteor.Error} error 25 | */ 26 | export const couldNotUpdateRememberMe = (error) => { 27 | console.error( 28 | 'meteor/tprzytula:remember-me' + 29 | '\nCould not update remember me setting.' + 30 | '\nError:', 31 | error 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.4.3 2 | accounts-password@1.5.1 3 | allow-deny@1.1.0 4 | babel-compiler@7.3.4 5 | babel-runtime@1.3.0 6 | base64@1.0.11 7 | binary-heap@1.0.11 8 | boilerplate-generator@1.6.0 9 | callback-hook@1.1.0 10 | check@1.3.1 11 | ddp@1.4.0 12 | ddp-client@2.3.3 13 | ddp-common@1.4.0 14 | ddp-rate-limiter@1.0.7 15 | ddp-server@2.3.0 16 | diff-sequence@1.1.1 17 | dynamic-import@0.5.1 18 | ecmascript@0.12.4 19 | ecmascript-runtime@0.7.0 20 | ecmascript-runtime-client@0.8.0 21 | ecmascript-runtime-server@0.7.1 22 | ejson@1.1.0 23 | email@1.2.3 24 | fetch@0.1.1 25 | geojson-utils@1.0.10 26 | id-map@1.1.0 27 | inter-process-messaging@0.1.0 28 | localstorage@1.2.0 29 | logging@1.1.20 30 | meteor@1.9.3 31 | minimongo@1.4.5 32 | modern-browsers@0.1.4 33 | modules@0.13.0 34 | modules-runtime@0.10.3 35 | mongo@1.6.2 36 | mongo-decimal@0.1.1 37 | mongo-dev-server@1.1.0 38 | mongo-id@1.0.7 39 | npm-bcrypt@0.9.3 40 | npm-mongo@3.1.2 41 | ordered-dict@1.1.0 42 | promise@0.11.2 43 | random@1.1.0 44 | rate-limit@1.0.9 45 | reactive-var@1.0.11 46 | reload@1.3.0 47 | retry@1.1.0 48 | routepolicy@1.1.0 49 | service-configuration@1.0.11 50 | sha@1.0.9 51 | socket-stream-client@0.2.2 52 | srp@1.0.12 53 | tprzytula:remember-me@1.0.2 54 | tracker@1.2.0 55 | underscore@1.0.10 56 | webapp@1.7.3 57 | webapp-hashing@1.0.9 58 | -------------------------------------------------------------------------------- /test/e2e/client/main.js: -------------------------------------------------------------------------------- 1 | import { Template } from 'meteor/templating'; 2 | import RememberMe from 'meteor/tprzytula:remember-me'; 3 | import { ReactiveDict } from 'meteor/reactive-dict'; 4 | 5 | import './main.html'; 6 | 7 | const State = new ReactiveDict('state', { rememberMe: false }); 8 | 9 | Template.loginSystem.helpers({ 10 | buttonState() { 11 | return Meteor.user() ? 'Log out' : 'Login' 12 | } 13 | }); 14 | 15 | Template.loginResultTemplate.helpers({ 16 | loginResult() { 17 | return State.get('loginResult') || ''; 18 | }, 19 | loginResultDate() { 20 | return State.get('loginResultDate') || ''; 21 | } 22 | }); 23 | 24 | Template.loginSystem.helpers({ 25 | rememberMe() { 26 | return State.get('rememberMe'); 27 | } 28 | }); 29 | 30 | Template.loginSystem.events({ 31 | 'click input'() { 32 | State.set('rememberMe', !State.get('rememberMe')); 33 | }, 34 | 'click button'() { 35 | if (Meteor.user()) { 36 | Meteor.logout(); 37 | return; 38 | } 39 | State.set('loginResult', ''); 40 | 41 | RememberMe.loginWithPassword('test', 'test', (error) => { 42 | if (error) { 43 | State.set('loginResult', error.reason || error); 44 | } 45 | }, State.get('rememberMe')); 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /test/server/integration/error.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import sinon from 'sinon'; 3 | import { createMeteorError } from './../../../server/integration/error'; 4 | 5 | describe('Given createMeteorError method', () => { 6 | const sandbox = sinon.createSandbox(); 7 | let errorStub; 8 | 9 | beforeEach(() => { 10 | errorStub = sandbox.spy(Meteor, 'Error'); 11 | }); 12 | 13 | afterEach(() => { 14 | sandbox.restore(); 15 | }); 16 | 17 | describe('When invoked with sample parameters', () => { 18 | let createdError; 19 | 20 | beforeEach(() => { 21 | createdError = createMeteorError(500, 'Bad request', 22 | 'Your request is missing required fields'); 23 | }); 24 | 25 | it('should call Meteor.Error with correct parameters', () => { 26 | expect(errorStub).to.be.calledWithExactly(500, 'Bad request', 27 | 'Your request is missing required fields'); 28 | }); 29 | 30 | it('should return an instance of Meteor.Error with correct fields', () => { 31 | expect(createdError instanceof Meteor.Error).to.be.true(); 32 | expect(createdError.error).to.be.equal(500); 33 | expect(createdError.reason).to.be.equal('Bad request'); 34 | expect(createdError.details).to.be.equal('Your request is missing required fields'); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/client/lib/alerts.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import sinon from 'sinon'; 3 | 4 | import * as Alerts from './../../../client/lib/alerts'; 5 | 6 | describe('Given alerts', () => { 7 | const sandbox = sinon.createSandbox(); 8 | let consoleWarnSpy, consoleErrorSpy; 9 | 10 | beforeEach(() => { 11 | consoleWarnSpy = sandbox.spy(console, 'warn'); 12 | consoleErrorSpy = sandbox.spy(console, 'error'); 13 | }); 14 | 15 | afterEach(() => { 16 | sandbox.restore(); 17 | }); 18 | 19 | describe('When rememberMeNotActive is invoked', () => { 20 | beforeEach(() => { 21 | Alerts.rememberMeNotActive(); 22 | }); 23 | 24 | it('should print warning message', () => { 25 | expect(consoleWarnSpy).to.be.calledOnce(); 26 | }); 27 | }); 28 | 29 | describe('When couldNotUpdateRememberMe is invoked', () => { 30 | const sampleError = 'test'; 31 | 32 | beforeEach(() => { 33 | Alerts.couldNotUpdateRememberMe(sampleError); 34 | }); 35 | 36 | it('should print error message', () => { 37 | expect(consoleErrorSpy).to.be.calledOnce(); 38 | }); 39 | 40 | it('should contain passed error in the message', () => { 41 | const { args } = consoleErrorSpy.getCall(0); 42 | expect(args.includes(sampleError)).to.be.equal(true); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/e2e/cypress/integration/index.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Remember-Me', () => { 4 | beforeEach(() => { 5 | cy.visit('localhost:3000') 6 | }); 7 | 8 | describe('When user attempts to log in', () => { 9 | beforeEach(() => { 10 | cy.get('[data-cy=login-button]').click(); 11 | }); 12 | 13 | it('should log in successfully', () => { 14 | cy.get('body').contains('You are logged in'); 15 | }); 16 | 17 | describe('And then the user re-visits the website', () => { 18 | beforeEach(() => { 19 | cy.reload(); 20 | }); 21 | 22 | it('should not be logged-in', () => { 23 | cy.get('body').contains('You are not logged in'); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('When the user clicks on the remember-me checkbox', () => { 29 | beforeEach(() => { 30 | cy.get('[data-cy=remember-me-checkbox]').click(); 31 | }); 32 | 33 | describe('And then attempts to log in', () => { 34 | beforeEach(() => { 35 | cy.get('[data-cy=login-button]').click(); 36 | }); 37 | 38 | it('should log in successfully', () => { 39 | cy.get('body').contains('You are logged in'); 40 | }); 41 | 42 | describe('And the user re-visits the website', () => { 43 | beforeEach(() => { 44 | cy.wait(100); 45 | cy.reload(); 46 | }); 47 | 48 | it('should be logged-in', () => { 49 | cy.get('body').contains('You are logged in'); 50 | }); 51 | }); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /server/loginAttemptValidator/index.js: -------------------------------------------------------------------------------- 1 | import * as IsAllowed from './validators/isAllowed'; 2 | import * as ShouldResumeBeAccepted from './validators/shouldResumeBeAccepted'; 3 | 4 | /** 5 | * The brain of the RememberMe functionality. 6 | * Decides if user's login attempt should be accepted or not. 7 | */ 8 | class LoginAttemptValidator { 9 | constructor(attempt = {}) { 10 | this._loginAttempt = attempt; 11 | this._validators = this._prepareValidators(); 12 | } 13 | 14 | /** 15 | * Creates list of validators 16 | * @returns {Object[]} validators 17 | * @private 18 | */ 19 | _prepareValidators() { 20 | return [ 21 | IsAllowed.factory(this._loginAttempt), 22 | ShouldResumeBeAccepted.factory(this._loginAttempt) 23 | ]; 24 | } 25 | 26 | /** 27 | * Runs all validators and returns the result. 28 | * If any of them failed then the validation did not succeeded. 29 | * @returns {Object} result 30 | */ 31 | validate() { 32 | const failedValidator = this._validators.find(validator => !validator.validate()); 33 | if (failedValidator) { 34 | return failedValidator.getError(); 35 | } 36 | 37 | return { 38 | result: true, 39 | resultCode: 0, 40 | reason: 'Validation passed' 41 | }; 42 | } 43 | } 44 | 45 | export const factory = (...params) => new LoginAttemptValidator(...params); 46 | 47 | export default LoginAttemptValidator; 48 | 49 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'tprzytula:remember-me', 3 | version: '1.0.2', 4 | summary: 'Extension for Meteor account-base package with the implementation of rememberMe', 5 | git: 'https://github.com/tprzytulacc/Meteor-RememberMe', 6 | documentation: 'README.md' 7 | }); 8 | 9 | Package.onUse((api) => { 10 | api.versionsFrom('1.5.2.2'); 11 | api.use('ecmascript'); 12 | api.use('accounts-base'); 13 | api.use('accounts-password'); 14 | api.mainModule('client/index.js', 'client'); 15 | api.mainModule('server/index.js', 'server'); 16 | }); 17 | 18 | /* 19 | https://github.com/meteortesting/meteor-mocha/issues/63 20 | 21 | Unfortunately, because of the issue above I was not able to test this package using the "meteor test-packages" 22 | and had to setup a "fake" meteor project instead. 23 | 24 | You can follow my approach if you have encountered the same issues. Make sure to setup a Meteor project within 25 | your package and end up with a ".meteor" directory where your dependency should be listed in the packages list. 26 | 27 | Things to remember: 28 | - Make sure to always have the recent version of your package under .meteor/packages 29 | - When you want to publish your package by running "Meteor publish" you need to remove the ".meteor" directory 30 | before because otherwise it won't allow you to do that (I know it's a pain) 31 | 32 | Hopefully one day Meteor won't require from us to use hacks for such basic things as testing your own code. 33 | */ 34 | -------------------------------------------------------------------------------- /client/lib/overriding.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | 3 | /* eslint-disable */ 4 | export const prepareLoginWithPasswordMethod = (accountsInstance) => { 5 | /* 6 | The method is based on the original one in Accounts: 7 | https://github.com/meteor/meteor/blob/46257bad264bf089e35e0fe35494b51fe5849c7b/packages/accounts-password/password_client.js#L33 8 | */ 9 | /* istanbul ignore next */ 10 | // TODO: Maybe try to test it anyways? We changed this method so it would be nice to ensure that it's okay 11 | return function (selector, password, callback) { 12 | if (typeof selector === 'string') { 13 | selector = selector.indexOf('@') === -1 14 | ? { username: selector } 15 | : { email: selector }; 16 | } 17 | accountsInstance.callLoginMethod({ 18 | methodArguments: [{ 19 | user: selector, 20 | password: Accounts._hashPassword(password) 21 | }], 22 | userCallback: function (error, result) { 23 | if (error && error.error === 400 && 24 | error.reason === 'old password format') { 25 | srpUpgradePath({ 26 | upgradeError: error, 27 | userSelector: selector, 28 | plaintextPassword: password 29 | }, callback); 30 | } else if (error) { 31 | callback && callback(error); 32 | } else { 33 | callback && callback(); 34 | } 35 | } 36 | }); 37 | }; 38 | }; 39 | /* eslint-enable */ 40 | -------------------------------------------------------------------------------- /server/integration/accounts.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | 3 | /** 4 | * Adds additional validation step in the Meteor's login process. 5 | * @param {function} callback 6 | */ 7 | export const addValidationStep = callback => Accounts.validateLoginAttempt(callback); 8 | 9 | /** 10 | * Returns last login token associated to the connectionId. 11 | * @param {string} connectionId 12 | * @returns {string} loginToken 13 | */ 14 | export const getConnectionLastLoginToken = connectionId => Accounts._getLoginToken(connectionId); 15 | 16 | 17 | /** 18 | * Returns user's record matching the username. 19 | * @param {string} username 20 | * @returns {Object} user 21 | */ 22 | export const findUserByUsername = username => Accounts.findUserByUsername(username); 23 | 24 | /** 25 | * Returns user's record matching the email. 26 | * @param {string} email. 27 | * @returns {Object} user 28 | */ 29 | export const findUserByEmail = email => Accounts.findUserByEmail(email); 30 | 31 | /** 32 | * Finds user's record matching provided user details. 33 | * @param {string} [username] 34 | * @param {[Object]} [emails] 35 | * @returns {Object || undefined} result 36 | */ 37 | export const findUser = user = ({ username, emails = [] } = {}) => { 38 | if (username) { 39 | return findUserByUsername(username); 40 | } 41 | 42 | const [ primaryEmail ] = emails; 43 | if (primaryEmail && primaryEmail.address) { 44 | return findUserByEmail(primaryEmail.address); 45 | } 46 | }; 47 | 48 | /** 49 | * Hashes login token. 50 | * @param {string} token 51 | * @returns {string} hashedToken 52 | */ 53 | export const hashLoginToken = token => Accounts._hashLoginToken(token); 54 | -------------------------------------------------------------------------------- /test/server/lib/helpers.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import { getValueFromTree } from '../../../server/lib/helpers'; 3 | 4 | describe('Given getValueFromTree method', () => { 5 | const sampleParam = { 6 | test: { 7 | test: { 8 | treasure: 'congrats!' 9 | }, 10 | treasure: 'that is cheating' 11 | }, 12 | something: { 13 | test: { 14 | treasure: 'bad choice' 15 | } 16 | } 17 | }; 18 | let result; 19 | 20 | describe('When a correct path is provided', () => { 21 | beforeEach(() => { 22 | result = getValueFromTree(sampleParam, 'test.test.treasure'); 23 | }); 24 | 25 | it('should return undefined', () => { 26 | expect(result).to.be.equal('congrats!'); 27 | }); 28 | }); 29 | 30 | describe('When an incorrect path is provided', () => { 31 | beforeEach(() => { 32 | result = getValueFromTree(sampleParam, 'one.two.three.four'); 33 | }); 34 | 35 | it('should return undefined', () => { 36 | expect(result).to.be.equal(undefined); 37 | }); 38 | }); 39 | 40 | describe('When path is not provided', () => { 41 | beforeEach(() => { 42 | result = getValueFromTree(sampleParam); 43 | }); 44 | 45 | it('should return undefined', () => { 46 | expect(result).to.be.equal(undefined); 47 | }); 48 | }); 49 | 50 | describe('When nothing is passed', () => { 51 | beforeEach(() => { 52 | result = getValueFromTree(); 53 | }); 54 | 55 | it('should return undefined', () => { 56 | expect(result).to.be.equal(undefined); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /doc/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.0.2] - 29.10.2019 4 | * Improved clarity of how to use the loginWithPassword (#38) 5 | * Test coverage is now correctly gathered from both client & server 6 | 7 | ## [1.0.1] - 03.12.2018 8 | * Add support to accounts that are identified by email (fixes #16) 9 | 10 | ## [1.0.0] - 14.08.2018 11 | * Dependency was refactored 12 | 13 | **Important changes:** 14 | * All log ins made by the default Meteor.loginWithPassword method won't be affected anymore by this dependency. Every client who did not report the rememberMe setting will stay logged in to match the default Meteor's behaviour. 15 | 16 | **Breaking changes:** 17 | * `activate` method was removed. There is no need to activate RememberMe on the server anymore. 18 | * `changeAccountsSystem` will now throw an error when provided parameter is not a valid instance of the AccountsClient 19 | 20 | ## [0.2.1] - 10.05.2018 21 | * Change client methods to arrow functions to prevent wrong context issues ([Issue #6](https://github.com/tprzytula/Meteor-Remember-Me/issues/6)) 22 | * loginWithPassword method for custom accounts was throwing an error if accounts were not stored in `Meteor.remoteUsers` (whoops!) 23 | 24 | ## [0.2.0] - 13.03.2018 25 | New feature: 26 | * Add support for custom AccountsClient ([introduction](CUSTOM_ACCOUNTS.md)) 27 | 28 | Related improvements: 29 | * Check if onLogin callback from the dependency is already present 30 | * Check if loginAttempt method was already overridden in provided instance 31 | 32 | ## [0.1.3] - 04.02.2018 33 | * Inform client if the functionality was not activated on the server 34 | * Client side unit tests 35 | 36 | ## [0.1.2] - 01.02.2018 37 | * Remove 'lodash' dependency, replace usages with ES6 38 | * Remove 'crypto-js' dependency, use 'crypto' instead 39 | * Decrease the server bundle size significantly by the above changes 40 | 41 | ## [0.1.1] - 27.01.2018 42 | * Print correct error in case of already disallowed attempt 43 | * Add server side tests 44 | 45 | ## [0.1.0] - 19.01.2018 46 | * Initial release 47 | -------------------------------------------------------------------------------- /test/server/integration/collection.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import sinon from 'sinon'; 3 | import { getUserByToken, replaceUserTokens } from '../../../server/integration/collection'; 4 | 5 | describe('Given getUserByToken method', () => { 6 | const sandbox = sinon.createSandbox(); 7 | let findOneStub; 8 | 9 | beforeEach(() => { 10 | findOneStub = sandbox.stub(Meteor.users, 'findOne'); 11 | }); 12 | 13 | afterEach(() => { 14 | sandbox.restore(); 15 | }); 16 | 17 | describe('When called with a token', () => { 18 | const sampleToken = 'test-token'; 19 | 20 | beforeEach(() => { 21 | getUserByToken(sampleToken); 22 | }); 23 | 24 | it('should construct a query and pass it to the Meteor findOne method', () => { 25 | const expectedQuery = { 26 | 'services.resume.loginTokens.hashedToken': sampleToken 27 | }; 28 | expect(findOneStub).to.be.calledWith(expectedQuery); 29 | }); 30 | }); 31 | }); 32 | 33 | describe('Given replaceUserTokens method', () => { 34 | const sandbox = sinon.createSandbox(); 35 | let updateStub; 36 | 37 | beforeEach(() => { 38 | updateStub = sandbox.stub(Meteor.users, 'update'); 39 | }); 40 | 41 | afterEach(() => { 42 | sandbox.restore(); 43 | }); 44 | 45 | describe('When called with an id and tokens', () => { 46 | const sampleId = 'user-id'; 47 | const sampleTokens = ['token-one', 'token-two']; 48 | 49 | beforeEach(() => { 50 | replaceUserTokens(sampleId, sampleTokens); 51 | }); 52 | 53 | it('should construct a query and pass it to the Meteor update method with id', () => { 54 | const expectedQuery = { 55 | $set: { 56 | 'services.resume.loginTokens': sampleTokens 57 | } 58 | }; 59 | expect(updateStub).to.be.calledWith(sampleId, expectedQuery); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/e2e/.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.4.5 2 | accounts-password@1.5.1 3 | allow-deny@1.1.0 4 | autoupdate@1.6.0 5 | babel-compiler@7.4.0 6 | babel-runtime@1.4.0 7 | base64@1.0.12 8 | binary-heap@1.0.11 9 | blaze@2.3.3 10 | blaze-html-templates@1.1.2 11 | blaze-tools@1.0.10 12 | boilerplate-generator@1.6.0 13 | caching-compiler@1.2.1 14 | caching-html-compiler@1.1.3 15 | callback-hook@1.2.0 16 | check@1.3.1 17 | ddp@1.4.0 18 | ddp-client@2.3.3 19 | ddp-common@1.4.0 20 | ddp-rate-limiter@1.0.7 21 | ddp-server@2.3.0 22 | deps@1.0.12 23 | diff-sequence@1.1.1 24 | dynamic-import@0.5.1 25 | ecmascript@0.13.0 26 | ecmascript-runtime@0.7.0 27 | ecmascript-runtime-client@0.9.0 28 | ecmascript-runtime-server@0.8.0 29 | ejson@1.1.0 30 | email@1.2.3 31 | es5-shim@4.8.0 32 | fetch@0.1.1 33 | geojson-utils@1.0.10 34 | hot-code-push@1.0.4 35 | html-tools@1.0.11 36 | htmljs@1.0.11 37 | id-map@1.1.0 38 | inter-process-messaging@0.1.0 39 | jquery@1.11.11 40 | launch-screen@1.1.1 41 | livedata@1.0.18 42 | localstorage@1.2.0 43 | logging@1.1.20 44 | meteor@1.9.3 45 | meteor-base@1.4.0 46 | minifier-css@1.4.3 47 | minifier-js@2.5.0 48 | minimongo@1.4.5 49 | mobile-experience@1.0.5 50 | mobile-status-bar@1.0.14 51 | modern-browsers@0.1.4 52 | modules@0.14.0 53 | modules-runtime@0.11.0 54 | mongo@1.7.0 55 | mongo-decimal@0.1.1 56 | mongo-dev-server@1.1.0 57 | mongo-id@1.0.7 58 | npm-bcrypt@0.9.3 59 | npm-mongo@3.2.0 60 | observe-sequence@1.0.16 61 | ordered-dict@1.1.0 62 | promise@0.11.2 63 | random@1.1.0 64 | rate-limit@1.0.9 65 | reactive-dict@1.3.0 66 | reactive-var@1.0.11 67 | reload@1.3.0 68 | retry@1.1.0 69 | routepolicy@1.1.0 70 | service-configuration@1.0.11 71 | sha@1.0.9 72 | shell-server@0.4.0 73 | socket-stream-client@0.2.2 74 | spacebars@1.0.15 75 | spacebars-compiler@1.1.3 76 | srp@1.0.12 77 | standard-minifier-css@1.5.4 78 | standard-minifier-js@2.5.0 79 | templating@1.3.2 80 | templating-compiler@1.3.3 81 | templating-runtime@1.3.2 82 | templating-tools@1.1.2 83 | tprzytula:remember-me@1.0.2 84 | tracker@1.2.0 85 | ui@1.0.13 86 | underscore@1.0.10 87 | webapp@1.7.5 88 | webapp-hashing@1.0.9 89 | -------------------------------------------------------------------------------- /client/accountsWrapper.js: -------------------------------------------------------------------------------- 1 | import AccountsConfigurator from './accountsConfigurator'; 2 | import * as Alerts from './lib/alerts'; 3 | 4 | const updateFlagMethod = 'tprzytula:rememberMe-update'; 5 | 6 | /** 7 | * Wrapper for currently used accounts system. 8 | * @property {AccountsClient} _accounts 9 | */ 10 | class AccountsWrapper { 11 | constructor(accounts) { 12 | this._accounts = accounts; 13 | } 14 | 15 | /** 16 | * Configures accounts. 17 | * @public 18 | */ 19 | configure() { 20 | this._accountsConfigurator = new AccountsConfigurator(this._accounts); 21 | this._accountsConfigurator.configure(); 22 | } 23 | 24 | /** 25 | * Wraps login method from the accounts instance. 26 | * @param {string | Object} selector - Either a string interpreted as a username or an email; or an object with a 27 | * single key: `email`, `username` or `id`. Username or email match in a case 28 | * insensitive manner. 29 | * @param {string} password 30 | * @param {boolean} flag 31 | * @param {function} callback 32 | * @public 33 | */ 34 | loginWithPassword({ selector, password, flag, callback }) { 35 | this._accounts.loginWithPassword(selector, password, (error) => { 36 | if (!error) { 37 | this._updateFlag(flag); 38 | } 39 | callback(error); 40 | }); 41 | } 42 | 43 | /** 44 | * Informs the server of the state update of rememberMe flag. 45 | * @param {boolean} flag 46 | * @private 47 | */ 48 | _updateFlag(flag) { 49 | this._accounts.connection.call(updateFlagMethod, flag, (error) => { 50 | if (error && error.error === 404) { 51 | Alerts.rememberMeNotActive(); 52 | } else if (error) { 53 | Alerts.couldNotUpdateRememberMe(error); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | export const factory = (...params) => new AccountsWrapper(...params); 60 | 61 | export default AccountsWrapper; 62 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | Accounts, 3 | AccountsClient 4 | } from 'meteor/accounts-base'; 5 | import AccountsWrapper from './accountsWrapper'; 6 | import { 7 | exportFlagFromParams, 8 | exportCallbackFromParams 9 | } from './lib/methodParams'; 10 | 11 | /** 12 | * RememberMe client-side 13 | * Extends functionality of Meteor's Accounts system. 14 | * @property {AccountsWrapper} _accountsWrapper 15 | */ 16 | class RememberMe { 17 | constructor() { 18 | this._accountsWrapper = null; 19 | this._init(); 20 | } 21 | 22 | /** 23 | * Configures default Accounts system to be used. 24 | * @private 25 | */ 26 | _init() { 27 | this._accountsWrapper = new AccountsWrapper(Accounts); 28 | this._accountsWrapper.configure(); 29 | } 30 | 31 | /** 32 | * Login method. 33 | * To be used in the same way as "Meteor.loginWithPassword" except 34 | * of being able to pass the RememberMe flag as the last parameter. 35 | * @param params 36 | * @public 37 | */ 38 | loginWithPassword = (...params) => { 39 | const [selector, password, ...rest] = params; 40 | const flag = exportFlagFromParams(rest); 41 | const callback = exportCallbackFromParams(rest); 42 | this._accountsWrapper.loginWithPassword({ selector, password, flag, callback }); 43 | }; 44 | 45 | /** 46 | * Gives the possibility to change the default Accounts system to a different one. 47 | * The new instance can use different DDP connection or even be on the same one. 48 | * Example of usage is given in the documentation. 49 | * @public 50 | */ 51 | changeAccountsSystem = (accountsInstance) => { 52 | if (accountsInstance instanceof AccountsClient !== true) { 53 | throw new Meteor.Error(400, 'Provided argument is not a valid instance of AccountsClient'); 54 | } 55 | this._accountsWrapper = new AccountsWrapper(accountsInstance); 56 | this._accountsWrapper.configure(); 57 | } 58 | } 59 | 60 | export const factory = () => new RememberMe(); 61 | 62 | export default factory(); 63 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.4.4 2 | accounts-password@1.5.1 3 | allow-deny@1.1.0 4 | autoupdate@1.6.0 5 | babel-compiler@7.3.4 6 | babel-runtime@1.3.0 7 | base64@1.0.12 8 | binary-heap@1.0.11 9 | blaze@2.3.3 10 | blaze-html-templates@1.1.2 11 | blaze-tools@1.0.10 12 | boilerplate-generator@1.6.0 13 | caching-compiler@1.2.1 14 | caching-html-compiler@1.1.3 15 | callback-hook@1.1.0 16 | check@1.3.1 17 | ddp@1.4.0 18 | ddp-client@2.3.3 19 | ddp-common@1.4.0 20 | ddp-rate-limiter@1.0.7 21 | ddp-server@2.3.0 22 | deps@1.0.12 23 | diff-sequence@1.1.1 24 | dynamic-import@0.5.1 25 | ecmascript@0.12.7 26 | ecmascript-runtime@0.7.0 27 | ecmascript-runtime-client@0.8.0 28 | ecmascript-runtime-server@0.7.1 29 | ejson@1.1.0 30 | email@1.2.3 31 | es5-shim@4.8.0 32 | fetch@0.1.1 33 | geojson-utils@1.0.10 34 | hot-code-push@1.0.4 35 | html-tools@1.0.11 36 | htmljs@1.0.11 37 | http@1.4.2 38 | id-map@1.1.0 39 | inter-process-messaging@0.1.0 40 | jquery@1.11.11 41 | launch-screen@1.1.1 42 | livedata@1.0.18 43 | lmieulet:meteor-coverage@3.2.0 44 | localstorage@1.2.0 45 | logging@1.1.20 46 | meteor@1.9.3 47 | meteor-base@1.4.0 48 | meteortesting:browser-tests@1.3.1 49 | meteortesting:mocha@1.1.3 50 | meteortesting:mocha-core@6.2.2 51 | minifier-css@1.4.2 52 | minifier-js@2.4.1 53 | minimongo@1.4.5 54 | mobile-experience@1.0.5 55 | mobile-status-bar@1.0.14 56 | modern-browsers@0.1.4 57 | modules@0.13.0 58 | modules-runtime@0.10.3 59 | mongo@1.6.3 60 | mongo-decimal@0.1.1 61 | mongo-dev-server@1.1.0 62 | mongo-id@1.0.7 63 | npm-bcrypt@0.9.3 64 | npm-mongo@3.1.2 65 | observe-sequence@1.0.16 66 | ordered-dict@1.1.0 67 | promise@0.11.2 68 | random@1.1.0 69 | rate-limit@1.0.9 70 | reactive-var@1.0.11 71 | reload@1.3.0 72 | retry@1.1.0 73 | routepolicy@1.1.0 74 | service-configuration@1.0.11 75 | sha@1.0.9 76 | shell-server@0.4.0 77 | socket-stream-client@0.2.2 78 | spacebars@1.0.15 79 | spacebars-compiler@1.1.3 80 | srp@1.0.12 81 | standard-minifier-css@1.5.3 82 | standard-minifier-js@2.4.1 83 | templating@1.3.2 84 | templating-compiler@1.3.3 85 | templating-runtime@1.3.2 86 | templating-tools@1.1.2 87 | tprzytula:remember-me@1.0.2 88 | tracker@1.2.0 89 | ui@1.0.13 90 | underscore@1.0.10 91 | url@1.2.0 92 | webapp@1.7.4 93 | webapp-hashing@1.0.9 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rememberme", 3 | "version": "1.0.2", 4 | "description": "Extension for Meteor account-base package with the implementation of rememberMe", 5 | "license": "MIT", 6 | "author": "Tomasz Przytuła ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/tprzytulacc/Meteor-Remember-Me" 10 | }, 11 | "keywords": [ 12 | "meteor", 13 | "rememberme", 14 | "autologin", 15 | "login" 16 | ], 17 | "homepage": "https://github.com/tprzytulacc/Meteor-Remember-Me", 18 | "scripts": { 19 | "eslint": "eslint ./client ./server ./test || true", 20 | "eslint-check": "eslint ./client ./server ./test", 21 | "eslint-fix": "eslint ./client ./server ./test --fix || true", 22 | "test": "npm run eslint-check && npm run test:headless", 23 | "test:headless": "cross-env BABEL_ENV=COVERAGE TEST_BROWSER_DRIVER=chrome COVERAGE=1 COVERAGE_OUT_LCOVONLY=1 COVERAGE_OUT_HTML=1 COVERAGE_OUT_TEXT_SUMMARY=1 COVERAGE_APP_FOLDER=$PWD/ meteor test --once --driver-package meteortesting:mocha", 24 | "test:watch": "cross-env WATCH=1 BABEL_ENV=COVERAGE TEST_BROWSER_DRIVER=chrome meteor test --driver-package meteortesting:mocha", 25 | "test:e2e": "cd test/e2e && meteor npm install && meteor npm test", 26 | "coveralls": "cat .coverage/lcov.info | coveralls" 27 | }, 28 | "devDependencies": { 29 | "@babel/runtime": "^7.7.2", 30 | "babel-eslint": "^10.0.3", 31 | "babel-plugin-istanbul": "^5.2.0", 32 | "bcrypt": "^3.0.6", 33 | "chromedriver": "78.0.1", 34 | "coveralls": "^3.0.7", 35 | "cross-env": "^5.2.1", 36 | "eslint": "^6.6.0", 37 | "eslint-import-resolver-meteor": "^0.4.0", 38 | "eslint-plugin-import": "^2.18.2", 39 | "eslint-plugin-meteor": "^6.0.0", 40 | "meteor-node-stubs": "^0.4.1", 41 | "pre-commit": "^1.2.2", 42 | "pre-push": "^0.1.1", 43 | "selenium-webdriver": "3.6.0", 44 | "sinon": "^7.5.0", 45 | "ultimate-chai": "^4.1.1" 46 | }, 47 | "pre-commit": [ 48 | "test" 49 | ], 50 | "pre-push": [ 51 | "test" 52 | ], 53 | "babel": { 54 | "env": { 55 | "COVERAGE": { 56 | "plugins": [ 57 | "istanbul" 58 | ] 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/lib/connectionLastLoginToken.js: -------------------------------------------------------------------------------- 1 | import * as helpers from './helpers'; 2 | import * as integrationCollection from '../integration/collection'; 3 | import * as integrationAccounts from '../integration/accounts'; 4 | 5 | /** 6 | * Gives tools to manage the last login token associated to the connectionId. 7 | * @property {string} connectionId 8 | * @property {string} lastToken 9 | * @property {Object} tokenOwner 10 | * @class 11 | */ 12 | class ConnectionLastLoginToken { 13 | constructor(connectionId) { 14 | this.connectionId = connectionId; 15 | this.lastToken = ''; 16 | this.tokenOwner = null; 17 | } 18 | 19 | /** 20 | * Returns all tokens for the user. 21 | * @returns {Object[]} tokens 22 | * @private 23 | */ 24 | _getAllUserTokens() { 25 | return helpers.getValueFromTree(this.tokenOwner, 'services.resume.loginTokens') || []; 26 | } 27 | 28 | /** 29 | * Looks for the last token that matches this connectionId and stores it. 30 | * @private 31 | */ 32 | _fetchLastToken() { 33 | const lastToken = integrationAccounts.getConnectionLastLoginToken(this.connectionId); 34 | const tokenOwner = integrationCollection.getUserByToken(lastToken); 35 | if (!lastToken || !tokenOwner) { 36 | throw new Error(`Could not find tokens for ${this.connectionId}`); 37 | } 38 | Object.assign(this, { lastToken, tokenOwner }); 39 | } 40 | 41 | /** 42 | * Appends/Replaces fields in the loginToken; 43 | * @param {Object} fields 44 | * @returns {boolean} result 45 | */ 46 | updateFields(fields) { 47 | this._fetchLastToken(); 48 | const loginTokens = this._getAllUserTokens(); 49 | const updatedLoginTokens = loginTokens.map((token) => { 50 | if (token.hashedToken === this.lastToken) { 51 | return Object.assign({}, token, fields); 52 | } 53 | return token; 54 | }); 55 | const result = integrationCollection.replaceUserTokens( 56 | this.tokenOwner._id, 57 | updatedLoginTokens 58 | ); 59 | return result === 1; 60 | } 61 | } 62 | 63 | export const factory = (...params) => new ConnectionLastLoginToken(...params); 64 | 65 | export default ConnectionLastLoginToken; 66 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import { check } from 'meteor/check'; 2 | import * as integrationMethod from './integration/method'; 3 | import * as integrationAccounts from './integration/accounts'; 4 | import * as integrationError from './integration/error'; 5 | import * as ConnectionLastLoginToken from './lib/connectionLastLoginToken'; 6 | import * as LoginAttemptValidator from './loginAttemptValidator'; 7 | 8 | /** 9 | * RememberMe server-side 10 | * Extends functionality of Meteor's Accounts system. 11 | */ 12 | class RememberMe { 13 | constructor() { 14 | this._activate(); 15 | } 16 | 17 | /** 18 | * Activates the functionality on the server 19 | * @private 20 | */ 21 | _activate() { 22 | this._createMethod(); 23 | integrationAccounts.addValidationStep(RememberMe._validateAttempt.bind(this)); 24 | } 25 | 26 | /** 27 | * Creates Meteor method to listen for requests coming from users. 28 | * Users who will use the rememberMe functionality will pass the setting 29 | * using this method 30 | * @private 31 | */ 32 | _createMethod() { 33 | if (this._updateRememberMeMethod) return; 34 | this._updateRememberMeMethod = integrationMethod.factory({ 35 | name: 'tprzytula:rememberMe-update', 36 | callback: RememberMe._updateRememberMe.bind(this) 37 | }); 38 | this._updateRememberMeMethod.setup(); 39 | } 40 | 41 | /** 42 | * Updates the state of rememberMe setting for requesting connection. 43 | * @param {Object} connection 44 | * @param {boolean} rememberMe 45 | * @returns {boolean} result 46 | * @private 47 | */ 48 | static _updateRememberMe({ connection }, rememberMe) { 49 | check(rememberMe, Boolean); 50 | const lastLoginToken = ConnectionLastLoginToken.factory(connection.id); 51 | return lastLoginToken.updateFields({ rememberMe }); 52 | } 53 | 54 | /** 55 | * Validated login attempt 56 | * @param {Object} attempt 57 | * @returns {boolean} result 58 | * @private 59 | */ 60 | static _validateAttempt(attempt) { 61 | const validator = LoginAttemptValidator.factory(attempt); 62 | const { result, errorCode, reason, error } = validator.validate(); 63 | if (error) { 64 | throw error; 65 | } else if (!result) { 66 | throw integrationError.createMeteorError(errorCode, reason); 67 | } 68 | return true; 69 | } 70 | } 71 | 72 | export const factory = () => new RememberMe(); 73 | 74 | export default factory(); 75 | -------------------------------------------------------------------------------- /test/server/lib/connectionLastLoginToken.spec.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | import { expect } from 'ultimate-chai'; 3 | import sinon from 'sinon'; 4 | import * as connectionLastLoginToken from '../../../server/lib/connectionLastLoginToken'; 5 | 6 | describe('Given the connectionLastLoginToken class', () => { 7 | const sandbox = sinon.createSandbox(); 8 | let instance, updateTokensStub, sampleTokens, sampleUser; 9 | 10 | beforeEach(() => { 11 | sampleTokens = [ 12 | { 13 | when: new Date(), 14 | hashedToken: '7Rg3IXBj0hNZJWQa677ILS2jWKtt2rC7o9Nat3+7+zw=' 15 | }, 16 | { 17 | when: new Date(), 18 | hashedToken: '+7+zw=' 19 | } 20 | ]; 21 | sampleUser = { 22 | _id: '6vCQJw5TrRaeb9ZJM', 23 | username: 'test', 24 | services: { 25 | resume: { 26 | loginTokens: sampleTokens 27 | } 28 | } 29 | }; 30 | sandbox.stub(Accounts, '_getLoginToken').returns(sampleTokens[0].hashedToken); 31 | sandbox.stub(Meteor.users, 'findOne').returns(sampleUser); 32 | updateTokensStub = sandbox.stub(Meteor.users, 'update').returns(1); 33 | instance = connectionLastLoginToken.factory('connection-id'); 34 | }); 35 | 36 | afterEach(() => { 37 | sandbox.restore(); 38 | }); 39 | 40 | describe('When updateFields is called', () => { 41 | beforeEach(() => { 42 | instance.updateFields({ rememberMe: true }); 43 | }); 44 | 45 | it('should request to replace user tokens with correct params', () => { 46 | const expectedUpdatedToken = Object.assign(sampleTokens[0], { rememberMe: true }); 47 | const expectedQuery = { 48 | $set: { 49 | 'services.resume.loginTokens': [expectedUpdatedToken, sampleTokens[1]] 50 | } 51 | }; 52 | expect(updateTokensStub).to.be 53 | .calledWithExactly(sampleUser._id, expectedQuery); 54 | }); 55 | }); 56 | }); 57 | 58 | describe('Given the connectionLastLoginToken factory', () => { 59 | it('should be a function', () => { 60 | expect(typeof connectionLastLoginToken.factory).to.be.equal('function'); 61 | }); 62 | 63 | describe('When invoked', () => { 64 | let result; 65 | 66 | beforeEach(() => { 67 | result = connectionLastLoginToken.factory(); 68 | }); 69 | 70 | it('should return an instance of ConnectionLastLoginToken', () => { 71 | expect(result instanceof connectionLastLoginToken.default).to.be.equal(true); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/server/integration/method.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import sinon from 'sinon'; 3 | import * as MeteorMethod from '../../../server/integration/method'; 4 | 5 | describe('When an instance is created', () => { 6 | const sandbox = sinon.createSandbox(); 7 | let meteorMethodInstance, callbackSpy; 8 | 9 | beforeEach(() => { 10 | const callbackMock = { callback: () => {} }; 11 | callbackSpy = sandbox.spy(callbackMock, 'callback'); 12 | meteorMethodInstance = MeteorMethod.factory({ 13 | name: 'test', 14 | callback: callbackMock.callback 15 | }); 16 | }); 17 | 18 | afterEach(() => { 19 | sandbox.restore(); 20 | }); 21 | 22 | describe('And setup is invoked', () => { 23 | let meteorMethodsStub; 24 | 25 | beforeEach(() => { 26 | meteorMethodsStub = sandbox.stub(Meteor, 'methods'); 27 | meteorMethodInstance.setup(); 28 | }); 29 | 30 | it('should pass a new method configuration to Meteor', () => { 31 | expect(meteorMethodsStub).to.be.calledOnce(); 32 | }); 33 | 34 | describe('And the method configuration', () => { 35 | let receivedConfiguration; 36 | 37 | beforeEach(() => { 38 | [receivedConfiguration] = meteorMethodsStub.getCall(0).args; 39 | }); 40 | 41 | it('should contain a method with provided name', () => { 42 | expect('test' in receivedConfiguration).to.be.equal(true); 43 | expect(typeof receivedConfiguration.test).to.be.equal('function'); 44 | }); 45 | 46 | describe('And when the method is invoked', () => { 47 | const sampleParams = ['1', '2', '3']; 48 | 49 | beforeEach(() => { 50 | receivedConfiguration.test(...sampleParams); 51 | }); 52 | 53 | it('should call provided callback with method context and provided params', () => { 54 | expect(callbackSpy).to.be.calledWith(sinon.match.object, ...sampleParams); 55 | }); 56 | }); 57 | }); 58 | }); 59 | }); 60 | 61 | describe('Given MeteorMethod factory', () => { 62 | it('should be a function', () => { 63 | expect(typeof MeteorMethod.factory).to.be.equal('function'); 64 | }); 65 | 66 | describe('When invoked', () => { 67 | let result; 68 | 69 | beforeEach(() => { 70 | result = MeteorMethod.factory({ name: 'test', callback: () => {} }); 71 | }); 72 | 73 | it('should return an instance of MeteorMethod', () => { 74 | expect(result instanceof MeteorMethod.default).to.be.equal(true); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /server/loginAttemptValidator/validators/shouldResumeBeAccepted.js: -------------------------------------------------------------------------------- 1 | import * as Validator from './validator'; 2 | import * as integrationAccounts from '../../integration/accounts'; 3 | 4 | /** 5 | * ShouldResumeBeAccepted 6 | * 7 | * Validates if the attempt is a resume type. 8 | * Then decides if it should be accepted. 9 | * 10 | * @class 11 | * @extends Validator 12 | */ 13 | class ShouldResumeBeAccepted extends Validator.default { 14 | static _didUserReportSetting(userResume) { 15 | return 'rememberMe' in userResume; 16 | } 17 | 18 | constructor(...params) { 19 | super(...params); 20 | super.setErrorDetails({ 21 | reason: 'Resume not allowed when user does not have remember me', 22 | errorCode: 405 23 | }); 24 | } 25 | 26 | /** 27 | * Runs the validation. 28 | * @returns {boolean} isValid 29 | */ 30 | validate() { 31 | if (this.loginAttempt.type !== 'resume') { 32 | return true; 33 | } 34 | 35 | const userResume = this._getResume(); 36 | if (!userResume) { 37 | return false; 38 | } 39 | 40 | if (!ShouldResumeBeAccepted._didUserReportSetting(userResume)) { 41 | // User did not report the setting so it should work in a default way 42 | return true; 43 | } 44 | 45 | const methodArguments = this.loginAttempt.methodArguments || []; 46 | const loggedAtLeastOnce = 47 | methodArguments.some(argument => argument.loggedAtLeastOnce === true); 48 | return (userResume.rememberMe || loggedAtLeastOnce); 49 | } 50 | 51 | /** 52 | * Fetches loginToken record associated to this login attempt from the database. 53 | * @returns {Object} loginToken 54 | * @private 55 | */ 56 | _getResume() { 57 | const loginTokens = this._getUsersLoginTokens(); 58 | const hashedToken = this._getTokenFromAttempt(); 59 | return loginTokens.find(item => item.hashedToken === hashedToken); 60 | } 61 | 62 | /** 63 | * Hashes and returns login token passed in the login attempt. 64 | * @returns {string} 65 | * @private 66 | */ 67 | _getTokenFromAttempt() { 68 | const attemptToken = (this.loginAttempt.methodArguments) 69 | ? this.loginAttempt.methodArguments[0].resume 70 | : ''; 71 | return integrationAccounts.hashLoginToken(attemptToken); 72 | } 73 | 74 | /** 75 | * Finds user record in the database and returns all of the user's loginTokens. 76 | * @returns {[Object]} loginTokens 77 | * @private 78 | */ 79 | _getUsersLoginTokens() { 80 | const user = integrationAccounts.findUser(this.loginAttempt.user); 81 | return (user && user.services && user.services.resume) 82 | ? user.services.resume.loginTokens 83 | : []; 84 | } 85 | } 86 | 87 | export const factory = (...params) => new ShouldResumeBeAccepted(...params); 88 | 89 | export default ShouldResumeBeAccepted; 90 | -------------------------------------------------------------------------------- /test/server/loginAttemptValidator/validators/isAllowed.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import * as isAllowed from '../../../../server/loginAttemptValidator/validators/isAllowed'; 3 | 4 | describe('Given the isAllowed validator', () => { 5 | let instance, loginAttempt, result; 6 | 7 | describe('When login attempt is allowed', () => { 8 | beforeEach(() => { 9 | loginAttempt = { allowed: true }; 10 | instance = isAllowed.factory(loginAttempt); 11 | }); 12 | 13 | describe('And the validation is invoked', () => { 14 | beforeEach(() => { 15 | result = instance.validate(); 16 | }); 17 | 18 | it('should not pass the validation', () => { 19 | expect(result).to.be.equal(true); 20 | }); 21 | }); 22 | }); 23 | 24 | describe('When login attempt is disallowed', () => { 25 | beforeEach(() => { 26 | loginAttempt = { allowed: false, error: 'not today' }; 27 | instance = isAllowed.factory(loginAttempt); 28 | }); 29 | 30 | describe('And the validation is invoked', () => { 31 | beforeEach(() => { 32 | result = instance.validate(); 33 | }); 34 | 35 | it('should not pass the validation', () => { 36 | expect(result).to.be.equal(false); 37 | }); 38 | 39 | describe('And the getError is invoked', () => { 40 | beforeEach(() => { 41 | result = instance.getError(); 42 | }); 43 | 44 | it('should return a proper error details', () => { 45 | expect(result).to.be.deep.equal({ 46 | result: false, 47 | reason: 'Attempt disallowed by previous validation', 48 | error: 'not today' 49 | }); 50 | }); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('When login attempt is empty', () => { 56 | beforeEach(() => { 57 | loginAttempt = {}; 58 | instance = isAllowed.factory(loginAttempt); 59 | }); 60 | 61 | describe('And the validation is invoked', () => { 62 | beforeEach(() => { 63 | result = instance.validate(); 64 | }); 65 | 66 | it('should not pass the validation', () => { 67 | expect(result).to.be.equal(false); 68 | }); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('Given the isAllowed factory', () => { 74 | it('should be a function', () => { 75 | expect(typeof isAllowed.factory).to.be.equal('function'); 76 | }); 77 | 78 | describe('When invoked', () => { 79 | let result; 80 | 81 | beforeEach(() => { 82 | result = isAllowed.factory({ error: 'something' }); 83 | }); 84 | 85 | it('should return an instance of IsAllowed validator', () => { 86 | expect(result instanceof isAllowed.default).to.be.equal(true); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /client/accountsConfigurator.js: -------------------------------------------------------------------------------- 1 | import { prepareLoginWithPasswordMethod } from './lib/overriding'; 2 | 3 | const IS_CALLBACK_REGISTERED = 'tprzytula:remember-me_callbackRegistered'; 4 | const IS_METHOD_OVERRIDDEN = 'tprzytula:remember-me_methodOverridden'; 5 | const LOGIN_WITH_PASSWORD_METHOD = 'loginWithPassword'; 6 | 7 | /** 8 | * Extends AccountsClient instance with RememberMe functionality. 9 | * @property {AccountsClient} _accounts 10 | */ 11 | class AccountsConfigurator { 12 | constructor(accounts) { 13 | this._accounts = accounts; 14 | } 15 | 16 | /** 17 | * Starts the configuration process. 18 | * @public 19 | */ 20 | configure() { 21 | this._registerLoginMethod(); 22 | this._registerCallback(); 23 | } 24 | 25 | /** 26 | * Extends accounts with a "loginWithPassword" method, which is 27 | * based on the "Meteor.loginWithPassword". 28 | * @private 29 | */ 30 | _registerLoginMethod() { 31 | if (LOGIN_WITH_PASSWORD_METHOD in this._accounts) return; 32 | this._accounts.loginWithPassword = prepareLoginWithPasswordMethod(this._accounts); 33 | } 34 | 35 | /** 36 | * Registers "onLogin" callback. 37 | * After successful login attempt the loginMethod will be overridden. 38 | * @private 39 | */ 40 | _registerCallback() { 41 | if (IS_CALLBACK_REGISTERED in this._accounts) return; 42 | this._accounts.onLogin(this._overrideCallLoginMethod.bind(this)); 43 | this._accounts[IS_CALLBACK_REGISTERED] = true; 44 | } 45 | 46 | /** 47 | * Overrides Meteor's loginMethod. 48 | * From now each time user will perform login/autologin 49 | * the new loginMethod will be invoked. 50 | * @private 51 | */ 52 | _overrideCallLoginMethod() { 53 | if (IS_METHOD_OVERRIDDEN in this._accounts) return; 54 | this._accounts.callLoginMethod = this._getNewCallLoginMethod(); 55 | this._accounts[IS_METHOD_OVERRIDDEN] = true; 56 | } 57 | 58 | /** 59 | * Prepares login method. 60 | * 61 | * Extends the method arguments with "loggedAtLeastOnce". 62 | * This argument will indicate to the server that we were already 63 | * logged in during this device session, so our previously set rememberMe 64 | * option should not affect the outcome of the next login attempt. 65 | * 66 | * Calls original callLoginMethod at the end with extended "methodArguments". 67 | * @returns {Function} callLoginMethod 68 | * @private 69 | */ 70 | _getNewCallLoginMethod() { 71 | const accountsCallLoginMethod = this._accounts.callLoginMethod.bind(this._accounts); 72 | return (options) => { 73 | const preparedOptions = options || {}; 74 | const argument = { loggedAtLeastOnce: true }; 75 | if (preparedOptions.methodArguments) { 76 | preparedOptions.methodArguments.push(argument); 77 | } else { 78 | preparedOptions.methodArguments = [argument]; 79 | } 80 | accountsCallLoginMethod(preparedOptions); 81 | }; 82 | } 83 | } 84 | 85 | export const factory = (...params) => new AccountsConfigurator(...params); 86 | 87 | export default AccountsConfigurator; 88 | -------------------------------------------------------------------------------- /doc/CUSTOM_ACCOUNTS.md: -------------------------------------------------------------------------------- 1 | # AccountsClient 2 | 3 | ## Custom AccountsClient in Meteor Apps 4 | 5 | #### Wait why? 6 | 7 | Imagine an example situation where you decided to split your Meteor server into two separate ones to reduce the overload. You found it pretty convenient to use one of them only for serving HCP where the separate one will handle all the logic. 8 | 9 | Another example could be an android app in the Google Play store where upon start you could decide between connecting to the *NA* or *EU* server. 10 | 11 | #### How ? 12 | To achieve the first example you will point all the client apps to the HCP server and connect to the separate one using: 13 | 14 | ```js 15 | const remoteConnection = DDP.connect('127.0.0.1:4000'); 16 | ``` 17 | By now you can easily start using methods from the new connection the same way as you are doing it on the main: 18 | ```js 19 | remoteConnection.call('delayApocalypse', 1000, (error) => { 20 | if (error) { 21 | console.error('Could not delay the apocalypse:', error); 22 | } 23 | }) 24 | ``` 25 | 26 | However migrating the accounts system is a bit more tricky. Upon using the default accounts methods the app's clients will try to log in to the main server by the default (which was supposed to be HCP only). 27 | 28 | The first step for migration is to create a new instance of AccountsClient 29 | 30 | ```js 31 | const accountsClient = new AccountsClient({ connection: remoteConnection }); 32 | ``` 33 | 34 | Unfortunately if you want to have a method like `loginWithPassword` then you have to implement it yourself the same way as it's done for the main accounts system [Source](https://github.com/meteor/meteor/blob/46257bad264bf089e35e0fe35494b51fe5849c7b/packages/accounts-password/password_client.js#L33) 35 | 36 | But don't worry! Using tprzytula:remember-me you don't have to worry about that. 37 | 38 | ## Switching to the custom AccountsClient in tprzytula:remember-me 39 | 40 | Using this dependency the login logic always stays the same no matter of which AccountsClient system you are currently using. You can switch the accounts system at any point during your app lifetime. After you will be done with the AccountsClient configuration the only thing you need to do is to pass the instance to *changeAccountsSystem* method and voila! 41 | 42 | ### Example: 43 | 44 | ##### Configuration: 45 | 46 | To let the dependency know that you have and want to use a separate custom account system you need to pass the instance to the `changeAccountsSystem` method. 47 | 48 | ```js 49 | import { AccountsClient } from 'meteor/accounts-base'; 50 | import RememberMe from 'meteor/tprzytula:remember-me'; 51 | 52 | Meteor.remoteConnection = DDP.connect('127.0.0.1:4000'); // Meteor's server for accounts 53 | Meteor.remoteUsers = new AccountsClient({ connection: Meteor.remoteConnection }); 54 | 55 | RememberMe.changeAccountsSystem(Meteor.remoteUsers); 56 | 57 | ``` 58 | 59 | ##### Usage: 60 | 61 | After the configuration you can use the newly set accounts system in the same way you were doing it previously. 62 | 63 | ```js 64 | import RememberMe from 'meteor/tprzytula:remember-me'; 65 | 66 | RememberMe.loginWithPassword('username', 'password', (error) => { 67 | if (error) { 68 | console.error(error); 69 | return; 70 | } 71 | // success! 72 | }, true); 73 | ``` 74 | -------------------------------------------------------------------------------- /test/client/lib/methodParams.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import { 3 | exportFlagFromParams, 4 | exportCallbackFromParams 5 | } from './../../../client/lib/methodParams'; 6 | 7 | describe('Given exportFlagFromParams', () => { 8 | const sampleFlag = false; 9 | const sampleMethod = () => {}; 10 | let result; 11 | 12 | describe('When rememberMe flag is a first param', () => { 13 | beforeEach(() => { 14 | result = exportFlagFromParams([sampleFlag]); 15 | }); 16 | 17 | it('should return the flag', () => { 18 | expect(result).to.be.equal(sampleFlag); 19 | }); 20 | }); 21 | 22 | describe('When rememberMe flag is a second param', () => { 23 | beforeEach(() => { 24 | result = exportFlagFromParams([sampleMethod, sampleFlag]); 25 | }); 26 | 27 | it('should return the flag', () => { 28 | expect(result).to.be.equal(sampleFlag); 29 | }); 30 | }); 31 | 32 | describe('When parameters are only containing a method', () => { 33 | beforeEach(() => { 34 | result = exportFlagFromParams([sampleMethod]); 35 | }); 36 | 37 | it('should return true as default', () => { 38 | expect(result).to.be.equal(true); 39 | }); 40 | }); 41 | 42 | describe('When the parameters are empty', () => { 43 | beforeEach(() => { 44 | result = exportFlagFromParams([]); 45 | }); 46 | 47 | it('should return true as default', () => { 48 | expect(result).to.be.equal(true); 49 | }); 50 | }); 51 | 52 | describe('When nothing is passed to the method', () => { 53 | beforeEach(() => { 54 | result = exportFlagFromParams(); 55 | }); 56 | 57 | it('should return true as default', () => { 58 | expect(result).to.be.equal(true); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('Given exportCallbackFromParams', () => { 64 | const sampleMethod = () => {}; 65 | let result; 66 | 67 | describe('When function is a first param', () => { 68 | beforeEach(() => { 69 | result = exportCallbackFromParams([sampleMethod, 1, 'abc']); 70 | }); 71 | 72 | it('should return the same method', () => { 73 | expect(result).to.be.equal(sampleMethod); 74 | }); 75 | }); 76 | 77 | describe('When function is a second param', () => { 78 | beforeEach(() => { 79 | result = exportCallbackFromParams([1, sampleMethod, 'abc']); 80 | }); 81 | 82 | it('should return a function', () => { 83 | expect(typeof result).to.be.equal('function'); 84 | }); 85 | 86 | describe('And the returned function', () => { 87 | it('should not be the same as the one in params', () => { 88 | expect(result).to.not.be.equal(sampleMethod); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('When function is not in parameters', () => { 94 | beforeEach(() => { 95 | result = exportCallbackFromParams([1, 'abc']); 96 | }); 97 | 98 | it('should return a function', () => { 99 | expect(typeof result).to.be.equal('function'); 100 | }); 101 | }); 102 | 103 | describe('When the parameters are empty', () => { 104 | beforeEach(() => { 105 | result = exportCallbackFromParams([]); 106 | }); 107 | 108 | it('should return a function', () => { 109 | expect(typeof result).to.be.equal('function'); 110 | }); 111 | }); 112 | 113 | describe('When nothing is passed to the method', () => { 114 | beforeEach(() => { 115 | result = exportCallbackFromParams(); 116 | }); 117 | 118 | it('should return a function', () => { 119 | expect(typeof result).to.be.equal('function'); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meteor - Remember Me 2 | [![Coverage Status](https://coveralls.io/repos/github/tprzytula/Meteor-Remember-Me/badge.svg)](https://coveralls.io/github/tprzytula/Meteor-Remember-Me) 3 | 4 | RememberMe is a Meteor package that extends the default Accounts system with a new "remember me" functionality. 5 | 6 | Login sessions by default have the expiry date set to 90 days which mean that once you've logged in into your application you are able to come back to it at any given point of time during this period and be still automatically logged in. You must have to log out yourself if you do not want that to happen. 7 | 8 | However, this is not always what we intend to see as the users of any given application. Sometimes for privacy reasons we want to be sure that once we closed the website no one else would be able to control our account by visiting the website again from the same device without the need to always make sure to log out ourselves. 9 | 10 | The following package gives the ability to control the default behaviour by giving users the power to make the decision if they want to persist their login sessions after they close the application or not. 11 | 12 | All of this comes just as an additional parameter to the `loginWithPassword` method where you can simply provide `true` to keep the default behaviour or `false` if you want Meteor to reject the next auto-login attempt after you leave the website. 13 | 14 | ## Installation 15 | 16 | `meteor add tprzytula:remember-me` 17 | 18 | ##### You can view the package on atmosphere: https://atmospherejs.com/tprzytula/remember-me 19 | 20 | ## Usage 21 | 22 | 1. Import the package on the client side: 23 | 24 | ```js 25 | import RememberMe from 'meteor/tprzytula:remember-me'; 26 | ``` 27 | 28 | 2. Replace your login method with this: 29 | 30 | ```js 31 | RememberMe.loginWithPassword(user, password, (error) => { 32 | // Your previous logic 33 | }, false) 34 | ``` 35 | 36 | If you don't need a callback then you can simply change it to: 37 | 38 | ```js 39 | RememberMe.loginWithPassword(user, password, false) 40 | ``` 41 | 42 | ## API 43 | ###### All methods are client side only 44 | 45 | `loginWithPassword(string | Object: user, string: password, func: callback, boolean: rememberMe = true)` 46 | 47 | Wrapper for a Meteor.loginWithPassword with an addition of rememberMe as the last parameter. 48 | 49 | If not provided, the default for rememberMe is true to match the behaviour of Meteor. 50 | 51 | Refer to the [Meteor's documentation](https://docs.meteor.com/api/accounts.html#Meteor-loginWithPassword) for more information about the login method itself. 52 | 53 | `changeAccountsSystem(AccountsClient: customAccounts)` 54 | 55 | Gives the possibility to set a custom accounts instance to be used for the login system ([more details](doc/CUSTOM_ACCOUNTS.md)) 56 | 57 | ## Important notice 58 | 59 | Many people were trying to solve this problem in the past by storing the setting in local storage and singing out the user or clearing their cached token when they come back. However, this approach might cause many side effects, especially when your business logic uses `onLogin` callbacks because the user will still be logged-in for a split of a second before their logic could take effect. 60 | 61 | You won't be having these problems when using this package. 62 | 63 | **By using this dependency you can be sure that:** 64 | 65 | - Not a single `onLogin` callback will be invoked when your users are coming back to the website after logging-in with `rememberMe` being `false`. 66 | - The `rememberMe` setting is stored only in the MongoDB when you leave the website. There will be no information regarding this setting stored in local storage, IndexedDB etc. 67 | - The `rememberMe` setting is unique not only to the user but also to the login token itself which means that each device will be respected separately. Logging-in on user "test" with rememberMe on your phone and then without rememberMe on your desktop won't stop you from being logged-in automatically the next time you visit the website on your phone. 68 | - Despite the choice you won't be suddenly logged-out when you reconnect after losing connection during the same session. 69 | 70 | ## Testing 71 | 72 | You can test this dependency by running `npm run test:headless` to perform unit tests or `npm run test:e2e` for Cypress tests instead. You can also check the results of a GitHub Action pipeline from the last commit. 73 | 74 | As you might notice I'm using `meteor test` instead of `meteor test-packages`. 75 | 76 | You can find more about why was I forced to create such a hack [here](package.js). Hopefully this will be helpful for you if you ran through similar issues. 77 | 78 | ## Changelog 79 | [Meteor Remember-Me - Changelog](doc/CHANGELOG.md) 80 | -------------------------------------------------------------------------------- /test/client/accountsConfigurator.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import sinon from 'sinon'; 3 | 4 | import { DDP } from 'meteor/ddp-client'; 5 | import { AccountsClient } from 'meteor/accounts-base'; 6 | import * as AccountsConfigurator from './../../client/accountsConfigurator'; 7 | 8 | describe('Given AccountsConfigurator', () => { 9 | const sandbox = sinon.createSandbox(); 10 | let accountsClient, accountsConfigurator, connection; 11 | 12 | beforeEach(() => { 13 | connection = DDP.connect('localhost:3000'); 14 | accountsClient = new AccountsClient({ connection }); 15 | accountsConfigurator = AccountsConfigurator.factory(accountsClient); 16 | }); 17 | 18 | afterEach(() => { 19 | connection.disconnect(); 20 | sandbox.restore(); 21 | }); 22 | 23 | describe('When configure is invoked', () => { 24 | let onLoginStub, onLoginCallback; 25 | 26 | beforeEach(() => { 27 | onLoginStub = sandbox.stub(accountsClient, 'onLogin') 28 | .callsFake((callback) => { onLoginCallback = callback; }); 29 | accountsConfigurator.configure(); 30 | }); 31 | 32 | it('should register onLogin callback with correct method', () => { 33 | expect(onLoginStub).to.be.calledOnce(); 34 | }); 35 | 36 | it('should set loginWithPassword method to the instance', () => { 37 | expect('loginWithPassword' in accountsClient).to.be.equal(true); 38 | }); 39 | 40 | describe('And configure is invoked again', () => { 41 | beforeEach(() => { 42 | accountsConfigurator.configure(); 43 | }); 44 | 45 | it('should not register additional onLogin callbacks', () => { 46 | expect(onLoginStub).to.be.calledOnce(); 47 | }); 48 | }); 49 | 50 | describe('And the users logs in', () => { 51 | let callLoginMethodStub; 52 | 53 | beforeEach(() => { 54 | callLoginMethodStub = sandbox.stub(accountsClient, 'callLoginMethod'); 55 | onLoginCallback(); 56 | }); 57 | 58 | it('should override callLoginMethod', () => { 59 | expect(accountsClient.callLoginMethod).to.not.be.equal(callLoginMethodStub); 60 | }); 61 | 62 | describe('And the user logs in again', () => { 63 | let overriddenCallLoginMethodStub; 64 | 65 | beforeEach(() => { 66 | overriddenCallLoginMethodStub = sandbox.stub(accountsClient, 'callLoginMethod'); 67 | onLoginCallback(); 68 | }); 69 | 70 | it('should not override callLoginMethod again', () => { 71 | expect(accountsClient.callLoginMethod) 72 | .to.be.equal(overriddenCallLoginMethodStub); 73 | }); 74 | }); 75 | 76 | describe('And the callLoginMethod is invoked without parameter', () => { 77 | beforeEach(() => { 78 | accountsClient.callLoginMethod(); 79 | }); 80 | 81 | it('should call original method with loggedAtLeastOnce in arguments', () => { 82 | const expectedParameter = { methodArguments: [{ loggedAtLeastOnce: true }] }; 83 | expect(callLoginMethodStub).to.be.calledWithExactly(expectedParameter); 84 | }); 85 | }); 86 | 87 | describe('And the callLoginMethod is invoked with sample parameter', () => { 88 | const sampleArguments = [ 89 | { username: 'test-user' }, 90 | { password: 'test-password' } 91 | ]; 92 | 93 | beforeEach(() => { 94 | accountsClient.callLoginMethod({ methodArguments: [...sampleArguments] }); 95 | }); 96 | 97 | it('should call original method with sample parameters with an addition of loggedAtLeastOnce in arguments', () => { 98 | const expectedArguments = [...sampleArguments, { loggedAtLeastOnce: true }]; 99 | expect(callLoginMethodStub) 100 | .to.be.calledWithExactly({ methodArguments: expectedArguments }); 101 | }); 102 | }); 103 | }); 104 | }); 105 | }); 106 | 107 | describe('Given AccountsConfigurator factory', () => { 108 | it('should be a function', () => { 109 | expect(typeof AccountsConfigurator.factory).to.be.equal('function'); 110 | }); 111 | 112 | describe('When invoked', () => { 113 | let result; 114 | 115 | beforeEach(() => { 116 | result = AccountsConfigurator.factory(); 117 | }); 118 | 119 | it('should return an instance of AccountsConfigurator', () => { 120 | expect(result instanceof AccountsConfigurator.default).to.be.equal(true); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/server/loginAttemptValidator/validators/shouldResumeBeAccepted.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import { expect } from 'ultimate-chai'; 3 | import * as shouldResumeBeAccepted from '../../../../server/loginAttemptValidator/validators/shouldResumeBeAccepted'; 4 | import * as integrationAccounts from '../../../../server/integration/accounts'; 5 | 6 | describe('Given the shouldResumeBeAccepted validator', () => { 7 | const sandbox = sinon.createSandbox(); 8 | let instance, loginAttemptMock, mockedToken, result; 9 | 10 | beforeEach(() => { 11 | sandbox 12 | .stub(integrationAccounts, 'hashLoginToken') 13 | .callsFake(token => token); 14 | sandbox 15 | .stub(integrationAccounts, 'findUser') 16 | .callsFake(() => ({ 17 | services: { 18 | resume: { 19 | loginTokens: [mockedToken] 20 | } 21 | } 22 | })); 23 | }); 24 | 25 | afterEach(() => { 26 | sandbox.restore(); 27 | }); 28 | 29 | describe('When user starts a new device session after being logged in', () => { 30 | beforeEach(() => { 31 | loginAttemptMock = { 32 | type: 'resume', 33 | methodArguments: [{ resume: 'token' }], 34 | user: { username: 'test' } 35 | }; 36 | instance = shouldResumeBeAccepted.factory(loginAttemptMock); 37 | }); 38 | 39 | describe('And rememberMe was true', () => { 40 | beforeEach(() => { 41 | mockedToken = { hashedToken: 'token', rememberMe: true }; 42 | result = instance.validate(); 43 | }); 44 | 45 | it('should pass the validation', () => { 46 | expect(result).to.be.equal(true); 47 | }); 48 | }); 49 | 50 | describe('And rememberMe was false', () => { 51 | beforeEach(() => { 52 | mockedToken = { hashedToken: 'token', rememberMe: false }; 53 | result = instance.validate(); 54 | }); 55 | 56 | it('should not pass the validation', () => { 57 | expect(result).to.be.equal(false); 58 | }); 59 | 60 | describe('And the getError is invoked', () => { 61 | beforeEach(() => { 62 | result = instance.getError(); 63 | }); 64 | 65 | it('should return a proper error details', () => { 66 | expect(result).to.be.deep.equal({ 67 | result: false, 68 | reason: 'Resume not allowed when user does not have remember me', 69 | errorCode: 405 70 | }); 71 | }); 72 | }); 73 | }); 74 | 75 | describe('And rememberMe was not reported by user', () => { 76 | beforeEach(() => { 77 | mockedToken = { hashedToken: 'token' }; 78 | result = instance.validate(); 79 | }); 80 | 81 | it('should pass the validation', () => { 82 | expect(result).to.be.equal(true); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('When user reconnects after a connection loss', () => { 88 | beforeEach(() => { 89 | loginAttemptMock = { 90 | type: 'resume', 91 | methodArguments: [{ resume: 'token' }, { loggedAtLeastOnce: true }], 92 | user: { username: 'test' } 93 | }; 94 | instance = shouldResumeBeAccepted.factory(loginAttemptMock); 95 | }); 96 | 97 | describe('And rememberMe was true', () => { 98 | beforeEach(() => { 99 | mockedToken = { hashedToken: 'token', rememberMe: true }; 100 | result = instance.validate(); 101 | }); 102 | 103 | it('should pass the validation', () => { 104 | expect(result).to.be.equal(true); 105 | }); 106 | }); 107 | 108 | describe('And rememberMe was false', () => { 109 | beforeEach(() => { 110 | mockedToken = { hashedToken: 'token', rememberMe: false }; 111 | result = instance.validate(); 112 | }); 113 | 114 | it('should pass the validation', () => { 115 | expect(result).to.be.equal(true); 116 | }); 117 | }); 118 | 119 | describe('And rememberMe was not present', () => { 120 | beforeEach(() => { 121 | mockedToken = { hashedToken: 'token' }; 122 | result = instance.validate(); 123 | }); 124 | 125 | it('should pass the validation', () => { 126 | expect(result).to.be.equal(true); 127 | }); 128 | }); 129 | }); 130 | 131 | describe('When user logged in using a method', () => { 132 | beforeEach(() => { 133 | loginAttemptMock = { type: 'password' }; 134 | instance = shouldResumeBeAccepted.factory(loginAttemptMock); 135 | }); 136 | 137 | describe('And the validate method is invoked', () => { 138 | beforeEach(() => { 139 | result = instance.validate(); 140 | }); 141 | 142 | it('should pass the validation', () => { 143 | expect(result).to.be.equal(true); 144 | }); 145 | }); 146 | }); 147 | }); 148 | 149 | describe('Given the shouldResumeBeAccepted factory', () => { 150 | it('should be a function', () => { 151 | expect(typeof shouldResumeBeAccepted.factory).to.be.equal('function'); 152 | }); 153 | 154 | describe('When invoked', () => { 155 | let result; 156 | 157 | beforeEach(() => { 158 | result = shouldResumeBeAccepted.factory(); 159 | }); 160 | 161 | it('should return an instance of ShouldResumeBeAccepted validator', () => { 162 | expect(result instanceof shouldResumeBeAccepted.default).to.be.equal(true); 163 | }); 164 | }); 165 | }); 166 | 167 | -------------------------------------------------------------------------------- /test/server/integration/accounts.spec.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | import { expect } from 'ultimate-chai'; 3 | import sinon from 'sinon'; 4 | import { 5 | addValidationStep, 6 | findUserByUsername, 7 | findUserByEmail, 8 | findUser, 9 | getConnectionLastLoginToken, 10 | hashLoginToken 11 | } from '../../../server/integration/accounts'; 12 | 13 | describe('Given addValidationStep method', () => { 14 | const sandbox = sinon.createSandbox(); 15 | let validateLoginAttemptStub; 16 | 17 | beforeEach(() => { 18 | validateLoginAttemptStub = sandbox.stub(Accounts, 'validateLoginAttempt'); 19 | }); 20 | 21 | afterEach(() => { 22 | sandbox.restore(); 23 | }); 24 | 25 | describe('When a param is passed', () => { 26 | const sampleMethod = () => {}; 27 | 28 | beforeEach(() => { 29 | addValidationStep(sampleMethod); 30 | }); 31 | 32 | it('should pass the param to the Accounts validateLoginAttempt method', () => { 33 | expect(validateLoginAttemptStub).to.be.calledWith(sampleMethod); 34 | }); 35 | }); 36 | }); 37 | 38 | describe('Given findUserByUsername method', () => { 39 | const sandbox = sinon.createSandbox(); 40 | let findUserByUsernameStub; 41 | 42 | beforeEach(() => { 43 | findUserByUsernameStub = sandbox.stub(Accounts, 'findUserByUsername'); 44 | }); 45 | 46 | afterEach(() => { 47 | sandbox.restore(); 48 | }); 49 | 50 | describe('When a param is passed', () => { 51 | const sampleUsername = 'test-user'; 52 | 53 | beforeEach(() => { 54 | findUserByUsername(sampleUsername); 55 | }); 56 | 57 | it('should pass the param to the Accounts findUserByUsername method', () => { 58 | expect(findUserByUsernameStub).to.be.calledWith(sampleUsername); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('Given findUserByEmail method', () => { 64 | const sandbox = sinon.createSandbox(); 65 | let findUserByEmailStub; 66 | 67 | beforeEach(() => { 68 | findUserByEmailStub = sandbox.stub(Accounts, 'findUserByEmail'); 69 | }); 70 | 71 | afterEach(() => { 72 | sandbox.restore(); 73 | }); 74 | 75 | describe('When a param is passed', () => { 76 | const sampleEmail = 'test@test.com'; 77 | 78 | beforeEach(() => { 79 | findUserByEmail(sampleEmail); 80 | }); 81 | 82 | it('should pass the param to the Accounts findUserByEmail method', () => { 83 | expect(findUserByEmailStub).to.be.calledWith(sampleEmail); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('Given findUser method', () => { 89 | const sandbox = sinon.createSandbox(); 90 | const sampleUsername = 'test'; 91 | const sampleEmail = 'test@test.com'; 92 | let findUserByUsernameStub, findUserByEmailStub; 93 | 94 | beforeEach(() => { 95 | findUserByUsernameStub = sandbox.stub(Accounts, 'findUserByUsername'); 96 | findUserByEmailStub = sandbox.stub(Accounts, 'findUserByEmail'); 97 | }); 98 | 99 | afterEach(() => { 100 | sandbox.restore(); 101 | }); 102 | 103 | describe('When the record contains username and email', () => { 104 | beforeEach(() => { 105 | findUser({ username: sampleUsername, emails: [ { address: sampleEmail } ] }); 106 | }); 107 | 108 | it('should pass the username to the Accounts findUserByUsernameStub method', () => { 109 | expect(findUserByUsernameStub).to.be.calledWith(sampleUsername); 110 | }); 111 | }); 112 | 113 | describe('When the record contains only username', () => { 114 | beforeEach(() => { 115 | findUser({ username: sampleUsername }); 116 | }); 117 | 118 | it('should pass the username to the Accounts findUserByUsernameStub method', () => { 119 | expect(findUserByUsernameStub).to.be.calledWith(sampleUsername); 120 | }); 121 | }); 122 | 123 | describe('When the record contains only email', () => { 124 | beforeEach(() => { 125 | findUser({ emails: [ { address: sampleEmail } ] }); 126 | }); 127 | 128 | it('should pass the email to the Accounts findUserByEmail method', () => { 129 | expect(findUserByEmailStub).to.be.calledWith(sampleEmail); 130 | }); 131 | }); 132 | 133 | describe('When the record does not contain username or email', () => { 134 | beforeEach(() => { 135 | findUser(); 136 | }); 137 | 138 | it('should not call the Accounts methods', () => { 139 | expect(findUserByUsernameStub).to.not.be.called(); 140 | expect(findUserByEmailStub).to.not.be.called(); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('Given getConnectionLastLoginToken method', () => { 146 | const sandbox = sinon.createSandbox(); 147 | let _getLoginTokenStub; 148 | 149 | beforeEach(() => { 150 | _getLoginTokenStub = sandbox.stub(Accounts, '_getLoginToken'); 151 | }); 152 | 153 | afterEach(() => { 154 | sandbox.restore(); 155 | }); 156 | 157 | describe('When a param is passed', () => { 158 | const sampleConnectionId = 'test-connection-id'; 159 | 160 | beforeEach(() => { 161 | getConnectionLastLoginToken(sampleConnectionId); 162 | }); 163 | 164 | it('should pass the param to the Accounts validateLoginAttempt method', () => { 165 | expect(_getLoginTokenStub).to.be.calledWith(sampleConnectionId); 166 | }); 167 | }); 168 | }); 169 | 170 | describe('Given hashLoginToken method', () => { 171 | const sandbox = sinon.createSandbox(); 172 | let _hashLoginToken; 173 | 174 | beforeEach(() => { 175 | _hashLoginToken = sandbox.stub(Accounts, '_hashLoginToken'); 176 | }); 177 | 178 | afterEach(() => { 179 | sandbox.restore(); 180 | }); 181 | 182 | describe('When a param is passed', () => { 183 | const sampleToken = 'sample-token'; 184 | 185 | beforeEach(() => { 186 | hashLoginToken(sampleToken); 187 | }); 188 | 189 | it('should pass the param to the Accounts validateLoginAttempt method', () => { 190 | expect(_hashLoginToken).to.be.calledWith(sampleToken); 191 | }); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /test/client/accountsWrapper.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import sinon from 'sinon'; 3 | 4 | import { DDP } from 'meteor/ddp-client'; 5 | import { AccountsClient } from 'meteor/accounts-base'; 6 | import AccountsConfigurator from './../../client/accountsConfigurator'; 7 | import * as AccountsWrapper from './../../client/accountsWrapper'; 8 | import * as Alerts from './../../client/lib/alerts'; 9 | 10 | describe('Given AccountsWrapper', () => { 11 | const sandbox = sinon.createSandbox(); 12 | const sampleCorrectParams = { 13 | selector: 'test-user', 14 | password: 'test-password', 15 | flag: true, 16 | callback: sandbox.stub() 17 | }; 18 | let accountsClient, accountsWrapper, callStub, connection; 19 | 20 | beforeEach(() => { 21 | sampleCorrectParams.callback = sandbox.stub(); 22 | connection = DDP.connect('localhost:3000'); 23 | accountsClient = new AccountsClient({ connection }); 24 | accountsClient.loginWithPassword = sandbox.stub().callsFake((...params) => { 25 | const [selector, password, callback] = params; 26 | const attemptValid = selector === sampleCorrectParams.selector && 27 | password === sampleCorrectParams.password; 28 | if (attemptValid) { 29 | callback(); 30 | return; 31 | } 32 | callback('User not found'); 33 | }); 34 | callStub = sandbox.stub(accountsClient.connection, 'call') 35 | .callsFake((methodName, parameter, callback) => { 36 | callback(); 37 | }); 38 | accountsWrapper = AccountsWrapper.factory(accountsClient); 39 | }); 40 | 41 | afterEach(() => { 42 | connection.disconnect(); 43 | sandbox.restore(); 44 | }); 45 | 46 | describe('When initialised', () => { 47 | let configureSpy; 48 | 49 | beforeEach(() => { 50 | configureSpy = sandbox.stub(AccountsConfigurator.prototype, 'configure'); 51 | accountsWrapper.configure(); 52 | }); 53 | 54 | it('should initiate setup in AccountsConfiguration', () => { 55 | expect(configureSpy).to.be.calledOnce(); 56 | }); 57 | 58 | describe('And loginWithPassword is invoked with correct login details', () => { 59 | beforeEach(() => { 60 | accountsWrapper.loginWithPassword(sampleCorrectParams); 61 | }); 62 | 63 | it('should call loginWithPassword from the accounts instance with correct parameters', () => { 64 | const { selector, password } = sampleCorrectParams; 65 | expect(accountsClient.loginWithPassword) 66 | .to.be.calledWith(selector, password); 67 | }); 68 | 69 | it('should call callback without any error', () => { 70 | expect(sampleCorrectParams.callback).to.be.calledWith(undefined); 71 | }); 72 | 73 | it('should send request to update the rememberMe flag', () => { 74 | expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', sampleCorrectParams.flag); 75 | }); 76 | }); 77 | 78 | describe('And loginWithPassword with incorrect login details', () => { 79 | const sampleIncorrectParams = { 80 | selector: 'test-user-wrong', 81 | password: 'test-password-wrong', 82 | flag: true, 83 | callback: sinon.stub() 84 | }; 85 | 86 | beforeEach(() => { 87 | accountsWrapper.loginWithPassword(sampleIncorrectParams); 88 | }); 89 | 90 | it('should pass error to the callback', () => { 91 | expect(sampleIncorrectParams.callback).to.be.calledWith('User not found'); 92 | }); 93 | 94 | it('should not send request to update the rememberMe flag', () => { 95 | expect(callStub).not.to.be.calledWith('tprzytula:rememberMe-update'); 96 | }); 97 | }); 98 | 99 | describe('And rememberMe is not activated on the server', () => { 100 | beforeEach(() => { 101 | callStub.callsFake((methodName, parameter, callback) => { 102 | if (methodName === 'tprzytula:rememberMe-update') { 103 | callback(new Meteor.Error(404)); 104 | } 105 | }); 106 | }); 107 | 108 | describe('And loginWithPassword is invoked', () => { 109 | let alertStub; 110 | 111 | beforeEach(() => { 112 | alertStub = sandbox.stub(Alerts, 'rememberMeNotActive'); 113 | accountsWrapper.loginWithPassword(sampleCorrectParams); 114 | }); 115 | 116 | it('should alert that rememberMe is not activated on the server', () => { 117 | expect(alertStub).to.be.calledOnce(); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('And server throws an unexpected error when requested to update rememberMe flag', () => { 123 | beforeEach(() => { 124 | callStub.callsFake((methodName, parameter, callback) => { 125 | if (methodName === 'tprzytula:rememberMe-update') { 126 | callback(new Meteor.Error(401)); 127 | } 128 | }); 129 | }); 130 | 131 | describe('And loginWithPassword is invoked', () => { 132 | let alertStub; 133 | 134 | beforeEach(() => { 135 | alertStub = sandbox.stub(Alerts, 'couldNotUpdateRememberMe'); 136 | accountsWrapper.loginWithPassword(sampleCorrectParams); 137 | }); 138 | 139 | it('should alert that it could not update the rememberMe setting', () => { 140 | expect(alertStub).to.be.calledOnce(); 141 | }); 142 | }); 143 | }); 144 | }); 145 | }); 146 | 147 | describe('Given AccountsWrapper factory', () => { 148 | it('should be a function', () => { 149 | expect(typeof AccountsWrapper.factory).to.be.equal('function'); 150 | }); 151 | 152 | describe('When invoked', () => { 153 | let result; 154 | 155 | beforeEach(() => { 156 | result = AccountsWrapper.factory(); 157 | }); 158 | 159 | it('should return an instance of AccountsWrapper', () => { 160 | expect(result instanceof AccountsWrapper.default).to.be.equal(true); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /test/client/index.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'ultimate-chai'; 2 | import sinon from 'sinon'; 3 | import { DDP } from 'meteor/ddp-client'; 4 | import { Accounts, AccountsClient } from 'meteor/accounts-base'; 5 | import * as RememberMe from './../../client/index'; 6 | 7 | describe('Given RememberMe', () => { 8 | const sandbox = sinon.createSandbox(); 9 | const sampleCorrectParams = { 10 | selector: 'test-user', 11 | password: 'test-password', 12 | flag: true, 13 | callback: sandbox.stub() 14 | }; 15 | let rememberMe; 16 | 17 | beforeEach(() => { 18 | rememberMe = RememberMe.factory(); 19 | }); 20 | 21 | afterEach(() => { 22 | sandbox.restore(); 23 | }); 24 | 25 | describe('When using loginWithPassword method with sample parameters', () => { 26 | let loginWithPasswordStub; 27 | 28 | beforeEach(() => { 29 | loginWithPasswordStub = sandbox.stub(Accounts, 'loginWithPassword'); 30 | rememberMe.loginWithPassword(...Object.values(sampleCorrectParams)); 31 | }); 32 | 33 | it('should call loginWithPassword with correct arguments', () => { 34 | const { selector, password } = sampleCorrectParams; 35 | expect(loginWithPasswordStub).to.be.calledWith(selector, password); 36 | }); 37 | }); 38 | 39 | describe('When switching accounts system', () => { 40 | let connection, accountsClient, loginWithPasswordStub; 41 | 42 | beforeEach(() => { 43 | connection = DDP.connect('localhost:3000'); 44 | accountsClient = new AccountsClient({ connection }); 45 | rememberMe.changeAccountsSystem(accountsClient); 46 | loginWithPasswordStub = sandbox.stub(accountsClient, 'loginWithPassword'); 47 | }); 48 | 49 | describe('And invoking loginWithPassword method', () => { 50 | beforeEach(() => { 51 | rememberMe.loginWithPassword(...Object.values(sampleCorrectParams)); 52 | }); 53 | 54 | it('should call loginWithPassword from the new instance', () => { 55 | expect(loginWithPasswordStub).to.be.calledOnce(); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('When not providing a correct AccountsClient instance to the "changeAccountsSystem"', () => { 61 | let error; 62 | 63 | beforeEach(() => { 64 | try { 65 | rememberMe.changeAccountsSystem(RememberMe.factory()); 66 | } catch (e) { 67 | console.error(e); 68 | error = e; 69 | } 70 | }); 71 | 72 | it('should throw an exception', () => { 73 | expect(error instanceof Meteor.Error).to.be.true(); 74 | }); 75 | }); 76 | 77 | describe('And loginWithPassword method parameters', () => { 78 | let callStub, parameters; 79 | 80 | beforeEach(() => { 81 | parameters = { 82 | selector: 'test-user', 83 | password: 'test-password' 84 | }; 85 | callStub = sandbox.stub(Accounts.connection, 'call') 86 | .callsFake((methodName, parameter, callback) => { 87 | callback(); 88 | }); 89 | sandbox.stub(Accounts, 'loginWithPassword') 90 | .callsFake((selector, password, callback) => { 91 | callback(); 92 | }); 93 | }); 94 | 95 | describe('When parameters contains callback', () => { 96 | beforeEach(() => { 97 | Object.assign(parameters, { callback: () => {} }); 98 | }); 99 | 100 | describe('And the method is invoked with rememberMe parameter being true', () => { 101 | beforeEach(() => { 102 | Object.assign(parameters, { flag: true }); 103 | rememberMe.loginWithPassword(...Object.values(parameters)); 104 | }); 105 | 106 | it('should send RememberMe true request to the server', () => { 107 | expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); 108 | }); 109 | }); 110 | 111 | describe('And the method is invoked with rememberMe parameter being false', () => { 112 | beforeEach(() => { 113 | Object.assign(parameters, { flag: false }); 114 | rememberMe.loginWithPassword(...Object.values(parameters)); 115 | }); 116 | 117 | it('should send RememberMe false request to the server', () => { 118 | expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', false); 119 | }); 120 | }); 121 | 122 | describe('And the method is invoked without rememberMe parameter', () => { 123 | beforeEach(() => { 124 | rememberMe.loginWithPassword(...Object.values(parameters)); 125 | }); 126 | 127 | it('should send RememberMe true request to the server', () => { 128 | expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); 129 | }); 130 | }); 131 | }); 132 | 133 | describe('When parameters do not contain a callback', () => { 134 | describe('And the method is invoked with rememberMe parameter being true', () => { 135 | beforeEach(() => { 136 | Object.assign(parameters, { flag: true }); 137 | rememberMe.loginWithPassword(...Object.values(parameters)); 138 | }); 139 | 140 | it('should send RememberMe true request to the server', () => { 141 | expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); 142 | }); 143 | }); 144 | 145 | describe('And the method is invoked with rememberMe parameter being false', () => { 146 | beforeEach(() => { 147 | Object.assign(parameters, { flag: false }); 148 | rememberMe.loginWithPassword(...Object.values(parameters)); 149 | }); 150 | 151 | it('should send RememberMe true request to the server', () => { 152 | expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', false); 153 | }); 154 | }); 155 | 156 | describe('And the method is invoked without rememberMe parameter', () => { 157 | beforeEach(() => { 158 | rememberMe.loginWithPassword(...Object.values(parameters)); 159 | }); 160 | 161 | it('should send RememberMe true request to the server', () => { 162 | expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); 163 | }); 164 | }); 165 | }); 166 | }); 167 | }); 168 | 169 | describe('Given RememberMe factory', () => { 170 | it('should be a function', () => { 171 | expect(typeof RememberMe.factory).to.be.equal('function'); 172 | }); 173 | }); 174 | --------------------------------------------------------------------------------