├── .dockerignore ├── .gitignore ├── .npmrc ├── .nycrc ├── .travis.yml ├── Dockerfile ├── Dockerfile.prod ├── Jenkinsfile ├── LICENSE ├── Logo.png ├── README.md ├── apiary.apib ├── build.sh ├── codeship-services.yml ├── codeship-steps.yml ├── deployment.env.encrypted ├── docker-compose.prod.yml ├── docker-compose.yml ├── docs └── index.html ├── package.json ├── src ├── bin │ └── www.ts ├── config.ts ├── controllers │ ├── methods.ts │ ├── specs │ │ ├── methods.spec.ts │ │ └── verifiers.spec.ts │ └── verifiers.ts ├── exceptions │ └── exceptions.ts ├── helpers │ ├── responses.ts │ └── specs │ │ └── responses.spec.ts ├── index.d.ts ├── ioc.container.ts ├── middlewares │ ├── common.ts │ ├── requests.ts │ └── specs │ │ ├── common.spec.ts │ │ └── requests.spec.ts ├── server.ts └── services │ ├── auth.ts │ ├── authenticator.verification.ts │ ├── base.verification.ts │ ├── email.verification.ts │ ├── helpers.ts │ ├── providers │ ├── email.ts │ ├── index.ts │ └── specs │ │ └── email.spec.ts │ ├── specs │ ├── auth.spec.ts │ ├── authenticator.spec.ts │ ├── storages.spec.ts │ └── verifications.spec.ts │ ├── storages.ts │ └── verifications.ts ├── tsconfig.build.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | .nyc_output 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .nyc_output 5 | dist 6 | ts-node 7 | .idea 8 | .vscode 9 | 10 | codeship.aes 11 | deployment.env 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "lines": 80, 3 | "statements": 80, 4 | "functions": 80, 5 | "branches": 78, 6 | "include": [ 7 | "**/*.ts" 8 | ], 9 | "exclude": [ 10 | "**/*.spec.ts", 11 | "coverage", 12 | "*.ts", 13 | "src/*.ts" 14 | ], 15 | "reporter": [ 16 | "html", 17 | "text", 18 | "text-summary" 19 | ], 20 | "require": [ 21 | "ts-node/register" 22 | ], 23 | "extension": [ 24 | ".ts" 25 | ], 26 | "cache": false, 27 | "all": false, 28 | "check-coverage": true 29 | } 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | services: 3 | - docker 4 | before_install: 5 | - docker login -u $DOCKER_USER -p $DOCKER_PASS 6 | - chmod ugo+x ./build.sh 7 | script: 8 | - docker-compose build verify 9 | - docker-compose run --rm verify sh -c "yarn && yarn test" 10 | - export TAG=`if [ "$TRAVIS_BRANCH" == "master" ]; then echo "production"; elif ["$TRAVIS_BRANCH" 11 | == "develop"]; then echo "stage"; else echo "dev-$(git rev-parse --short HEAD)" 12 | ; fi` 13 | - "./build.sh $TAG" 14 | env: 15 | global: 16 | - secure: iav2owjHUd8+tPlyX+mzHmVayZJoGilep3A8/EvJIvb1t6Do39tQU4lSqd0LhJxg3mQh6kV2oBk0OZO8LInSD/XrGn1afuqWdPCNd02GbdqyBqZ5NiazpzL9mpiL8tAMQsfBtSe5sAkg47e9URzoUpXLNhg/6VD3iPqwBGOJ1eLvxiziotVeKlB+RweEUUCb6x/Mo4iEY0BRI+ciYW1pNXtKwHGYg35lxbG5vjJVl4p9LN0+J6D5gc9COHQNSqZDA3Y6CY3/Nxr6F/pHqgl5VycRXaGg4PcmnnB7RfaSgp/10x6pN1VB2pUcIj6d9JmdNKUPswX/E6b068IIuCHpuVw5BKKD/qGzLZufR+dLViFQjkZb9ZNXNbWpbyEH7JGewc7hkOPFDC9TW5Mn64SLRzs9IMzN2eEL3pkW0Tg36i1N/NTevZnxIsRGbRyLBYn4VItVe5eHQOheBTPmJ+Txb82cdPLhSv0WjvQtp25tAy34tfQVAoZ8Tiq07oTTFM41UTJhVLQCKp7bEO0MNUQzfXjpPZWyFsnyhn9ZBy9MJum4Ud41tmvedYVzDjzAKnnNXJlMpPdwb3nj2czYly2Man9wRbkSLKa7dnv08v2Y5I+UuVUZ7sgtjKH9OSghhvyjNvtR70YX6J6nBNPGnOZwNAuCM7Rq0JYuoRzh8fkWtUY= 17 | - secure: gtld4jpjYEo+gfOOtwp89zTFPfHx6A42+NGAB+RyABgZqDdqqyBKSo/fjsIgy4etSiZ6yZhoqrAjzon1oe6YJsHInDpnuPzpSdnpUg6YsJFusHf+mPEKg/dPVS4dPA/bjPYcCgK7Cys+YUKeMKjUqyufv+duufjL89TOdpTMvhqnepHc9N/R75AX7kKR4f1kGR1ce3m48QAidgq/m5j4u7Nkl+5qvmtyOd9SULyiGFxvbckBGzhXRpmlIXakqZa90ui3pirfeJHq5S2s6x9hrV6i3MYWxsRmeoEOpCT40W/wEZwVvMo3zTAVmZLsl9QvMhdtjwYKBgRC0dd3gFLYV8szGsxU9qDnOaCPleawE3SZ/+4WvHPxpN/bzQdRDFJLexhMhydmZnEWg2DpFyow6Oz4f/ViPqH3qySkwEUX5EBmX1pTk5nFpdtzfTN3FCUb3sYSlEbBopsU8KqA5hRnflB5b1JwoN4vg/pUBt/8f2Fi8lbxtkB9NbHmQvSufVAnluCOFlC5AkzHQZP3xSL3+7vIYVPHKGru6qqwa2yZXvJdLNspZO+WN6NIRK14NpaptPUmmo+fGpjUdyKXo5a/WH/NAGGcUPT1N0fVOqwbE2+K8PJVwNN+GoCv1KUS/U6C3AQlb0vbv8wqAL0Ud9pzkD9PRs7O+Fpmv/Hmxfyz86o= 18 | notifications: 19 | slack: 20 | secure: XkpxurHZJxQuhwnAnQvhWEIIOJGLywHQgW+DLjNCxwo4nM82IbsTY4a6JicsO0jkzdkxX1yk5W4pB7BbBeiuwLC72gRZGmD2AY1EZThsPsVtv6aMi6VXgYyiAgAv2gV8Dv7P8MsHnSiqkwuIwHnveu3QA2yTjnsDeyfsMWRSdH1C4kb6EpsxMV2LE0KxEtM88Vney2Qm82LhSyB0x9c2ExVsQOOtseKMaeA2KXgm0b2wJLmYgtapk8eZszC9fao4ANkCfVvipt78C4eJvska/0KGem36k7ZKZm+Qgk8SUWU1YHALOznsCzuVKErwuaurDxgFcImxQgGdQ2+ygHQPqdh16i0kmEu/TPb1IxKSf8a2NBUfoSutneDmrJAD9peEFnXvAA2D8RICtLMkxprXgPgncEwRlPdfpMI/F7xv/+1u2xWjUrSME2IjHFUACLbdnrKH6VSnCrXZmKTLC0+nsujAEPELqSWfJ3xzNReKf6XqCADVSm4rwvbaV9E4YlFNiPKnQTpV3ELe0hEcouI4kKAYWR59g6QLjYacVvosK2Gv0q8c9jo67X1Tf3E4gOiN7Ai3qjKSwlE3QXaQzlHPEzzje0ImdT39F2dCTdydTgh6PX23B6IIfHwZE1PGn8oHwtpZ/kZ9K739W7TwAkbW6Bv94uXrGTOlQGn0iMBZIAY= 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:8.9.1 2 | 3 | VOLUME /usr/src/app 4 | WORKDIR /usr/src/app 5 | EXPOSE 3000 6 | EXPOSE 4000 7 | -------------------------------------------------------------------------------- /Dockerfile.prod: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:8.9.1 2 | 3 | RUN apk update && apk add bash && rm -rf /var/cache/apk/* 4 | 5 | RUN mkdir -p /usr/src/app 6 | ADD . /usr/src/app 7 | 8 | WORKDIR /usr/src/app 9 | 10 | RUN yarn 11 | RUN mkdir ./dist 12 | RUN npm run build 13 | 14 | CMD npm run serve 15 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | stages { 4 | stage('Build') { 5 | steps { 6 | sh 'docker-compose build -f docker-compose.prod.yml' 7 | } 8 | } 9 | stage('Deploy') { 10 | steps { 11 | sh 'docker-compose push -f docker-compose.prod.yml' 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jincor 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 | -------------------------------------------------------------------------------- /Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/secret-tech/backend-verify/8be9c7b7cf771e86f78cc79b81dc5a48c27f2973/Logo.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jincor Verification Service 2 | ![](https://travis-ci.org/JincorTech/backend-verify.svg?branch=master) 3 | ![](https://habrastorage.org/webt/59/d5/42/59d542206afbe280817420.png) 4 | 5 | # Jincor VERIFY Service 6 | 7 | Jincor Verification is a service for verify users email, phone, and etc. 8 | The main responsibilities are: 9 | 1. Interact with a service provider 10 | 1. Validation of a received code 11 | 12 | Take a look at our [Wiki](../../wiki) for more details. 13 | 14 | ## API Endpoints Summary 15 | For more details see the [API docs](https://jincortech.github.io/backend-verify/) 16 | 17 | JWT_TOKEN should be passed for every API call in the HTTP headers, 18 | that was received from auth service. 19 | 20 | 1. `/methods/{METHOD}/actions/initiate [POST]` 21 | 1. `/methods/{METHOD}/verifiers/{VERIFICATION_ID}/actions/validate [POST]` 22 | 1. `/methods/{METHOD}/verifiers/{VERIFICATION_ID} [DELETE]` 23 | 24 | ## How to start development and run tests? 25 | 26 | 1. Clone this repo. 27 | 1. Run `docker-compose build --no-cache`. 28 | 1. Run `docker-compose up -d`. 29 | 1. To install dependencies run: `docker-compose exec verify yarn`. 30 | 1. To run tests run: `docker-compose exec verify yarn test`. 31 | 1. To build production image run `docker-compose -f docker-compose.prod.yml build --no-cache`. 32 | 33 | ## How to generate docs? 34 | 1. Install `npm install -g aglio`. 35 | 1. Run `mkdir /usr/local/lib/node_modules/aglio/node_modules/aglio-theme-olio/cache`. 36 | 1. Generate `aglio --theme-variables cyborg --theme-template triple -i apiary.apib -o ./docs/index.html`. -------------------------------------------------------------------------------- /apiary.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: http://verify:3000/ 3 | 4 | # Jincor VERIFY Service 5 | 6 | Jincor Verification is a service for verify users email, phone, and etc. 7 | The main responsibilities are: 8 | 1. Interact with a service provider 9 | 1. Validation of a received code 10 | 11 | # API Endpoints Summary 12 | 13 | JWT_TOKEN should be passed for every API call in the HTTP headers, 14 | that was received from auth service. 15 | 16 | 1. `/methods/{METHOD}/actions/initiate [POST]` 17 | 1. `/methods/{METHOD}/verifiers/{VERIFICATION_ID}/actions/validate [POST]` 18 | 1. `/methods/{METHOD}/verifiers/{VERIFICATION_ID} [DELETE]` 19 | 20 | # Notes about google_auth 21 | 1. When you initiate google_auth verification 1st time for selected consumer you will get `totpUri` to show QR code in your frontend app. 22 | 2. The received secret will be **TEMPORARY** until you verify it first time. If secret is not verified at least 1 time and you `initiate` new google_auth verification for consumer - you will get **NEW** secret. It's required to ensure that user successfully stored the secret and entered correct code. 23 | 3. When you want to disable 2FA - initiate verification and send `removeSecret=true` param to `validate` endpoint to remove consumer's secret. 24 | 25 | ## Verification: Initiate [/methods/{METHOD}/actions/initiate] 26 | 27 | Initiate verification process, with usage of specified *METHOD*. 28 | 29 | + Parameters 30 | + METHOD (string) - One of *email*, *google_auth*, *phone* (not implemented). 31 | 32 | ### Initiate verification [POST] 33 | 34 | Example: email verification `/methods/email/actions/initiate`. 35 | Pass uuid in `policy.forcedVerificationId` to force using of your verification generated id. 36 | Set up own code in `policy.forcedCode` to force using of your verification code (does not apply for google_auth method). 37 | 38 | + Request (application/json) 39 | + Headers 40 | 41 | Authorization: Bearer {JWT_TOKEN} 42 | Accept: application/vnd.jincor+json; version=1 43 | 44 | + Body 45 | 46 | { 47 | "consumer": "test@test.com", 48 | "issuer": "Jincor", 49 | "template": { 50 | "body": "Click on the Verify Link to continue registration." 51 | }, 52 | "generateCode": { 53 | "length": 32, 54 | "symbolSet": ["DIGITS", "alphas", "ALPHAS"] 55 | }, 56 | "policy": { 57 | "expiredOn": "01:00:00" 58 | }, 59 | "payload": { 60 | "your": "custom payload" 61 | } 62 | } 63 | 64 | + Response 200 (application/json) 65 | 66 | { 67 | "status": 200, 68 | "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f", 69 | "attempts": 0, 70 | "expiredOn": 1505817462, 71 | "payload": { 72 | "your": "custom payload" 73 | } 74 | } 75 | 76 | + Response 200 (application/json) 77 | 78 | { 79 | "verificationId": "5028c0cd-07a9-4fa7-8d88-49edd2a44b72", 80 | "consumer": "test@test.com", 81 | "expiredOn": 1508689744, 82 | "totpUri": "otpauth://totp/:test@test.com?secret=CK53DOA3R7B2ZDMZKVOM53ZPT355ORJI&issuer=&algorithm=SHA1&digits=6&period=30", 83 | "status": 200 84 | } 85 | 86 | + Response 404 (application/json) 87 | 88 | { 89 | "status": 404, 90 | "error": "Method not supported" 91 | } 92 | 93 | + Response 422 (application/json) 94 | 95 | { 96 | "status": 422, 97 | "error": "Invalid request", 98 | "details": [ 99 | {"path": "generateCode.length", "error": "Incorrect number format"} 100 | ] 101 | } 102 | 103 | 104 | ## Verification: Validate [/methods/{METHOD}/verifiers/{VERIFICATION_ID}/actions/validate] 105 | 106 | + Parameters 107 | + METHOD (string) - One of *phone*, *email*, *google_auth*. 108 | + VERIFICATION_ID (string) 109 | 110 | ### Validate the code [POST] 111 | 112 | Example: code validation for the email 113 | method `/methods/email/verifiers/dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f/actions/validate`. 114 | 115 | + code `1234qwertA` (required) 116 | + removeSecret `true` (optional - use it to remove secret when you want to disable 2FA for consumer) 117 | 118 | + Request (application/json) 119 | 120 | + Headers 121 | 122 | Authorization: Bearer {JWT_TOKEN} 123 | Accept: application/vnd.jincor+json; version=1 124 | 125 | + Body 126 | 127 | { 128 | "code": "JeDknKO0EZRBT6aFPrFQhzcCA2aqyVsHzZeJ8Vf", 129 | "removeSecret": true 130 | } 131 | 132 | + Response 200 (application/json) 133 | 134 | { 135 | "status": 200, 136 | "data": { 137 | "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f", 138 | "consumer": "test@test.com", 139 | "expiredOn": 1505817462, 140 | "payload": { 141 | "your": "custom payload" 142 | }, 143 | "attempts": 0 144 | } 145 | } 146 | 147 | + Response 404 (application/json) 148 | 149 | { 150 | "status": 404, 151 | "error": "Not found" 152 | } 153 | 154 | + Response 422 (application/json) 155 | 156 | { 157 | "status": 422, 158 | "error": "Invalid code", 159 | "data": { 160 | "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f", 161 | "consumer": "test@test.com", 162 | "expiredOn": 1505817462, 163 | "payload": { 164 | "your": "custom payload" 165 | }, 166 | "attempts": 1 167 | } 168 | } 169 | 170 | 171 | ## Verifications [/methods/{METHOD}/verifiers/{VERIFICATION_ID}] 172 | 173 | + Parameters 174 | + METHOD (string) - One of *phone*, *email*, *google_auth*. 175 | + VERIFICATION_ID (string) 176 | 177 | ### Get verification [GET] 178 | 179 | + Request (application/json) 180 | 181 | + Headers 182 | 183 | Authorization: Bearer {JWT_TOKEN} 184 | Accept: application/vnd.jincor+json; version=1 185 | 186 | + Response 200 (application/json) 187 | 188 | { 189 | "status": 200, 190 | "data": { 191 | "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f", 192 | "consumer": "test@test.com", 193 | "expiredOn": 1505817462, 194 | "payload": { 195 | "your": "custom payload" 196 | }, 197 | "attempts": 1 198 | } 199 | } 200 | 201 | + Response 404 (application/json) 202 | 203 | { 204 | "status": 404, 205 | "error": "Not found" 206 | } 207 | 208 | ### Invalidate the code [DELETE] 209 | 210 | + Request (application/json) 211 | 212 | + Headers 213 | 214 | Authorization: Bearer {JWT_TOKEN} 215 | Accept: application/vnd.jincor+json; version=1 216 | 217 | + Response 200 (application/json) 218 | 219 | { 220 | "status": 200 221 | } 222 | 223 | + Response 404 (application/json) 224 | 225 | { 226 | "status": 404, 227 | "error": "Not found" 228 | } 229 | 230 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | IMAGE_NAME="jincort/backend-verify" 5 | TAG="${1}" 6 | docker build -t ${IMAGE_NAME}:${TAG} -f Dockerfile.prod . 7 | docker push ${IMAGE_NAME}:${TAG} 8 | -------------------------------------------------------------------------------- /codeship-services.yml: -------------------------------------------------------------------------------- 1 | verify: 2 | build: 3 | image: registry.heroku.com/backend-verify/web 4 | context: ./ 5 | dockerfile: Dockerfile.prod 6 | environment: 7 | REDIS_URL: 'redis://redis:6379' 8 | FORCE_HTTPS: disabled 9 | MAIL_DRIVER: dummy 10 | 11 | redis: 12 | image: registry.jincor.com/backend/redis:latest 13 | ports: 14 | - "6379" 15 | 16 | 17 | herokudeployment: 18 | image: codeship/heroku-deployment 19 | encrypted_env_file: deployment.env.encrypted 20 | volumes: 21 | - ./:/deploy 22 | 23 | dockercfg_generator: 24 | image: codeship/heroku-dockercfg-generator 25 | add_docker: true 26 | encrypted_env_file: deployment.env.encrypted 27 | -------------------------------------------------------------------------------- /codeship-steps.yml: -------------------------------------------------------------------------------- 1 | - name: test 2 | service: verify 3 | command: yarn test 4 | - name: deploy 5 | service: verify 6 | tag: master 7 | type: push 8 | image_name: registry.heroku.com/backend-verify/web 9 | registry: registry.heroku.com 10 | dockercfg_service: dockercfg_generator 11 | -------------------------------------------------------------------------------- /deployment.env.encrypted: -------------------------------------------------------------------------------- 1 | 54JVI0A5PJTMo7/g/hzIy75K8E8wO5MycxAlnfkGvrOVmkgZi0MthmNLVcfptb+i7oyfsLs8Sz5mWXZkeKoTs47avDU= 2 | 3 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | 4 | verify: 5 | image: registry.jincor.com/backend/verify:latest 6 | build: 7 | context: ./ 8 | dockerfile: Dockerfile.prod 9 | environment: 10 | REDIS_URL: 'redis://redis:6379' 11 | FORCE_HTTPS: disabled 12 | AUTH_API_URL: ${AUTH_API_URL} 13 | MAIL_DRIVER: ${MAIL_DRIVER} 14 | MAILGUN_SECRET: ${MAILGUN_SECRET} 15 | MAILGUN_DOMAIN: ${MAILGUN_DOMAIN} 16 | ports: 17 | - "3000" 18 | - "4000" 19 | links: 20 | - redis 21 | 22 | redis: 23 | image: registry.jincor.com/backend/redis:latest 24 | ports: 25 | - "6379" 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | 4 | verify: 5 | image: registry.jincor.com/backend/verify-develop:latest 6 | build: 7 | context: ./ 8 | dockerfile: Dockerfile 9 | environment: 10 | REDIS_URL: 'redis://redis:6379' 11 | FORCE_HTTPS: disabled 12 | MAIL_DRIVER: dummy 13 | tty: true 14 | ports: 15 | - "3000" 16 | - "4000" 17 | volumes: 18 | - ./:/usr/src/app 19 | links: 20 | - redis 21 | 22 | redis: 23 | image: jincort/backend-redis:production 24 | ports: 25 | - "6379" 26 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Jincor VERIFY Service

Jincor VERIFY Service

Jincor Verification is a service for verify users email, phone, and etc. 2 | The main responsibilities are:

3 |
    4 |
  1. Interact with a service provider
  2. 5 |
  3. Validation of a received code
  4. 6 |
7 |

API Endpoints Summary

8 |

JWT_TOKEN should be passed for every API call in the HTTP headers, 9 | that was received from auth service.

10 |
    11 |
  1. 12 |

    /methods/{METHOD}/actions/initiate [POST]

    13 |
  2. 14 |
  3. 15 |

    /methods/{METHOD}/verifiers/{VERIFICATION_ID}/actions/validate [POST]

    16 |
  4. 17 |
  5. 18 |

    /methods/{METHOD}/verifiers/{VERIFICATION_ID} [DELETE]

    19 |
  6. 20 |
21 |

Notes about google_auth

22 |
    23 |
  1. 24 |

    When you initiate google_auth verification 1st time for selected consumer you will get totpUri to show QR code in your frontend app.

    25 |
  2. 26 |
  3. 27 |

    The received secret will be TEMPORARY until you verify it first time. If secret is not verified at least 1 time and you initiate new google_auth verification for consumer - you will get NEW secret. It’s required to ensure that user successfully stored the secret and entered correct code.

    28 |
  4. 29 |
  5. 30 |

    When you want to disable 2FA - initiate verification and send removeSecret=true param to validate endpoint to remove consumer’s secret.

    31 |
  6. 32 |
33 |

Resource Group

Verification: Initiate

Initiate verification process, with usage of specified METHOD.

34 |
POST http://verify:3000//methods/METHOD/actions/initiate
Requestsexample 1
Headers
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
Accept: application/vnd.jincor+json; version=1
Body
{
 35 |   "consumer": "test@test.com",
 36 |   "issuer": "Jincor",
 37 |   "template": {
 38 |     "body": "Click on the <a href=\"https://service/verify-email/5RkvAr0PUe708a?code={{{CODE}}}&verificationId={{{VERIFICATION_ID}}}\">Verify Link</a> to continue registration."
 39 |   },
 40 |   "generateCode": {
 41 |     "length": 32,
 42 |     "symbolSet": [
 43 |       "DIGITS",
 44 |       "alphas",
 45 |       "ALPHAS"
 46 |     ]
 47 |   },
 48 |   "policy": {
 49 |     "expiredOn": "01:00:00"
 50 |   },
 51 |   "payload": {
 52 |     "your": "custom payload"
 53 |   }
 54 | }
Responses200200404422
Headers
Content-Type: application/json
Body
{
 55 |   "status": 200,
 56 |   "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f",
 57 |   "attempts": 0,
 58 |   "expiredOn": 1505817462,
 59 |   "payload": {
 60 |     "your": "custom payload"
 61 |   }
 62 | }
Headers
Content-Type: application/json
Body
{
 63 |   "verificationId": "5028c0cd-07a9-4fa7-8d88-49edd2a44b72",
 64 |   "consumer": "test@test.com",
 65 |   "expiredOn": 1508689744,
 66 |   "totpUri": "otpauth://totp/:test@test.com?secret=CK53DOA3R7B2ZDMZKVOM53ZPT355ORJI&issuer=&algorithm=SHA1&digits=6&period=30",
 67 |   "status": 200
 68 | }
Headers
Content-Type: application/json
Body
{
 69 |   "status": 404,
 70 |   "error": "Method not supported"
 71 | }
Headers
Content-Type: application/json
Body
{
 72 |   "status": 422,
 73 |   "error": "Invalid request",
 74 |   "details": [
 75 |     {
 76 |       "path": "generateCode.length",
 77 |       "error": "Incorrect number format"
 78 |     }
 79 |   ]
 80 | }

Initiate verification
POST/methods/{METHOD}/actions/initiate

Example: email verification /methods/email/actions/initiate. 81 | Pass uuid in policy.forcedVerificationId to force using of your verification generated id. 82 | Set up own code in policy.forcedCode to force using of your verification code (does not apply for google_auth method).

83 |
URI Parameters
HideShow
METHOD
string (required) 

One of email, google_auth, phone (not implemented).

84 |

Verification: Validate

POST http://verify:3000//methods/METHOD/verifiers/VERIFICATION_ID/actions/validate
Requestsexample 1
Headers
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
Accept: application/vnd.jincor+json; version=1
Body
{
 85 |   "code": "JeDknKO0EZRBT6aFPrFQhzcCA2aqyVsHzZeJ8Vf",
 86 |   "removeSecret": true
 87 | }
Responses200404422
Headers
Content-Type: application/json
Body
{
 88 |   "status": 200,
 89 |   "data": {
 90 |     "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f",
 91 |     "consumer": "test@test.com",
 92 |     "expiredOn": 1505817462,
 93 |     "payload": {
 94 |       "your": "custom payload"
 95 |     },
 96 |     "attempts": 0
 97 |   }
 98 | }
Headers
Content-Type: application/json
Body
{
 99 |   "status": 404,
100 |   "error": "Not found"
101 | }
Headers
Content-Type: application/json
Body
{
102 |   "status": 422,
103 |   "error": "Invalid code",
104 |   "data": {
105 |     "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f",
106 |     "consumer": "test@test.com",
107 |     "expiredOn": 1505817462,
108 |     "payload": {
109 |       "your": "custom payload"
110 |     },
111 |     "attempts": 1
112 |   }
113 | }

Validate the code
POST/methods/{METHOD}/verifiers/{VERIFICATION_ID}/actions/validate

Example: code validation for the email 114 | method /methods/email/verifiers/dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f/actions/validate.

115 |
    116 |
  • 117 |

    code 1234qwertA (required)

    118 |
  • 119 |
  • 120 |

    removeSecret true (optional - use it to remove secret when you want to disable 2FA for consumer)

    121 |
  • 122 |
123 |
URI Parameters
HideShow
METHOD
string (required) 

One of phone, email, google_auth.

124 |
VERIFICATION_ID
string (required) 

Verifications

GET http://verify:3000//methods/METHOD/verifiers/VERIFICATION_ID
Requestsexample 1
Headers
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
Accept: application/vnd.jincor+json; version=1
Responses200404
Headers
Content-Type: application/json
Body
{
125 |   "status": 200,
126 |   "data": {
127 |     "verificationId": "dc910ae0-7c67-4ace-8ebb-9edd4b5d8b0f",
128 |     "consumer": "test@test.com",
129 |     "expiredOn": 1505817462,
130 |     "payload": {
131 |       "your": "custom payload"
132 |     },
133 |     "attempts": 1
134 |   }
135 | }
Headers
Content-Type: application/json
Body
{
136 |   "status": 404,
137 |   "error": "Not found"
138 | }

Get verification
GET/methods/{METHOD}/verifiers/{VERIFICATION_ID}

URI Parameters
HideShow
METHOD
string (required) 

One of phone, email, google_auth.

139 |
VERIFICATION_ID
string (required) 

DELETE http://verify:3000//methods/METHOD/verifiers/VERIFICATION_ID
Requestsexample 1
Headers
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
Accept: application/vnd.jincor+json; version=1
Responses200404
Headers
Content-Type: application/json
Body
{
140 |   "status": 200
141 | }
Headers
Content-Type: application/json
Body
{
142 |   "status": 404,
143 |   "error": "Not found"
144 | }

Invalidate the code
DELETE/methods/{METHOD}/verifiers/{VERIFICATION_ID}

URI Parameters
HideShow
METHOD
string (required) 

One of phone, email, google_auth.

145 |
VERIFICATION_ID
string (required) 

Generated by aglio on 28 Nov 2017

-------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "verify", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "nodemon -w ./src -e ts ./src/bin/www --exec ts-node", 7 | "lint": "tslint './src/**/*.ts'", 8 | "lintFix": "tslint --fix './src/**/*.ts'", 9 | "test": "nyc mocha ./src/**/*.spec.ts", 10 | "build": "tsc -p tsconfig.build.json --outDir dist", 11 | "serve": "node ./dist/bin/www.js" 12 | }, 13 | "nyc": { 14 | "exclude": [ 15 | "src/**/*.spec.ts" 16 | ] 17 | }, 18 | "dependencies": { 19 | "@types/joi": "10.4.0", 20 | "authenticator": "1.1.3", 21 | "bcrypt-nodejs": "0.0.3", 22 | "body-parser": "~1.15.2", 23 | "debug": "~2.2.0", 24 | "express": "~4.14.0", 25 | "inversify": "4.2.0", 26 | "inversify-express-utils": "4.0.0", 27 | "joi": "10.6.0", 28 | "js-yaml": "^3.10.0", 29 | "jsonwebtoken": "^7.2.1", 30 | "lru-cache": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz", 31 | "mailcomposer": "4.0.2", 32 | "mailgun-js": "0.13.1", 33 | "moment": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz", 34 | "node-mailjet": "^3.2.1", 35 | "node-uuid": "^1.4.7", 36 | "redis": "2.6.3", 37 | "reflect-metadata": "0.1.10", 38 | "request": "2.81.0", 39 | "rolling-rate-limiter": "0.1.9", 40 | "typemoq": "^2.0.1" 41 | }, 42 | "devDependencies": { 43 | "@types/bcrypt-nodejs": "0.0.30", 44 | "@types/chai": "https://registry.npmjs.org/@types/chai/-/chai-3.4.34.tgz", 45 | "@types/chai-as-promised": "7.1.0", 46 | "@types/chai-http": "0.0.29", 47 | "@types/debug": "0.0.29", 48 | "@types/express": "4.0.34", 49 | "@types/jsonwebtoken": "7.2.0", 50 | "@types/mocha": "2.2.38", 51 | "@types/node": "7.0.0", 52 | "@types/node-uuid": "0.0.28", 53 | "@types/redis": "0.12.34", 54 | "@types/request": "2.0.3", 55 | "@types/sinon": "2.3.4", 56 | "chai": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", 57 | "chai-as-promised": "7.1.1", 58 | "chai-http": "3.0.0", 59 | "mocha": "3.2.0", 60 | "nodemon": "^1.11.0", 61 | "npm-shrinkwrap": "^6.0.2", 62 | "nyc": "10.0.0", 63 | "sinon": "3.3.0", 64 | "ts-node": "2.0.0", 65 | "tslint": "5.7.0", 66 | "tslint-config-standard": "5.0.2", 67 | "typescript": "2.1.5" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/bin/www.ts: -------------------------------------------------------------------------------- 1 | import server from '../server'; 2 | import * as http from 'http'; 3 | import * as https from 'https'; 4 | import * as fs from 'fs'; 5 | import config from '../config'; 6 | 7 | /** 8 | * Create HTTP server. 9 | */ 10 | const httpServer = http.createServer(server); 11 | 12 | /** 13 | * Listen on provided port, on all network interfaces. 14 | */ 15 | httpServer.listen(config.server.port); 16 | 17 | if (config.server.httpsServer === 'enabled') { 18 | const httpsOptions = { 19 | key: fs.readFileSync(__dirname + '/server.key'), 20 | cert: fs.readFileSync(__dirname + '/auth.crt') 21 | }; 22 | const httpsServer = https.createServer(httpsOptions, server); 23 | httpsServer.listen(config.server.httpsPort); 24 | } 25 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const { 2 | REDIS_URL, 3 | PORT, 4 | HTTPS_PORT, 5 | HTTPS_SERVER, 6 | FORCE_HTTPS, 7 | AUTH_API_URL, 8 | AUTH_API_TIMEOUT 9 | } = process.env; 10 | 11 | export default { 12 | environment: { 13 | isTesting: process.env.LOADED_MOCHA_OPTS === 'true' 14 | }, 15 | server: { 16 | port: parseInt(PORT, 10) || 3000, 17 | httpsPort: parseInt(HTTPS_PORT, 10) || 4000, 18 | httpsServer: HTTPS_SERVER || 'disabled', 19 | forceHttps: FORCE_HTTPS || 'disabled' 20 | }, 21 | auth: { 22 | url: AUTH_API_URL || 'http://auth:3000/tenant/verify', 23 | timeout: parseInt(AUTH_API_TIMEOUT, 10) || 5000 24 | }, 25 | redis: { 26 | url: REDIS_URL || 'redis://redis:6379', 27 | prefix: 'jincor_verify_' 28 | }, 29 | providers: { 30 | email: { 31 | provider: process.env.MAIL_DRIVER || 'dummy' 32 | } 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/controllers/methods.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import { inject, injectable } from 'inversify'; 3 | import { controller, httpPost } from 'inversify-express-utils'; 4 | import 'reflect-metadata'; 5 | import { InvalidParametersException } from '../exceptions/exceptions'; 6 | import { responseWithError, responseAsUnbehaviorError } from '../helpers/responses'; 7 | import { AuthorizedRequest } from '../middlewares/common'; 8 | 9 | import { 10 | VerificationServiceFactory, 11 | VerificationServiceFactoryType 12 | } from '../services/verifications'; 13 | 14 | interface MethodRequest extends AuthorizedRequest { 15 | params: { 16 | method: string; 17 | }; 18 | } 19 | 20 | /** 21 | * MethodsController resource 22 | */ 23 | @injectable() 24 | @controller( 25 | '/methods', 26 | 'AuthMiddleware' 27 | ) 28 | export class MethodsController { 29 | constructor( @inject(VerificationServiceFactoryType) private verificationFactory: VerificationServiceFactory) { 30 | } 31 | 32 | /** 33 | * Initiate verification process for specified method. 34 | * 35 | * @param req express req object 36 | * @param res express res object 37 | */ 38 | @httpPost( 39 | '/:method/actions/initiate', 40 | 'SupportedMethodsMiddleware', 41 | 'InitiateVerificationValidation' 42 | ) 43 | async initiate(req: MethodRequest, res: Response): Promise { 44 | try { 45 | const verificationService = this.verificationFactory.create(req.params.method); 46 | const verificationDetails = await verificationService.initiate(req.body, req.tenant); 47 | res.json(Object.assign({}, verificationDetails, { status: 200 })); 48 | } catch (err) { 49 | if (err instanceof InvalidParametersException) { 50 | responseWithError(res, 422, { 51 | 'error': err.name, 52 | 'details': err.details 53 | }); 54 | } else { 55 | responseAsUnbehaviorError(res, err); 56 | } 57 | } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/controllers/specs/methods.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import app from '../../server'; 3 | 4 | chai.use(require('chai-http')); 5 | const {expect, request} = chai; 6 | 7 | function createRequest(path: string, data: any) { 8 | return request(app).post(path) 9 | .set('Accept', 'application/json') 10 | .set('Authorization', 'Bearer TOKEN').send(data); 11 | } 12 | 13 | describe('Test Methods controller', () => { 14 | 15 | it('will initiate a email verification process', (done) => { 16 | const forcedId = '395a0e7d-3a1f-4d51-8dad-7d0229bd64ac'; 17 | createRequest('/methods/email/actions/initiate', { 18 | consumer: 'test@test.com', 19 | template: { 20 | fromName: 'Sender Name', 21 | fromEmail: 'source@email.com', 22 | body: 'body' 23 | }, 24 | policy: { 25 | expiredOn: '00:01:00', 26 | forcedCode: '123456', 27 | forcedVerificationId: forcedId 28 | } 29 | }).end((err, res) => { 30 | expect(res.status).is.equals(200); 31 | expect(res.body.verificationId).is.equals(forcedId); 32 | expect(res.body.expiredOn).is.greaterThan(~~((+new Date()) / 1000)); 33 | done(); 34 | }); 35 | }); 36 | 37 | it('will initiate a email verification process and accept payload', (done) => { 38 | const forcedId = '395a0e7d-3a1f-4d51-8dad-7d0229bd64ac'; 39 | createRequest('/methods/email/actions/initiate', { 40 | consumer: 'test@test.com', 41 | template: { 42 | fromName: 'Sender Name', 43 | fromEmail: 'source@email.com', 44 | body: 'body' 45 | }, 46 | policy: { 47 | expiredOn: '00:01:00', 48 | forcedCode: '123456', 49 | forcedVerificationId: forcedId 50 | }, 51 | payload: { 52 | sub: { 53 | key: 'value' 54 | }, 55 | key: 'value' 56 | } 57 | }).end((err, res) => { 58 | expect(res.status).is.equals(200); 59 | expect(res.body.verificationId).is.equals(forcedId); 60 | expect(res.body.expiredOn).is.greaterThan(~~((+new Date()) / 1000)); 61 | expect(res.body.payload).to.deep.eq({ 62 | sub: { 63 | key: 'value' 64 | }, 65 | key: 'value' 66 | }); 67 | done(); 68 | }); 69 | }); 70 | 71 | it('will initiate an authenticator verification process', (done) => { 72 | const params = { 73 | consumer: 'test@test.com', 74 | issuer: 'Jincor', 75 | policy: { 76 | expiredOn: '00:01:00' 77 | }, 78 | payload: { 79 | sub: { 80 | key: 'value' 81 | }, 82 | key: 'value' 83 | } 84 | }; 85 | 86 | createRequest('/methods/google_auth/actions/initiate', params).end((err, res) => { 87 | expect(res.status).is.equals(200); 88 | expect(res.body).to.have.property('verificationId'); 89 | expect(res.body.expiredOn).is.greaterThan(~~((+new Date()) / 1000)); 90 | expect(res.body.totpUri).contains('otpauth://totp/Jincor:test@test.com?secret='); 91 | expect(res.body.payload).to.deep.eq({ 92 | sub: { 93 | key: 'value' 94 | }, 95 | key: 'value' 96 | }); 97 | done(); 98 | }); 99 | }); 100 | 101 | it('will initiate an authenticator verification process and accept payload', (done) => { 102 | const params = { 103 | consumer: 'test@test.com', 104 | issuer: 'Jincor', 105 | policy: { 106 | expiredOn: '00:01:00' 107 | } 108 | }; 109 | 110 | createRequest('/methods/google_auth/actions/initiate', params).end((err, res) => { 111 | expect(res.status).is.equals(200); 112 | expect(res.body).to.have.property('verificationId'); 113 | expect(res.body.expiredOn).is.greaterThan(~~((+new Date()) / 1000)); 114 | expect(res.body.totpUri).contains('otpauth://totp/Jincor:test@test.com?secret='); 115 | done(); 116 | }); 117 | }); 118 | 119 | it('will require issuer param for google_auth initiate', (done) => { 120 | const params = { 121 | consumer: 'test@test.com', 122 | policy: { 123 | expiredOn: '00:01:00' 124 | } 125 | }; 126 | 127 | createRequest('/methods/google_auth/actions/initiate', params).end((err, res) => { 128 | expect(res.status).is.equals(422); 129 | done(); 130 | }); 131 | }); 132 | 133 | it('will fail of initiation of email verification process if no template body presents', (done) => { 134 | const forcedId = '395a0e7d-3a1f-4d51-8dad-7d0229bd64ac'; 135 | createRequest('/methods/email/actions/initiate', { 136 | consumer: 'test@test.com', 137 | template: { 138 | }, 139 | policy: { 140 | expiredOn: '00:01:00', 141 | forcedCode: '123456', 142 | forcedVerificationId: forcedId 143 | } 144 | }).end((err, res) => { 145 | expect(res.status).is.equals(422); 146 | expect(res.body.error).is.equals('Invalid request'); 147 | done(); 148 | }); 149 | }); 150 | 151 | }); 152 | -------------------------------------------------------------------------------- /src/controllers/specs/verifiers.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import app from '../../server'; 3 | import * as express from 'express'; 4 | import * as TypeMoq from 'typemoq'; 5 | import { container } from '../../ioc.container'; 6 | import { InversifyExpressServer } from 'inversify-express-utils'; 7 | import * as bodyParser from 'body-parser'; 8 | import { VerificationServiceFactoryType, VerificationServiceFactory, VerificationServiceFactoryRegister } from '../../services/verifications'; 9 | import AuthenticatorVerificationService from '../../services/authenticator.verification'; 10 | import { SimpleInMemoryStorageService } from '../../services/storages'; 11 | import * as authenticator from 'authenticator'; 12 | 13 | chai.use(require('chai-http')); 14 | const { expect, request } = chai; 15 | 16 | function createRequest(path: string, data: any, method: string = 'post', customApp?: any) { 17 | const appToUse = customApp || app; 18 | 19 | return request(appToUse)[method](path) 20 | .set('Accept', 'application/json') 21 | .set('Authorization', 'Bearer TOKEN').send(data); 22 | } 23 | 24 | function createInitiateEmailVerification(verificationId: string, code: string) { 25 | return createRequest('/methods/email/actions/initiate', { 26 | consumer: 'test@test.com', 27 | template: { 28 | body: 'body' 29 | }, 30 | policy: { 31 | expiredOn: '00:01:00', 32 | forcedCode: code, 33 | forcedVerificationId: verificationId 34 | }, 35 | payload: { 36 | key: 'value' 37 | } 38 | }); 39 | } 40 | 41 | function mockStorageForGoogleAuth(secret, verificationId, verificationData) { 42 | const customApp = express(); 43 | 44 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 45 | storageMock.setup(x => x.get(TypeMoq.It.isValue(`test${ verificationId }`), TypeMoq.It.isAny())) 46 | .returns(async(): Promise => verificationData); 47 | 48 | storageMock.setup(x => x.get(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny())) 49 | .returns(async(): Promise => secret); 50 | 51 | const authenticatorService = new AuthenticatorVerificationService('test', storageMock.object); 52 | 53 | const factoryMock = TypeMoq.Mock.ofType(VerificationServiceFactoryRegister); 54 | 55 | factoryMock.setup(x => x.create(TypeMoq.It.isAny())) 56 | .returns(() => authenticatorService); 57 | 58 | container.rebind(VerificationServiceFactoryType).toConstantValue(factoryMock.object); 59 | 60 | customApp.use(bodyParser.json()); 61 | customApp.use(bodyParser.urlencoded({ extended: false })); 62 | 63 | const server = new InversifyExpressServer(container, null, null, customApp); 64 | return server.build(); 65 | } 66 | 67 | describe('Test Verifier controller', () => { 68 | 69 | const notExistsVerificationId = 'afde0e7d-3a1f-4d51-8dad-7d0229bd64c4'; 70 | const forcedId = '395a0e7d-3a1f-4d51-8dad-7d0229bd64ac'; 71 | const forcedCode = '12345678'; 72 | 73 | it('will successfully validate verificationId and code - email', (done) => { 74 | 75 | createInitiateEmailVerification(forcedId, forcedCode).end((err, res) => { 76 | expect(res.status).is.equals(200); 77 | expect(res.body.verificationId).is.equals(forcedId); 78 | 79 | createRequest(`/methods/email/verifiers/${forcedId}/actions/validate`, { 80 | code: forcedCode 81 | }).end((err, res) => { 82 | expect(res.status).is.equals(200); 83 | expect(res.body.data.verificationId).is.equals('395a0e7d-3a1f-4d51-8dad-7d0229bd64ac'); 84 | expect(res.body.data.consumer).is.equals('test@test.com'); 85 | expect(res.body.data.expiredOn).to.be.an('number'); 86 | expect(res.body.data.payload).to.deep.eq({ 87 | key: 'value' 88 | }); 89 | done(); 90 | }); 91 | }); 92 | 93 | }); 94 | 95 | it('will respond with 422 for incorrect code and increase attempts count - email', (done) => { 96 | 97 | createInitiateEmailVerification(forcedId, forcedCode).end((err, res) => { 98 | expect(res.status).is.equals(200); 99 | expect(res.body.verificationId).is.equals(forcedId); 100 | 101 | createRequest(`/methods/email/verifiers/${forcedId}/actions/validate`, { 102 | code: '123456' 103 | }).end((err, res) => { 104 | expect(res.status).is.equals(422); 105 | expect(res.body.data.verificationId).is.equals('395a0e7d-3a1f-4d51-8dad-7d0229bd64ac'); 106 | expect(res.body.data.consumer).is.equals('test@test.com'); 107 | expect(res.body.data.expiredOn).to.be.an('number'); 108 | expect(res.body.data.payload).to.deep.eq({ 109 | key: 'value' 110 | }); 111 | expect(res.body.data.attempts).to.eq(1); 112 | expect(res.body.data).to.not.have.property('code'); 113 | done(); 114 | }); 115 | }); 116 | 117 | }); 118 | 119 | it('will successfully get verification data - email', (done) => { 120 | 121 | createInitiateEmailVerification(forcedId, forcedCode).end((err, res) => { 122 | expect(res.status).is.equals(200); 123 | expect(res.body.verificationId).is.equals(forcedId); 124 | 125 | createRequest(`/methods/email/verifiers/${forcedId}`, null, 'get').end((err, res) => { 126 | expect(res.status).is.equals(200); 127 | expect(res.body.data.verificationId).is.equals('395a0e7d-3a1f-4d51-8dad-7d0229bd64ac'); 128 | expect(res.body.data.consumer).is.equals('test@test.com'); 129 | expect(res.body.data.expiredOn).to.be.an('number'); 130 | expect(res.body.data.payload).to.deep.eq({ 131 | key: 'value' 132 | }); 133 | expect(res.body.data.attempts).to.eq(0); 134 | expect(res.body.data).to.not.have.property('code'); 135 | done(); 136 | }); 137 | }); 138 | 139 | }); 140 | 141 | it('should respond with 404 if verification is not found - email', (done) => { 142 | createRequest(`/methods/email/verifiers/randomId`, null, 'get').end((err, res) => { 143 | expect(res.status).is.equals(404); 144 | done(); 145 | }); 146 | }); 147 | 148 | it('will fail validation if verificationId not exists', (done) => { 149 | 150 | createRequest(`/methods/email/verifiers/${notExistsVerificationId}/actions/validate`, { 151 | code: '123456' 152 | }).end((err, res) => { 153 | expect(res.status).is.equals(404); 154 | expect(res.body.error).is.equals('Not found'); 155 | done(); 156 | }); 157 | 158 | }); 159 | 160 | it('will successfully remove verificationId', (done) => { 161 | 162 | createInitiateEmailVerification(forcedId, forcedCode).end((err, res) => { 163 | expect(res.status).is.equals(200); 164 | expect(res.body.verificationId).is.equals(forcedId); 165 | 166 | createRequest(`/methods/email/verifiers/${forcedId}`, {}, 'delete').end((err, res) => { 167 | expect(res.status).is.equals(200); 168 | done(); 169 | }); 170 | }); 171 | 172 | }); 173 | 174 | it('will fail remove if verificationId doesn\'t exists', (done) => { 175 | 176 | createRequest(`/methods/email/verifiers/${notExistsVerificationId}`, {}, 'delete').end((err, res) => { 177 | expect(res.status).is.equals(404); 178 | expect(res.body.error).is.equals('Not found'); 179 | done(); 180 | }); 181 | 182 | }); 183 | 184 | it('will successfully validate verificationId and code - authenticator', (done) => { 185 | const secret = { 186 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 187 | verified: true 188 | }; 189 | const verificationId = 'verificationId'; 190 | const verificationData = { 191 | consumer: 'test@test.com', 192 | payload: { 193 | key: 'value' 194 | }, 195 | attempts: 0 196 | }; 197 | 198 | const testApp = mockStorageForGoogleAuth(secret, verificationId, verificationData); 199 | createRequest(`/methods/google_auth/verifiers/${ verificationId }/actions/validate`, { 200 | code: authenticator.generateToken(secret.secret) 201 | }, 'post', testApp).end((err, res) => { 202 | expect(res.status).is.equals(200); 203 | expect(res.body.data.consumer).is.eq('test@test.com'); 204 | expect(res.body.data.payload).to.deep.eq({ 205 | key: 'value' 206 | }); 207 | done(); 208 | }); 209 | }); 210 | 211 | it('will respond with 422 for incorrect code and increase attempts count - authenticator', (done) => { 212 | const secret = { 213 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 214 | verified: true 215 | }; 216 | const verificationId = 'verificationId'; 217 | const verificationData = { 218 | consumer: 'test@test.com', 219 | payload: { 220 | key: 'value' 221 | }, 222 | attempts: 0 223 | }; 224 | 225 | const testApp = mockStorageForGoogleAuth(secret, verificationId, verificationData); 226 | createRequest(`/methods/google_auth/verifiers/${ verificationId }/actions/validate`, { 227 | code: authenticator.generateToken(secret.secret) + '1' 228 | }, 'post', testApp).end((err, res) => { 229 | expect(res.status).is.equals(422); 230 | expect(res.body.data.consumer).is.eq('test@test.com'); 231 | expect(res.body.data.payload).to.deep.eq({ 232 | key: 'value' 233 | }); 234 | expect(res.body.data.attempts).to.eq(1); 235 | expect(res.body.data).to.not.have.property('code'); 236 | done(); 237 | }); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /src/controllers/verifiers.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import { inject, injectable } from 'inversify'; 3 | import { controller, httpDelete, httpPost, httpGet } from 'inversify-express-utils'; 4 | import 'reflect-metadata'; 5 | import { NotFoundException } from '../exceptions/exceptions'; 6 | import { responseWithError, responseAsUnbehaviorError } from '../helpers/responses'; 7 | import { AuthorizedRequest } from '../middlewares/common'; 8 | 9 | import { 10 | VerificationServiceFactory, 11 | VerificationServiceFactoryType 12 | } from '../services/verifications'; 13 | 14 | interface VerifierRequest extends AuthorizedRequest { 15 | params: { 16 | method: string; 17 | verificationId: string; 18 | }; 19 | } 20 | 21 | /** 22 | * VerifiersController resource 23 | */ 24 | @injectable() 25 | @controller( 26 | '/methods/:method/verifiers', 27 | 'AuthMiddleware', 28 | 'SupportedMethodsMiddleware' 29 | ) 30 | export class VerifiersController { 31 | 32 | constructor( @inject(VerificationServiceFactoryType) private verificationFactory: VerificationServiceFactory) { 33 | } 34 | 35 | /** 36 | * Verify code for specified Verification Id. 37 | * 38 | * @param req express req object 39 | * @param res express res object 40 | */ 41 | @httpPost( 42 | '/:verificationId/actions/validate' 43 | ) 44 | async validate(req: VerifierRequest, res: Response): Promise { 45 | try { 46 | const verificationService = this.verificationFactory.create(req.params.method); 47 | const validationResult = await verificationService.validate(req.params.verificationId, req.body, req.tenant); 48 | if (!validationResult.isValid) { 49 | responseWithError(res, 422, { 50 | error: 'Invalid code', 51 | data: validationResult.verification 52 | }); 53 | } else { 54 | this.responseSuccessfully(res, validationResult.verification); 55 | } 56 | } catch (err) { 57 | if (err instanceof NotFoundException) { 58 | this.responseAsNotFound(res); 59 | } else { 60 | responseAsUnbehaviorError(res, err); 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Delete Verification Id. 67 | * 68 | * @param req express req object 69 | * @param res express res object 70 | */ 71 | @httpDelete( 72 | '/:verificationId' 73 | ) 74 | async remove(req: VerifierRequest, res: Response): Promise { 75 | try { 76 | const verificationService = this.verificationFactory.create(req.params.method); 77 | if (!await verificationService.remove(req.params.verificationId)) { 78 | throw new NotFoundException(); 79 | } 80 | this.responseSuccessfully(res); 81 | } catch (err) { 82 | if (err instanceof NotFoundException) { 83 | this.responseAsNotFound(res); 84 | } else { 85 | responseAsUnbehaviorError(res, err); 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Verify code for specified Verification Id. 92 | * 93 | * @param req express req object 94 | * @param res express res object 95 | */ 96 | @httpGet( 97 | '/:verificationId' 98 | ) 99 | async getVerification(req: VerifierRequest, res: Response): Promise { 100 | const verificationService = this.verificationFactory.create(req.params.method); 101 | const verification = await verificationService.getVerification(req.params.verificationId); 102 | if (verification) { 103 | delete verification.code; 104 | this.responseSuccessfully(res, verification); 105 | } else { 106 | this.responseAsNotFound(res); 107 | } 108 | } 109 | 110 | private responseSuccessfully(res: Response, data?: any) { 111 | res.json({ 112 | status: 200, 113 | data 114 | }); 115 | } 116 | 117 | private responseAsNotFound(res: Response) { 118 | responseWithError(res, 404, { 119 | 'error': 'Not found' 120 | }); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/exceptions/exceptions.ts: -------------------------------------------------------------------------------- 1 | export class InvalidParametersException extends Error { 2 | constructor(public details: any) { 3 | super(details); 4 | this.name = 'Invalid request'; 5 | } 6 | } 7 | 8 | export class NotFoundException extends Error { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/responses.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | 3 | /** 4 | * Format default error response 5 | * @param res 6 | * @param status 7 | * @param responseJson 8 | */ 9 | export function responseWithError(res: Response, status: number, responseJson: Object) { 10 | return res.status(status).json(Object.assign({}, responseJson, { status: status })); 11 | } 12 | 13 | /** 14 | * Format response for 500 error 15 | * @param res 16 | * @param err 17 | */ 18 | export function responseAsUnbehaviorError(res: Response, err: Error) { 19 | return responseWithError(res, 500, { 20 | 'error': err && err.name || err, 21 | 'message': err && err.message || '' 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/helpers/specs/responses.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as sinon from 'sinon'; 3 | import { responseWithError, responseAsUnbehaviorError } from '../responses'; 4 | 5 | const {expect} = chai; 6 | 7 | describe('Helpers Responses', () => { 8 | 9 | let stubResponse; 10 | let mock; 11 | beforeEach(() => { 12 | stubResponse = { 13 | status: () => { 14 | return 200; 15 | }, 16 | json: () => { 17 | return {}; 18 | } 19 | }; 20 | mock = sinon.mock(stubResponse); 21 | mock.expects('status').once().returns(stubResponse); 22 | mock.expects('json').once().returnsArg(0); 23 | }); 24 | 25 | it('will provide a status code in the result json by responseWithError', () => { 26 | expect(responseWithError(stubResponse as any, 404, { 'error': 'Not found' })) 27 | .is.deep.equals({ 'error': 'Not found', 'status': 404 }); 28 | 29 | mock.verify(); 30 | }); 31 | 32 | it('will provide a status code and details in the result json by responseAsUnbehaviorError', () => { 33 | expect(responseAsUnbehaviorError(stubResponse as any, new Error('Message'))) 34 | .is.deep.equals({ 'error': 'Error', 'message': 'Message', 'status' : 500 }); 35 | 36 | mock.verify(); 37 | }); 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare interface VerificationData { 2 | id: string; 3 | consumer: string; 4 | attempts: number; 5 | expiredOn: number; 6 | code?: string; 7 | } 8 | 9 | declare interface ValidationResult { 10 | isValid: boolean; 11 | verification?: VerificationData; 12 | } 13 | 14 | /** 15 | * Storage Options 16 | */ 17 | declare interface ValueOptions { 18 | ttlInSeconds: number; 19 | } 20 | 21 | /** 22 | * StorageService interface. 23 | */ 24 | declare interface StorageService { 25 | 26 | /** 27 | * Set value with expiration options 28 | * 29 | * @param name 30 | * @param value 31 | * @param options 32 | */ 33 | set(name: string, value: T, options?: ValueOptions): Promise; 34 | 35 | /** 36 | * Remove value 37 | * 38 | * @param name 39 | */ 40 | remove(name: string): Promise; 41 | 42 | /** 43 | * Get value 44 | * 45 | * @param name 46 | * @param defaultValue 47 | */ 48 | get(name: string, defaultValue: T): Promise; 49 | 50 | } 51 | 52 | /** 53 | * VerificationService interface. 54 | */ 55 | declare interface VerificationService { 56 | initiate(params: any, tenantData: TenantVerificationResult): Promise; 57 | validate(verificationId: string, params: any, tenantData: TenantVerificationResult): Promise; 58 | remove(verificationId: string): Promise; 59 | getVerification(verificationId: string): Promise; 60 | } 61 | 62 | declare interface ParamsType { 63 | consumer: string; 64 | template?: any; 65 | generateCode?: GenerateCodeType; 66 | payload?: any; 67 | policy: PolicyParamsType; 68 | } 69 | 70 | declare interface PolicyParamsType { 71 | forcedVerificationId?: string; 72 | forcedCode?: string; 73 | expiredOn: string; 74 | } 75 | 76 | declare interface GenerateCodeType { 77 | symbolSet: Array; 78 | length: number; 79 | } 80 | 81 | declare interface EmailTemplateType { 82 | fromEmail: string; 83 | fromName?: string; 84 | subject: string; 85 | body: string; 86 | } 87 | 88 | declare interface TenantVerificationResult { 89 | id: string; 90 | login: string; 91 | jti: string; 92 | iat: number; 93 | aud: string; 94 | isTenant: boolean; 95 | } 96 | 97 | declare interface AuthenticatorSecret { 98 | secret: string; 99 | verified: boolean; 100 | } 101 | -------------------------------------------------------------------------------- /src/ioc.container.ts: -------------------------------------------------------------------------------- 1 | import { interfaces as InversifyInterfaces, Container } from 'inversify'; 2 | import { interfaces, TYPE } from 'inversify-express-utils'; 3 | import * as express from 'express'; 4 | 5 | import { MethodsController } from './controllers/methods'; 6 | import { VerifiersController } from './controllers/verifiers'; 7 | 8 | import config from './config'; 9 | 10 | import * as commonMiddlewares from './middlewares/common'; 11 | import * as validationMiddlewares from './middlewares/requests'; 12 | import * as auths from './services/auth'; 13 | import * as storages from './services/storages'; 14 | import * as providers from './services/providers'; 15 | import * as verifications from './services/verifications'; 16 | 17 | let container = new Container(); 18 | 19 | // services 20 | /* istanbul ignore else */ 21 | if (config.environment.isTesting) { 22 | container.bind(auths.AuthenticationServiceType) 23 | .to(auths.JwtSingleInlineAuthenticationService); 24 | } else { 25 | container.bind(auths.AuthenticationServiceType) 26 | .toDynamicValue((context: InversifyInterfaces.Context): auths.AuthenticationService => { 27 | return new auths.CachedAuthenticationDecorator( 28 | context.container.resolve(auths.ExternalHttpJwtAuthenticationService) 29 | ); 30 | }).inSingletonScope(); 31 | } 32 | 33 | container.bind(providers.EmailProviderServiceType) 34 | .to(providers.EmailProviderService).inSingletonScope(); 35 | 36 | /* istanbul ignore else */ 37 | if (config.environment.isTesting) { 38 | container.bind(storages.StorageServiceType) 39 | .to(storages.SimpleInMemoryStorageService).inSingletonScope(); 40 | } else { 41 | container.bind(storages.StorageServiceType) 42 | .to(storages.RedisStorageService).inSingletonScope(); 43 | } 44 | 45 | container.bind(verifications.VerificationServiceFactoryType) 46 | .to(verifications.VerificationServiceFactoryRegister).inSingletonScope(); 47 | 48 | // middlewares 49 | container.bind(commonMiddlewares.AuthMiddlewareType) 50 | .to(commonMiddlewares.AuthMiddleware); 51 | 52 | container.bind(commonMiddlewares.SupportedMethodsMiddlewareType) 53 | .to(commonMiddlewares.SupportedMethodsMiddleware); 54 | 55 | const authMiddleware = container 56 | .get(commonMiddlewares.AuthMiddlewareType); 57 | 58 | /* istanbul ignore next */ 59 | container.bind('AuthMiddleware').toConstantValue( 60 | (req: any, res: any, next: any) => authMiddleware.execute(req, res, next) 61 | ); 62 | 63 | const supportedMethodsMiddleware = container 64 | .get(commonMiddlewares.SupportedMethodsMiddlewareType); 65 | 66 | /* istanbul ignore next */ 67 | container.bind('SupportedMethodsMiddleware').toConstantValue( 68 | (req: any, res: any, next: any) => supportedMethodsMiddleware.execute(req, res, next) 69 | ); 70 | 71 | container.bind('InitiateVerificationValidation').toConstantValue( 72 | (req: any, res: any, next: any) => validationMiddlewares.initiateVerification(req, res, next) 73 | ); 74 | 75 | // controllers 76 | container.bind(TYPE.Controller).to(MethodsController).whenTargetNamed('MethodsController'); 77 | container.bind(TYPE.Controller).to(VerifiersController).whenTargetNamed('VerifiersController'); 78 | 79 | export { container }; 80 | -------------------------------------------------------------------------------- /src/middlewares/common.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request, NextFunction } from 'express'; 2 | import { inject, injectable } from 'inversify'; 3 | 4 | import { VerificationServiceFactory, VerificationServiceFactoryType } from '../services/verifications'; 5 | import { AuthenticationService, AuthenticationServiceType, AuthenticationException } from '../services/auth'; 6 | import { responseWithError } from '../helpers/responses'; 7 | export const AuthMiddlewareType = Symbol('AuthMiddlewareType'); 8 | export const SupportedMethodsMiddlewareType = Symbol('SupportedMethodsMiddlewareType'); 9 | 10 | export interface AuthorizedRequest extends Request { 11 | tenant?: TenantVerificationResult; 12 | } 13 | 14 | /** 15 | * Authentication middleware. 16 | */ 17 | @injectable() 18 | export class AuthMiddleware { 19 | constructor( @inject(AuthenticationServiceType) private authenticationService: AuthenticationService) { 20 | } 21 | 22 | /** 23 | * Execute authentication 24 | * 25 | * @param req Request 26 | * @param res Response 27 | * @param next NextFunction 28 | */ 29 | async execute(req: AuthorizedRequest, res: Response, next: NextFunction) { 30 | try { 31 | const jwtToken = this.extractJwtFromRequestHeaders(req); 32 | const tenantData = await this.authenticationService.validate(jwtToken); 33 | if (!jwtToken || !tenantData) { 34 | return responseWithError(res, 401, { error: 'Not Authorized' }); 35 | } 36 | 37 | req.tenant = tenantData; 38 | return next(); 39 | } catch (error) { 40 | if (error instanceof AuthenticationException) { 41 | return responseWithError(res, 500, { error: error.message }); 42 | } 43 | return responseWithError(res, 500, { error }); 44 | } 45 | } 46 | 47 | private extractJwtFromRequestHeaders(req: Request): string | null { 48 | if (!req.headers.authorization) { 49 | return null; 50 | } 51 | 52 | const parts = req.headers.authorization.split(' '); 53 | 54 | if (parts[0] !== 'Bearer' || !parts[1]) { 55 | return null; 56 | } 57 | 58 | return parts[1]; 59 | } 60 | } 61 | 62 | /** 63 | * Filter only supported methods. 64 | */ 65 | @injectable() 66 | export class SupportedMethodsMiddleware { 67 | constructor( @inject(VerificationServiceFactoryType) private verificationFactory: VerificationServiceFactory) { 68 | } 69 | 70 | execute(req: Request, res: Response, next: NextFunction) { 71 | if (!this.verificationFactory.hasMethod(req.params.method)) { 72 | return responseWithError(res, 404, { error: 'Method not supported' }); 73 | } 74 | 75 | return next(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/middlewares/requests.ts: -------------------------------------------------------------------------------- 1 | import { responseWithError } from '../helpers/responses'; 2 | import * as Joi from 'joi'; 3 | import { Response, Request, NextFunction } from 'express'; 4 | 5 | const options = { 6 | allowUnknown: true 7 | }; 8 | 9 | const jsonSchemeValidateRequest = Joi.object().keys({ 10 | code: Joi.string().min(1).required() 11 | }); 12 | 13 | function commonFlowRequestMiddleware(scheme: Joi.Schema, req: Request, res: Response, next: NextFunction) { 14 | const result = Joi.validate(req.body || {}, scheme, options); 15 | 16 | if (result.error) { 17 | return responseWithError(res, 422, { 18 | 'error': result.error, 19 | 'details': result.value 20 | }); 21 | } else { 22 | return next(); 23 | } 24 | } 25 | 26 | export function validateRequest(req: Request, res: Response, next: NextFunction) { 27 | return commonFlowRequestMiddleware(jsonSchemeValidateRequest, req, res, next); 28 | } 29 | 30 | export function initiateVerification(req: Request, res: Response, next: NextFunction) { 31 | let scheme; 32 | 33 | if (req.params.method === 'email') { 34 | scheme = Joi.object().keys({ 35 | consumer: Joi.string().required().empty(), 36 | 37 | template: Joi.object({}), 38 | 39 | generateCode: Joi.when('policy.forcedCode', { 40 | is: null, 41 | then: Joi.object({ 42 | length: Joi.number().greater(0).less(32).required(), 43 | symbolSet: Joi.array().items(Joi.string().empty()).required() 44 | }) 45 | }), 46 | 47 | policy: Joi.object({ 48 | expiredOn: Joi.string().required().empty(), 49 | forcedVerificationId: Joi.string().empty().guid(), 50 | forcedCode: Joi.string().empty() 51 | }).required() 52 | }); 53 | } 54 | 55 | if (req.params.method === 'google_auth') { 56 | scheme = Joi.object().keys({ 57 | consumer: Joi.string().required().empty(), 58 | issuer: Joi.string().required(), 59 | 60 | policy: Joi.object({ 61 | expiredOn: Joi.string().required().empty(), 62 | forcedVerificationId: Joi.string().empty().guid() 63 | }).required() 64 | }); 65 | } 66 | 67 | return commonFlowRequestMiddleware(scheme, req, res, next); 68 | } 69 | -------------------------------------------------------------------------------- /src/middlewares/specs/common.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as chai from 'chai'; 3 | import { container } from '../../ioc.container'; 4 | import { AuthMiddleware, AuthMiddlewareType, SupportedMethodsMiddleware } from '../common'; 5 | import { AuthenticationService, AuthenticationServiceType, JwtSingleInlineAuthenticationService } from '../../services/auth'; 6 | 7 | chai.use(require('chai-http')); 8 | const {expect, request} = chai; 9 | 10 | describe('Common Middlewares', () => { 11 | 12 | describe('Test AuthMiddleware', () => { 13 | 14 | const authMiddleware = container.resolve(AuthMiddleware); 15 | const app: express.Application = express(); 16 | app.use((req: express.Request, res: express.Response, next: express.NextFunction) => authMiddleware.execute(req, res, next)); 17 | 18 | it('will not authorize without JWT token in the header', (done) => { 19 | request(app).get('/any-url').end((err, res) => { 20 | expect(res.status).to.equal(401); 21 | done(); 22 | }); 23 | }); 24 | 25 | it('will not authorize with JWT token that have invalid formatted', (done) => { 26 | request(app).get('/any-url').set('Authorization', 'InvalidHeaderValue').end((err, res) => { 27 | expect(res.status).to.equal(401); 28 | done(); 29 | }); 30 | }); 31 | 32 | it('will authorize with valid JWT token in the header', (done) => { 33 | request(app).get('/any-url').set('Authorization', 'Bearer TOKEN').end((err, res) => { 34 | expect(res.status).to.equal(404); 35 | done(); 36 | }); 37 | }); 38 | 39 | }); 40 | 41 | describe('Test SupportedMethodsMiddleware', () => { 42 | 43 | const supportedMethodsMiddleware = container.resolve(SupportedMethodsMiddleware); 44 | const app: express.Application = express() 45 | .use('/methods/:method/verifiers', (req: express.Request, res: express.Response, next: express.NextFunction) => supportedMethodsMiddleware.execute(req, res, next)) 46 | .use('/methods/:method/verifiers', (req: express.Request, res: express.Response, next: express.NextFunction) => res.status(200).json({})); 47 | 48 | it('will not accept unsupported method', (done) => { 49 | request(app).post('/methods/unsupported/verifiers') 50 | .end((err, res) => { 51 | expect(res.status).to.equal(404); 52 | expect(res.body.error).to.equal('Method not supported'); 53 | done(); 54 | }); 55 | }); 56 | 57 | it('will accept email method', (done) => { 58 | request(app).post('/methods/email/verifiers') 59 | .query({'method': 'email'}) 60 | .end((err, res) => { 61 | expect(res.status).to.equal(200); 62 | done(); 63 | }); 64 | }); 65 | 66 | }); 67 | 68 | }); 69 | -------------------------------------------------------------------------------- /src/middlewares/specs/requests.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as chai from 'chai'; 3 | import { validateRequest } from '../requests'; 4 | import * as bodyParser from 'body-parser'; 5 | 6 | chai.use(require('chai-http')); 7 | const {expect, request} = chai; 8 | 9 | function createApp() { 10 | const app: express.Application = express(); 11 | return app.use(bodyParser.json()); 12 | } 13 | 14 | function createRequest(app: express.Application, data: any) { 15 | return request(app).post('/').send(data); 16 | } 17 | 18 | describe('Requests Middlewares', () => { 19 | 20 | describe('ValidateRequest validator', () => { 21 | let app = createApp() 22 | .use((req: express.Request, res: express.Response, next: express.NextFunction) => validateRequest(req, res, next)); 23 | 24 | it('will accept right filled request', (done) => { 25 | createRequest(app, { 26 | code: 'code' 27 | }).end((err, res) => { 28 | expect(res.status).to.equal(404); 29 | done(); 30 | }); 31 | }); 32 | 33 | it('will not accept request with invalid fields', (done) => { 34 | createRequest(app, { 35 | code: 123 36 | }).end((err, res) => { 37 | expect(res.status).to.equal(422); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('will not accept request without required fields', (done) => { 43 | request(app).post('/').send({}).end((err, res) => { 44 | expect(res.status).to.equal(422); 45 | done(); 46 | }); 47 | }); 48 | 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import { Response, Request, NextFunction, Application } from 'express'; 3 | import * as bodyParser from 'body-parser'; 4 | import { InversifyExpressServer } from 'inversify-express-utils'; 5 | 6 | import config from './config'; 7 | import { container } from './ioc.container'; 8 | 9 | const app: Application = express(); 10 | 11 | app.disable('x-powered-by'); 12 | 13 | app.use((req: Request, res: Response, next: NextFunction) => { 14 | if (config.server.forceHttps === 'enabled') { 15 | if (!req.secure) { 16 | return res.redirect('https://' + req.hostname + ':' + config.server.httpsPort + req.originalUrl); 17 | } 18 | 19 | res.setHeader('Strict-Transport-Security', 'max-age=31536000'); 20 | } 21 | 22 | const acceptHeader = req.header('Accept') || ''; 23 | 24 | if (acceptHeader !== 'application/json' && acceptHeader.indexOf('application/vnd.jincor+json;') !== 0) { 25 | return res.status(406).json({ 26 | error: 'Unsupported "Accept" header' 27 | }); 28 | } 29 | 30 | res.setHeader('X-Content-Type-Options', 'nosniff'); 31 | res.setHeader('X-Frame-Options', 'deny'); 32 | res.setHeader('Content-Security-Policy', 'default-src \'none\''); 33 | return next(); 34 | }); 35 | 36 | app.post('*', (req: Request, res: Response, next: NextFunction) => { 37 | if (req.header('Content-Type') !== 'application/json') { 38 | return res.status(406).json({ 39 | error: 'Unsupported "Content-Type"' 40 | }); 41 | } 42 | 43 | return next(); 44 | }); 45 | 46 | app.use(bodyParser.json()); 47 | app.use(bodyParser.urlencoded({ extended: false })); 48 | 49 | let server = new InversifyExpressServer(container, null, null, app); 50 | 51 | export default server.build(); 52 | -------------------------------------------------------------------------------- /src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import * as LRU from 'lru-cache'; 2 | import { injectable } from 'inversify'; 3 | import * as request from 'request'; 4 | import 'reflect-metadata'; 5 | 6 | import config from '../config'; 7 | 8 | export const AuthenticationServiceType = Symbol('AuthenticationServiceType'); 9 | 10 | export class AuthenticationException extends Error { } 11 | 12 | /** 13 | * AuthenticationService interface 14 | */ 15 | export interface AuthenticationService { 16 | 17 | validate(jwtToken: string): Promise; 18 | 19 | } 20 | 21 | /** 22 | * ExternalHttpJwtAuthenticationService class 23 | */ 24 | @injectable() 25 | export class ExternalHttpJwtAuthenticationService implements AuthenticationService { 26 | 27 | private apiUrl: string = config.auth.url; 28 | private timeout: number = config.auth.timeout; 29 | 30 | /** 31 | * Validate JWT token 32 | * @param jwtToken 33 | */ 34 | async validate(jwtToken: string): Promise { 35 | if (!jwtToken) { 36 | return null; 37 | } 38 | 39 | return await this.callVerifyJwtTokenMethodEndpoint(jwtToken); 40 | } 41 | 42 | /** 43 | * Make HTTP/HTTPS request 44 | * @param jwtToken 45 | */ 46 | private async callVerifyJwtTokenMethodEndpoint(jwtToken: string): Promise { 47 | /* istanbul ignore next */ 48 | return new Promise((resolve, reject) => { 49 | request.post({ 50 | url: this.apiUrl, 51 | timeout: this.timeout, 52 | headers: { 53 | 'accept': 'application/json' 54 | }, 55 | json: { token: jwtToken } 56 | // agentOptions: { 57 | // cert: '', 58 | // key: '', 59 | // passphrase: '', 60 | // securityOptions: 'SSL_OP_NO_SSLv3' 61 | // } 62 | }, (error: any, response: any, body: any) => { 63 | if (error) { 64 | return reject(new AuthenticationException(error)); 65 | } 66 | 67 | if (response.statusCode !== 200 || !body.decoded) { 68 | return reject(new AuthenticationException('Invalid token')); 69 | } 70 | 71 | if (!body.decoded.isTenant) { 72 | return reject(new AuthenticationException('JWT isn\'t type of tenant')); 73 | } 74 | 75 | resolve(body.decoded); 76 | }); 77 | }); 78 | } 79 | 80 | } 81 | 82 | /** 83 | * Simple, single value token validator 84 | * @internal 85 | */ 86 | @injectable() 87 | export class JwtSingleInlineAuthenticationService implements AuthenticationService { 88 | 89 | private storedToken: string = 'TOKEN'; 90 | 91 | /** 92 | * Set token 93 | * @param token 94 | */ 95 | public setToken(token: string) { 96 | this.storedToken = token; 97 | } 98 | 99 | /** 100 | * Validate JWT token 101 | * @param jwtToken 102 | */ 103 | async validate(jwtToken: string): Promise { 104 | if (jwtToken !== this.storedToken) { 105 | return null; 106 | } 107 | return { 108 | id: 'tenantId', 109 | isTenant: true, 110 | login: 'tenantLogin', 111 | jti: '1234', 112 | iat: 1234, 113 | aud: '123' 114 | }; 115 | } 116 | } 117 | 118 | /** 119 | * Cache decorator for only successfully request 120 | */ 121 | export class CachedAuthenticationDecorator implements AuthenticationService { 122 | private lruCache: any; 123 | 124 | /** 125 | * @param authenticationService 126 | * @param maxAgeInSeconds 127 | * @param maxLength 128 | */ 129 | constructor(private authenticationService: AuthenticationService, maxAgeInSeconds: number = 30, maxLength: number = 1000) { 130 | this.lruCache = LRU({ 131 | max: maxLength, 132 | maxAge: maxAgeInSeconds * 1000 133 | }); 134 | } 135 | 136 | /** 137 | * @inheritdoc 138 | */ 139 | async validate(jwtToken: string): Promise { 140 | try { 141 | if (this.lruCache.has(jwtToken)) { 142 | return this.lruCache.get(jwtToken); 143 | } 144 | 145 | const result = await this.authenticationService.validate(jwtToken); 146 | this.lruCache.set(jwtToken, result); 147 | return result; 148 | } catch (err) { 149 | throw err; 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/services/authenticator.verification.ts: -------------------------------------------------------------------------------- 1 | import * as authenticator from 'authenticator'; 2 | import { NotFoundException } from '../exceptions/exceptions'; 3 | import { BaseVerificationService } from './base.verification'; 4 | 5 | export default class AuthenticatorVerificationService extends BaseVerificationService { 6 | async initiate(params: any, tenantData: TenantVerificationResult): Promise { 7 | const result = await super.initiate(params, tenantData); 8 | 9 | let secret = await this.getSecret(params.consumer, tenantData.id); 10 | 11 | if (secret === null || !secret.verified) { 12 | secret = await this.generateAndStoreSecret(params.consumer, tenantData.id); 13 | result.totpUri = authenticator.generateTotpUri(secret.secret, params.consumer, params.issuer, 'SHA1', 6, 30); 14 | } 15 | return result; 16 | } 17 | 18 | async validate(verificationId: string, params: any, tenantData: TenantVerificationResult): Promise { 19 | const verification = await this.getVerification(verificationId); 20 | if (verification === null) { 21 | throw new NotFoundException('Verification is not found'); 22 | } 23 | 24 | const secret = await this.getSecret(verification.consumer, tenantData.id); 25 | if (secret === null) { 26 | throw new NotFoundException('User secret is not found'); 27 | } 28 | 29 | const result = authenticator.verifyToken(secret.secret, params.code.toString()); 30 | if (!result) { 31 | verification.attempts += 1; 32 | await this.storageService.set(`${ this.keyPrefix }${ verificationId }`, verification); 33 | 34 | return { 35 | isValid: false, 36 | verification 37 | }; 38 | } 39 | 40 | if (params.removeSecret === true) { 41 | await this.removeSecret(verification.consumer, tenantData.id); 42 | } 43 | 44 | if (!secret.verified) { 45 | await this.verifyAndSaveSecret(verification.consumer, tenantData.id, secret); 46 | } 47 | 48 | await this.storageService.remove(`${ this.keyPrefix }${ verificationId }`); 49 | return { 50 | isValid: true, 51 | verification 52 | }; 53 | } 54 | 55 | private async generateAndStoreSecret(consumer: string, tenantId: string): Promise { 56 | const secret = { 57 | secret: authenticator.generateKey(), 58 | verified: false 59 | }; 60 | await this.storageService.set(`${ this.keyPrefix + tenantId + consumer }`, secret); 61 | return secret; 62 | } 63 | 64 | private async verifyAndSaveSecret(consumer: string, tenantId: string, secret: AuthenticatorSecret): Promise { 65 | secret.verified = true; 66 | await this.storageService.set(`${ this.keyPrefix + tenantId + consumer }`, secret); 67 | } 68 | 69 | private async getSecret(consumer: string, tenantId: string): Promise { 70 | return await this.storageService.get(`${ this.keyPrefix + tenantId + consumer }`, null); 71 | } 72 | 73 | private async removeSecret(consumer: string, tenantId: string): Promise { 74 | await this.storageService.remove(`${ this.keyPrefix + tenantId + consumer }`); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/services/base.verification.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment'; 2 | import * as uuid from 'node-uuid'; 3 | import { NotFoundException, InvalidParametersException } from '../exceptions/exceptions'; 4 | import * as crypto from 'crypto'; 5 | 6 | /** 7 | * BaseVerificationService 8 | */ 9 | export abstract class BaseVerificationService implements VerificationService { 10 | 11 | /** 12 | * Base constructor for verification service 13 | * 14 | * @param keyPrefix 15 | * @param storageService 16 | */ 17 | constructor(protected keyPrefix: string, protected storageService: StorageService) { 18 | } 19 | 20 | /** 21 | * Get or generate verificationId 22 | * 23 | * @param policyParams 24 | */ 25 | protected getVerificationId(policyParams: PolicyParamsType): string { 26 | if (policyParams.forcedVerificationId) { 27 | // @TODO: Add validation with usage of Joi 28 | return policyParams.forcedVerificationId; 29 | } 30 | 31 | return uuid.v4(); 32 | } 33 | 34 | /** 35 | * Get or generate code 36 | * 37 | * @param generateParams 38 | * @param policyParams 39 | */ 40 | protected getCode(generateParams: GenerateCodeType, policyParams: PolicyParamsType): string { 41 | if (policyParams && policyParams.forcedCode) { 42 | // @TODO: Add validation with usage of Joi 43 | return policyParams.forcedCode; 44 | } 45 | 46 | if (generateParams) { 47 | return this.generateCode(generateParams.symbolSet, generateParams.length); 48 | } 49 | 50 | return ''; 51 | } 52 | 53 | /** 54 | * Initiate verification process 55 | * 56 | * @param params 57 | * @param tenantData 58 | */ 59 | async initiate(params: ParamsType, tenantData: TenantVerificationResult): Promise { 60 | const verificationId = this.getVerificationId(params.policy); 61 | 62 | const code = this.getCode(params.generateCode, params.policy); 63 | 64 | const ttlInSeconds = moment.duration(params.policy.expiredOn).asSeconds(); 65 | 66 | if (!ttlInSeconds) { 67 | throw new InvalidParametersException('expiredOn format is invalid'); 68 | } 69 | 70 | const data = { 71 | verificationId, 72 | consumer: params.consumer, 73 | payload: params.payload, 74 | code, 75 | attempts: 0, 76 | expiredOn: ~~((+new Date() + ttlInSeconds * 1000) / 1000) 77 | }; 78 | 79 | await this.storageService.set(this.keyPrefix + verificationId, data, { ttlInSeconds }); 80 | 81 | return data; 82 | } 83 | 84 | /** 85 | * Validate verificationId with passed code 86 | * 87 | * @param verificationId 88 | * @param params 89 | * @param tenantData 90 | */ 91 | async validate(verificationId: string, params: any, tenantData: TenantVerificationResult): Promise { 92 | const result = await this.getVerification(verificationId); 93 | 94 | if (result === null) { 95 | throw new NotFoundException(); 96 | } 97 | 98 | if (result.code === params.code) { 99 | await this.remove(verificationId); 100 | delete result.code; 101 | return { 102 | isValid: true, 103 | verification: result 104 | }; 105 | } 106 | 107 | result.attempts += 1; 108 | await this.storageService.set(this.keyPrefix + verificationId, result); 109 | 110 | delete result.code; 111 | return { 112 | isValid: false, 113 | verification: result 114 | }; 115 | } 116 | 117 | /** 118 | * Remove verificationId 119 | * 120 | * @param verificationId 121 | */ 122 | async remove(verificationId: string): Promise { 123 | const result = await this.storageService.remove(this.keyPrefix + verificationId); 124 | return result !== null; 125 | } 126 | 127 | async getVerification(verificationId: string): Promise { 128 | return await this.storageService.get(this.keyPrefix + verificationId, null); 129 | } 130 | 131 | // @TODO: Rethink, may be is too weak algorithm here 132 | protected generateCode(symbolSet: Array, length: number): string { 133 | let stringWithAllSymbols = ''; 134 | let resultCode = ''; 135 | 136 | stringWithAllSymbols = (symbolSet || []).map(element => { 137 | switch (element) { 138 | case 'alphas': 139 | return 'abcdefghijklmnopqrstuvwxyz'; // ~27% 140 | case 'ALPHAS': 141 | return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // ~27% 142 | case 'DIGITS': 143 | return '01234567890123456789'; // ~22% it's duplicated for uniform distribution 144 | case 'SYMBOLS': 145 | return '!@#$%^&*-+=|<>()[]{};:_'; // ~24% 146 | default: 147 | return element; 148 | } 149 | }).join(''); 150 | 151 | while (length--) { 152 | resultCode += stringWithAllSymbols[crypto.randomBytes(1)[0] % stringWithAllSymbols.length]; 153 | } 154 | 155 | return resultCode; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/services/email.verification.ts: -------------------------------------------------------------------------------- 1 | import { EmailProviderService, EmailProvider } from './providers/index'; 2 | import { BaseVerificationService } from './base.verification'; 3 | import config from '../config'; 4 | import { InvalidParametersException } from '../exceptions/exceptions'; 5 | import * as Joi from 'joi'; 6 | import { validateObjectByJoiScheme } from './helpers'; 7 | 8 | export const jsonSchemeInitiateRequestValidEmailConsumer = Joi.object().keys({ 9 | consumer: Joi.string().email() 10 | }); 11 | 12 | export const jsonSchemeInitiateRequestEmailTemplate = Joi.object().keys({ 13 | fromEmail: Joi.string().empty(), 14 | fromName: Joi.string().empty(), 15 | subject: Joi.string().empty(), 16 | body: Joi.string().empty().required() 17 | }); 18 | 19 | /** 20 | * Concrete EmailVerificationService. 21 | */ 22 | export default class EmailVerificationService extends BaseVerificationService { 23 | 24 | protected emailProvider: EmailProvider; 25 | 26 | /** 27 | * Email verification specialization 28 | * 29 | * @param keyPrefix 30 | * @param storageService 31 | * @param emailProviderService 32 | */ 33 | constructor( 34 | protected keyPrefix: string, 35 | protected storageService: StorageService, 36 | protected emailProviderService: EmailProviderService 37 | ) { 38 | super(keyPrefix, storageService); 39 | 40 | if (!config.providers.email.provider) { 41 | throw new InvalidParametersException(`The environment variable MAIL_DRIVER isn\'t set up`); 42 | } 43 | 44 | this.emailProvider = emailProviderService.getEmailProviderByName(config.providers.email.provider); 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | async initiate(params: ParamsType, tenantData: TenantVerificationResult): Promise { 51 | if (params.consumer) { 52 | validateObjectByJoiScheme(params, jsonSchemeInitiateRequestValidEmailConsumer); 53 | } 54 | validateObjectByJoiScheme(params.template, jsonSchemeInitiateRequestEmailTemplate); 55 | 56 | const templateParams: EmailTemplateType = params.template; 57 | let responseObject = await super.initiate(params, tenantData); 58 | 59 | let emailFrom = templateParams.fromEmail || ''; 60 | 61 | if (templateParams.fromName) { 62 | emailFrom = `${templateParams.fromName} <${emailFrom}>`; 63 | } 64 | 65 | // @TODO: The better solution is used external microservice for sending 66 | await this.emailProvider.send( 67 | emailFrom, 68 | [params.consumer], 69 | templateParams.subject || 'Verification Email', 70 | templateParams.body 71 | .replace(/{{{CODE}}}/g, responseObject.code) 72 | .replace(/{{{VERIFICATION_ID}}}/g, responseObject.verificationId) 73 | ); 74 | 75 | delete responseObject.code; 76 | 77 | return responseObject; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/services/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { InvalidParametersException } from '../exceptions/exceptions'; 3 | 4 | export function validateObjectByJoiScheme(obj: any, scheme: Joi.Schema) { 5 | const result = Joi.validate(obj || {}, scheme, { allowUnknown: true }); 6 | if (result.error) { 7 | throw new InvalidParametersException(result.error && result.error.message || result.error); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/services/providers/email.ts: -------------------------------------------------------------------------------- 1 | import * as mailgun from 'mailgun-js'; 2 | import * as mailcomposer from 'mailcomposer'; 3 | 4 | import { 5 | EmailProvider, 6 | NotProperlyInitializedException, 7 | InvalidParametersException, 8 | InternalProviderException 9 | } from './index'; 10 | 11 | /** 12 | * Dummy Email Provider class 13 | */ 14 | export class DummyEmailProvider implements EmailProvider { 15 | 16 | /** 17 | * Get name of concrete provider 18 | */ 19 | public static getName() { 20 | return 'dummy'; 21 | } 22 | 23 | /** 24 | * @inheritdoc 25 | */ 26 | public send(sender: string, recipients: Array, subject: string, text: string): Promise { 27 | console.log('SEND EMAIL HEAD: sender=%s recipients=%s subject=%s', sender, recipients, subject); 28 | console.log('SEND EMAIL BODY:', text); 29 | return Promise.resolve({sender, recipients, subject}); 30 | } 31 | } 32 | 33 | /** 34 | * Mailgun Email Provider class 35 | */ 36 | export class MailgunEmailProvider implements EmailProvider { 37 | private api: any; 38 | 39 | /** 40 | * Initiate concrete provider instance 41 | */ 42 | constructor(config: any) { 43 | const { 44 | MAILGUN_SECRET, 45 | MAILGUN_DOMAIN 46 | } = config; 47 | 48 | if (!MAILGUN_DOMAIN) { 49 | throw new NotProperlyInitializedException('MAILGUN_DOMAIN is invalid'); 50 | } 51 | 52 | if (!MAILGUN_SECRET || !/^(api:)?key-.+/.test(MAILGUN_SECRET)) { 53 | throw new NotProperlyInitializedException('MAILGUN_SECRET is invalid'); 54 | } 55 | 56 | this.api = this.createMailgunApi({ 57 | apiKey: MAILGUN_SECRET, 58 | domain: MAILGUN_DOMAIN 59 | }); 60 | } 61 | 62 | /** 63 | * Create instance for mailgun api 64 | * @param config 65 | */ 66 | protected createMailgunApi(config: any) { 67 | return new mailgun(config); 68 | } 69 | 70 | /** 71 | * Get name of concrete provider 72 | */ 73 | public static getName() { 74 | return 'mailgun'; 75 | } 76 | 77 | /** 78 | * @inheritdoc 79 | */ 80 | public send(sender: string, recipients: Array, subject: string, text: string): Promise { 81 | if (!recipients.length) { 82 | throw new InvalidParametersException(); 83 | } 84 | 85 | /* istanbul ignore next */ 86 | return new Promise((resolve, reject) => { 87 | let mail = mailcomposer({ 88 | from: sender, 89 | to: recipients[0], 90 | subject, 91 | html: text 92 | }); 93 | 94 | mail.build((mailBuildError, message) => { 95 | let dataToSend = { 96 | to: recipients[0], 97 | message: message.toString('ascii') 98 | }; 99 | 100 | this.api.messages().sendMime(dataToSend, (err, body) => { 101 | if (err) { 102 | reject(new InternalProviderException(err)); 103 | } 104 | resolve(body); 105 | }); 106 | }); 107 | }); 108 | } 109 | } 110 | 111 | export class MailjetEmailProvider implements EmailProvider { 112 | private api: any; 113 | 114 | /** 115 | * Initiate concrete provider instance 116 | */ 117 | constructor(config: any) { 118 | this.api = require('node-mailjet').connect(config.MAILJET_API_KEY, config.MAILJET_API_SECRET); 119 | } 120 | 121 | /** 122 | * @inheritdoc 123 | */ 124 | public send(sender: string, recipients: string[], subject: string, text: string): Promise { 125 | const sendEmail = this.api.post('send'); 126 | 127 | const emailData = { 128 | 'FromEmail': sender, 129 | 'Subject': subject, 130 | 'Html-part': text, 131 | 'Recipients': [{'Email': recipients[0]}] 132 | }; 133 | 134 | return sendEmail.request(emailData); 135 | } 136 | 137 | public static getName() { 138 | return 'mailjet'; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/services/providers/index.ts: -------------------------------------------------------------------------------- 1 | import config from '../../config'; 2 | import * as emailProviders from './email'; 3 | import { injectable } from 'inversify'; 4 | import 'reflect-metadata'; 5 | 6 | // Exceptions 7 | export class ProviderException extends Error { } 8 | export class NotProperlyInitializedException extends ProviderException { } 9 | export class InvalidParametersException extends ProviderException { } 10 | export class NotFoundException extends ProviderException { } 11 | export class InternalProviderException extends ProviderException { } 12 | 13 | // Types 14 | 15 | /** 16 | * Email Provider interface 17 | */ 18 | export interface EmailProvider { 19 | 20 | /** 21 | * Send an email 22 | * 23 | * @param sender 24 | * @param recipients 25 | * @param subject 26 | * @param text 27 | * @return Promise 28 | */ 29 | send(sender: string, recipients: Array, subject: string, text: string): Promise; 30 | } 31 | 32 | export const EmailProviderServiceType = Symbol('EmailProviderService'); 33 | 34 | /** 35 | * Email Providers service 36 | */ 37 | @injectable() 38 | export class EmailProviderService { 39 | private emailRegistry: { 40 | [key: string]: EmailProvider 41 | } = {}; 42 | 43 | /** 44 | * Get concrete email provider by name 45 | * @param name 46 | */ 47 | public getEmailProviderByName(name: string): EmailProvider { 48 | return this.emailRegistry[name] || ( 49 | this.emailRegistry[name] = this.findAndInstantiate(name, emailProviders) 50 | ); 51 | } 52 | 53 | /** 54 | * Find by name provider 55 | * @param name 56 | */ 57 | private findAndInstantiate(name: string, providers: any): EmailProvider { 58 | let providerName = Object.keys(providers).filter((providerClassName) => ( 59 | providers[providerClassName] instanceof Object && 60 | providers[providerClassName].hasOwnProperty('getName') && 61 | providers[providerClassName].getName() === name 62 | )); 63 | 64 | if (providerName.length !== 1) { 65 | throw new NotFoundException(`${name} not found or it\'s duplicated`); 66 | } 67 | 68 | return new providers[providerName[0]](process.env); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/services/providers/specs/email.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as chai from 'chai'; 3 | import * as sinon from 'sinon'; 4 | 5 | import { DummyEmailProvider, MailgunEmailProvider } from '../email'; 6 | import { InvalidParametersException } from '../index'; 7 | 8 | const {expect} = chai; 9 | 10 | describe('Email Providers', () => { 11 | 12 | describe('Test DummyEmailProvider', () => { 13 | 14 | it('will get dummy name', async() => { 15 | expect(DummyEmailProvider.getName()).is.equals('dummy'); 16 | }); 17 | 18 | it('will send an email', async() => { 19 | let instance = new DummyEmailProvider(); 20 | expect(await instance.send( 21 | 'test@test.com', ['test1@test1.com'], 'subject', 'email body' 22 | )).is.property('sender').equals('test@test.com'); 23 | }); 24 | 25 | }); 26 | 27 | describe('Test MailgunEmailProvider', () => { 28 | 29 | const stubMailGunApi = { 30 | messages: () => stubMailGunApi, 31 | sendMime: (dataToSend, callback) => { 32 | callback(null, dataToSend.message); 33 | } 34 | }; 35 | 36 | class StubMailgunEmailProvider extends MailgunEmailProvider { 37 | protected createMailgunApi(config: any) { 38 | return stubMailGunApi; 39 | } 40 | } 41 | 42 | it('will get mailgun name', async() => { 43 | expect(MailgunEmailProvider.getName()).is.equals('mailgun'); 44 | }); 45 | 46 | it('will throw NotProperlyInitializedException if incorrect MAILGUN_SECRET passed', async() => { 47 | expect(() => { 48 | const stubInstance = new StubMailgunEmailProvider({ 49 | MAILGUN_SECRET: 'secret-12345', 50 | MAILGUN_DOMAIN: 'domain.com' 51 | }); 52 | }).throws('MAILGUN_SECRET is invalid'); 53 | }); 54 | 55 | it('will throw NotProperlyInitializedException if incorrect MAILGUN_DOMAIN not passed', async() => { 56 | expect(() => { 57 | const stubInstance = new StubMailgunEmailProvider({ 58 | MAILGUN_SECRET: 'key-12345' 59 | }); 60 | }).throws('MAILGUN_DOMAIN is invalid'); 61 | }); 62 | 63 | it('will send an email', async() => { 64 | const stubInstance = new StubMailgunEmailProvider({ 65 | MAILGUN_SECRET: 'key-12345', 66 | MAILGUN_DOMAIN: 'domain.com' 67 | }); 68 | 69 | const bodyString = 'MESSAGE'; 70 | 71 | expect(await stubInstance.send('sender@sender.com', ['recipient@recipient.com'], 'subject', bodyString)) 72 | .is.contains(bodyString); 73 | }); 74 | 75 | }); 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /src/services/specs/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationService, AuthenticationServiceType, ExternalHttpJwtAuthenticationService, JwtSingleInlineAuthenticationService, CachedAuthenticationDecorator } from '../auth'; 2 | import * as chai from 'chai'; 3 | import * as sinon from 'sinon'; 4 | 5 | import { container } from '../../ioc.container'; 6 | 7 | const {expect} = chai; 8 | 9 | const tenantData = { 10 | id: 'tenantId', 11 | isTenant: true, 12 | login: 'tenantLogin', 13 | jti: '1234', 14 | iat: 1234, 15 | aud: '123' 16 | }; 17 | 18 | describe('Auth Services', () => { 19 | 20 | describe('Test JwtSingleInlineAuthenticationService', () => { 21 | let instance: AuthenticationService; 22 | container.rebind(AuthenticationServiceType) 23 | .to(JwtSingleInlineAuthenticationService); 24 | 25 | beforeEach(() => { 26 | instance = container.resolve(JwtSingleInlineAuthenticationService); 27 | }); 28 | 29 | it('will not validate empty JWT string', async() => { 30 | expect(await instance.validate('')).is.eq(null); 31 | }); 32 | 33 | it('will not validate with incorrect JWT string', async() => { 34 | expect(await instance.validate('not empty string')).is.eq(null); 35 | }); 36 | 37 | it('will successfully validate with valid JWT string', async() => { 38 | expect(await instance.validate('TOKEN')).deep.eq(tenantData); 39 | }); 40 | }); 41 | 42 | describe('Test ExternalHttpJwtAuthenticationService', () => { 43 | let instance: AuthenticationService; 44 | container.rebind(AuthenticationServiceType) 45 | .to(ExternalHttpJwtAuthenticationService); 46 | 47 | beforeEach(async() => { 48 | instance = container.resolve(ExternalHttpJwtAuthenticationService); 49 | }); 50 | 51 | it('will not validate empty JWT string', async() => { 52 | let mock = sinon.mock(instance); 53 | mock.expects('callVerifyJwtTokenMethodEndpoint').never(); 54 | expect(await instance.validate('')).is.eq(null); 55 | mock.verify(); 56 | }); 57 | 58 | it('will successfully validate with valid JWT string', async() => { 59 | let mock = sinon.mock(instance); 60 | mock.expects('callVerifyJwtTokenMethodEndpoint').once().returns(Promise.resolve(tenantData)); 61 | expect(await instance.validate('not empty string')).deep.eq(tenantData); 62 | mock.verify(); 63 | }); 64 | }); 65 | 66 | describe('Test CachedAuthenticationDecorator', () => { 67 | let mock; 68 | 69 | function createDecoratedInstance(returnResult: TenantVerificationResult) { 70 | let instance = { 71 | validate: (jwtToken: string): Promise => Promise.resolve(returnResult) 72 | }; 73 | mock = sinon.mock(instance); 74 | mock.expects('validate').once().returns(Promise.resolve(returnResult)); 75 | 76 | return new CachedAuthenticationDecorator(instance); 77 | } 78 | 79 | it('will store a success validation result', async() => { 80 | let decoratedInstance = createDecoratedInstance(tenantData); 81 | 82 | expect(await decoratedInstance.validate('TOKEN')).is.eq(tenantData); 83 | expect(await decoratedInstance.validate('TOKEN')).is.eq(tenantData); 84 | 85 | mock.verify(); 86 | }); 87 | 88 | it('will store a failed validation result', async() => { 89 | let decoratedInstance = createDecoratedInstance(null); 90 | 91 | expect(await decoratedInstance.validate('TOKEN')).is.eq(null); 92 | expect(await decoratedInstance.validate('TOKEN')).is.eq(null); 93 | 94 | mock.verify(); 95 | }); 96 | }); 97 | 98 | }); 99 | -------------------------------------------------------------------------------- /src/services/specs/authenticator.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import AuthenticatorVerificationService from '../authenticator.verification'; 3 | import { SimpleInMemoryStorageService } from '../storages'; 4 | import * as TypeMoq from 'typemoq'; 5 | import * as authenticator from 'authenticator'; 6 | 7 | chai.use(require('chai-as-promised')); 8 | const { expect } = chai; 9 | 10 | const tenantData = { 11 | id: 'tenantId', 12 | isTenant: true, 13 | login: 'tenantLogin', 14 | jti: '1234', 15 | iat: 1234, 16 | aud: '123' 17 | }; 18 | 19 | describe('Authenticator service', () => { 20 | describe('Initiate', () => { 21 | it('should initiate verification - also generate secret for consumer if no secret yet', async() => { 22 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 23 | storageMock.setup(x => x.get(TypeMoq.It.isAny(), TypeMoq.It.isAny())) 24 | .returns(async(): Promise => null); 25 | 26 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 27 | 28 | const input = { 29 | consumer: 'test@test.com', 30 | issuer: 'Jincor', 31 | policy: { 32 | expiredOn: '01:00:00', 33 | forcedCode: '123' 34 | } 35 | }; 36 | 37 | const result = await this.authenticator.initiate(input, tenantData); 38 | expect(result.totpUri).contains('otpauth://totp/Jincor:test@test.com?secret='); 39 | storageMock.verify( 40 | x => x.set(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny()), 41 | TypeMoq.Times.once() 42 | ); 43 | }); 44 | 45 | it('should initiate verification - also generate secret for consumer if old secret is not verified', async() => { 46 | const secret = { 47 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 48 | verified: false 49 | }; 50 | 51 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 52 | storageMock.setup(x => x.get(TypeMoq.It.isAny(), TypeMoq.It.isAny())) 53 | .returns(async(): Promise => secret); 54 | 55 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 56 | 57 | const input = { 58 | consumer: 'test@test.com', 59 | issuer: 'Jincor', 60 | policy: { 61 | expiredOn: '01:00:00', 62 | forcedCode: '123' 63 | } 64 | }; 65 | 66 | const result = await this.authenticator.initiate(input, tenantData); 67 | expect(result.totpUri).contains('otpauth://totp/Jincor:test@test.com?secret='); 68 | storageMock.verify(x => x.set(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny()), TypeMoq.Times.once()); 69 | }); 70 | 71 | it('should initiate verification - use existing secret', async() => { 72 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 73 | const secret = { 74 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 75 | verified: true 76 | }; 77 | 78 | storageMock.setup(x => x.get(TypeMoq.It.isAny(), TypeMoq.It.isAny())) 79 | .returns(async(): Promise => secret); 80 | 81 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 82 | 83 | const input = { 84 | consumer: 'test@test.com', 85 | issuer: 'Jincor', 86 | policy: { 87 | expiredOn: '01:00:00', 88 | forcedCode: '123' 89 | } 90 | }; 91 | 92 | const result = await this.authenticator.initiate(input, tenantData); 93 | expect(result).to.not.have.property('totpUri'); 94 | }); 95 | 96 | describe('Validate', () => { 97 | it('should validate verification - correct code', async() => { 98 | const secret = { 99 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 100 | verified: true 101 | }; 102 | const verificationId = 'verificationId'; 103 | const verificationData = { 104 | consumer: 'test@test.com' 105 | }; 106 | 107 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 108 | storageMock.setup(x => x.get(TypeMoq.It.isValue(`test${ verificationId }`), TypeMoq.It.isAny())) 109 | .returns(async(): Promise => verificationData); 110 | 111 | storageMock.setup(x => x.get(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny())) 112 | .returns(async(): Promise => secret); 113 | 114 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 115 | 116 | const input = { 117 | consumer: 'test@test.com', 118 | code: authenticator.generateToken(secret.secret) 119 | }; 120 | 121 | const result = await this.authenticator.validate(verificationId, input, tenantData); 122 | expect(result.isValid).to.eq(true); 123 | }); 124 | 125 | it('should validate verification - correct code, should verify secret if not verified yet', async() => { 126 | const secret = { 127 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 128 | verified: false 129 | }; 130 | 131 | const verificationId = 'verificationId'; 132 | const verificationData = { 133 | consumer: 'test@test.com' 134 | }; 135 | 136 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 137 | storageMock.setup(x => x.get(TypeMoq.It.isValue(`test${ verificationId }`), TypeMoq.It.isAny())) 138 | .returns(async(): Promise => verificationData); 139 | 140 | storageMock.setup(x => x.get(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny())) 141 | .returns(async(): Promise => secret); 142 | 143 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 144 | 145 | const input = { 146 | consumer: 'test@test.com', 147 | code: authenticator.generateToken(secret.secret) 148 | }; 149 | 150 | const result = await this.authenticator.validate(verificationId, input, tenantData); 151 | expect(result.isValid).to.eq(true); 152 | 153 | const expectedSecret = { 154 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 155 | verified: true 156 | }; 157 | 158 | storageMock.verify( 159 | x => x.set(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isValue(expectedSecret)), 160 | TypeMoq.Times.once() 161 | ); 162 | }); 163 | 164 | it('should validate verification - incorrect code', async() => { 165 | const secret = { 166 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 167 | verified: true 168 | }; 169 | const verificationId = 'verificationId'; 170 | const verificationData = { 171 | consumer: 'test@test.com' 172 | }; 173 | 174 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 175 | storageMock.setup(x => x.get(TypeMoq.It.isValue(`test${ verificationId }`), TypeMoq.It.isAny())) 176 | .returns(async(): Promise => verificationData); 177 | 178 | storageMock.setup(x => x.get(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny())) 179 | .returns(async(): Promise => secret); 180 | 181 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 182 | 183 | const input = { 184 | consumer: 'test@test.com', 185 | code: authenticator.generateToken(secret.secret) + 1 186 | }; 187 | 188 | const result = await this.authenticator.validate(verificationId, input, tenantData); 189 | expect(result.isValid).to.eq(false); 190 | }); 191 | 192 | it('should remove secret if param is set', async() => { 193 | const secret = { 194 | secret: '3qlq j5uj gdcj xoqt 6rhu yglx 5mf5 i7ll', 195 | verified: true 196 | }; 197 | const verificationId = 'verificationId'; 198 | const verificationData = { 199 | consumer: 'test@test.com' 200 | }; 201 | 202 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 203 | storageMock.setup(x => x.get(TypeMoq.It.isValue(`test${ verificationId }`), TypeMoq.It.isAny())) 204 | .returns(async(): Promise => verificationData); 205 | 206 | storageMock.setup(x => x.get(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny())) 207 | .returns(async(): Promise => secret); 208 | 209 | storageMock.setup(x => x.remove(TypeMoq.It.isValue('testtenantIdtest@test.com'))) 210 | .returns(async(): Promise => null); 211 | 212 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 213 | 214 | const input = { 215 | consumer: 'test@test.com', 216 | code: authenticator.generateToken(secret.secret), 217 | removeSecret: true 218 | }; 219 | 220 | const result = await this.authenticator.validate(verificationId, input, tenantData); 221 | expect(result.isValid).to.eq(true); 222 | storageMock.verify(x => x.remove(TypeMoq.It.isValue('testtenantIdtest@test.com')), TypeMoq.Times.once()); 223 | }); 224 | 225 | it('should throw when verification is not found', async() => { 226 | const verificationId = 'verificationId'; 227 | 228 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 229 | storageMock.setup(x => x.get(TypeMoq.It.isValue(`test${ verificationId }`), TypeMoq.It.isAny())) 230 | .returns(async(): Promise => null); 231 | 232 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 233 | 234 | const input = { 235 | consumer: 'test@test.com', 236 | code: 123456 237 | }; 238 | 239 | expect(this.authenticator.validate(verificationId, input)).to.be.rejectedWith('Verification is not found'); 240 | }); 241 | 242 | it('should throw when secret is not found', async() => { 243 | const verificationId = 'verificationId'; 244 | const verificationData = { 245 | consumer: 'test@test.com' 246 | }; 247 | 248 | const storageMock = TypeMoq.Mock.ofType(SimpleInMemoryStorageService); 249 | storageMock.setup(x => x.get(TypeMoq.It.isValue(`test${ verificationId }`), TypeMoq.It.isAny())) 250 | .returns(async(): Promise => verificationData); 251 | 252 | storageMock.setup(x => x.get(TypeMoq.It.isValue('testtenantIdtest@test.com'), TypeMoq.It.isAny())) 253 | .returns(async(): Promise => null); 254 | 255 | this.authenticator = new AuthenticatorVerificationService('test', storageMock.object); 256 | 257 | const input = { 258 | consumer: 'test@test.com', 259 | code: 123456 260 | }; 261 | 262 | expect(this.authenticator.validate(verificationId, input, tenantData)).to.be.rejectedWith('User secret is not found'); 263 | }); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /src/services/specs/storages.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | 3 | import { SimpleInMemoryStorageService, RedisStorageService } from '../storages'; 4 | 5 | const {expect} = chai; 6 | 7 | function testStorageImplementation(instance: StorageService) { 8 | it('will set value', async() => { 9 | expect(await instance.set('field', 'value', { ttlInSeconds: 10 })).is.equals('value'); 10 | expect(await instance.get('field', null)).is.equals('value'); 11 | }); 12 | 13 | it('will try to get not existing value and return default', async() => { 14 | expect(await instance.get('noField', 'default')).is.equals('default'); 15 | }); 16 | 17 | it('will remove exising value', async() => { 18 | expect(await instance.set('field', 'value', { ttlInSeconds: 10 })).is.equals('value'); 19 | expect(await instance.remove('field')).is.equals('value'); 20 | }); 21 | } 22 | 23 | describe('Storages Services', () => { 24 | 25 | describe('Test SimpleInMemoryStorageService', () => { 26 | testStorageImplementation(new SimpleInMemoryStorageService()); 27 | }); 28 | 29 | describe('Test RedisStorageService', () => { 30 | class StubRedisStorageService extends RedisStorageService { 31 | protected createRedisClient(): any { 32 | let items = {}; 33 | return { 34 | items: {}, 35 | setex: (keyName, ttl, value, callback: () => void): boolean => { 36 | items[keyName] = value; 37 | callback(); 38 | return true; 39 | }, 40 | get: (keyName, callback: (err, result) => void): boolean => { 41 | callback(null, items[keyName]); 42 | return true; 43 | }, 44 | del: (keyName, callback: (err, result) => void): boolean => { 45 | let value = items[keyName]; 46 | delete items[keyName]; 47 | callback(null, value); 48 | return true; 49 | } 50 | }; 51 | } 52 | } 53 | let instance = new StubRedisStorageService(); 54 | 55 | testStorageImplementation(instance); 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /src/services/specs/verifications.spec.ts: -------------------------------------------------------------------------------- 1 | import { SimpleInMemoryStorageService, StorageServiceType } from '../storages'; 2 | import * as chai from 'chai'; 3 | import { BaseVerificationService } from '../base.verification'; 4 | import { container } from '../../ioc.container'; 5 | import { VerificationServiceFactoryRegister } from '../verifications'; 6 | import { NotFoundException, InvalidParametersException } from '../../exceptions/exceptions'; 7 | import AuthenticatorVerificationService from '../authenticator.verification'; 8 | import EmailVerificationService from '../email.verification'; 9 | 10 | chai.use(require('chai-as-promised')); 11 | 12 | const {expect} = chai; 13 | 14 | function createInitiateParams(params: any = {}) { 15 | return Object.assign({ 16 | consumer: 'test@test.com' 17 | }, params, { 18 | policy: Object.assign({ 19 | expiredOn: '01:00:00', 20 | forcedCode: '123' 21 | }, params.policy) 22 | }); 23 | } 24 | 25 | const tenantData = { 26 | id: 'tenantId', 27 | isTenant: true, 28 | login: 'tenantLogin', 29 | jti: '1234', 30 | iat: 1234, 31 | aud: '123' 32 | }; 33 | 34 | describe('Verifications Services', () => { 35 | container.rebind(StorageServiceType) 36 | .to(SimpleInMemoryStorageService); 37 | 38 | describe('Test VerificationServiceFactoryRegister', () => { 39 | let factory = container.resolve(VerificationServiceFactoryRegister); 40 | 41 | it('will have email method', () => { 42 | expect(factory.hasMethod('email')).is.eq(true); 43 | }); 44 | 45 | it('will have google_auth method', () => { 46 | expect(factory.hasMethod('google_auth')).is.eq(true); 47 | }); 48 | 49 | it('won\'t have unknown method', () => { 50 | expect(factory.hasMethod('unknown')).is.eq(false); 51 | }); 52 | 53 | it('will create email verification service', () => { 54 | expect(factory.create('email')).is.instanceOf(EmailVerificationService); 55 | }); 56 | 57 | it('will create google_auth verification service', () => { 58 | expect(factory.create('google_auth')).is.instanceOf(AuthenticatorVerificationService); 59 | }); 60 | 61 | it('will throw when trying to create unsupported method', () => { 62 | expect(() => factory.create('random')).to.throw('random not supported'); 63 | }); 64 | }); 65 | 66 | describe('Test BaseVerificationService', () => { 67 | class StubBaseVerificationService extends BaseVerificationService { } 68 | let storage = new SimpleInMemoryStorageService(); 69 | let instance = new StubBaseVerificationService('stub', storage); 70 | 71 | it('will initiate a verification process', async() => { 72 | let result = await instance.initiate(createInitiateParams(), tenantData); 73 | 74 | expect(result).to.have.property('verificationId'); 75 | expect(result.code).is.equals('123'); 76 | expect(result.expiredOn).is.greaterThan(~~((+new Date()) / 1000)); 77 | }); 78 | 79 | it('will fail initiation of verification process if expiredOn is invalid', async() => { 80 | const params = createInitiateParams({ 81 | policy: { expiredOn: null } 82 | }); 83 | 84 | expect(instance.initiate(params, tenantData)).to.be.rejectedWith(InvalidParametersException); 85 | }); 86 | 87 | it('will initiate of verification process if forcedVerificationId is set up', async() => { 88 | const forcedId = '886aa661-1fdb-4f29-b8e1-85ab6fc48912'; 89 | const params = createInitiateParams({ 90 | policy: { forcedVerificationId: forcedId } 91 | }); 92 | 93 | expect((await instance.initiate(params, tenantData)).verificationId).is.equals(forcedId); 94 | }); 95 | 96 | it('will initiate of verification process and return a new generated digital code', async() => { 97 | const params = { 98 | consumer: 'test@test.com', 99 | generateCode: { 100 | length: 16, 101 | symbolSet: ['DIGITS'] 102 | }, 103 | policy: { 104 | expiredOn: '01:00:00' 105 | } 106 | }; 107 | 108 | const code = (await instance.initiate(params, tenantData)).code; 109 | expect(/^[\d]{16}$/.test(code)).is.eq(true); 110 | }); 111 | 112 | it('will fail validation if verificationId doesn\'t existing', async() => { 113 | expect(instance.validate('zzz', {code: '123'}, tenantData)).to.be.rejectedWith(NotFoundException); 114 | }); 115 | 116 | it('will fail validation if code is incorrect', async() => { 117 | const forcedId = '886aa661-1fdb-4f29-b8e1-85ab6fc48932'; 118 | const initiateParams = createInitiateParams({ 119 | policy: { forcedVerificationId: forcedId } 120 | }); 121 | 122 | const verificationId = (await instance.initiate(initiateParams, tenantData)).verificationId; 123 | expect(verificationId).is.equals(forcedId); 124 | const isValid = (await instance.validate(forcedId, {code: '321'}, tenantData)).isValid; 125 | expect(isValid).is.eq(false); 126 | }); 127 | 128 | it('will successfully validate if all is correct', async() => { 129 | const forcedId = '886aa661-1fdb-4f29-b8e1-85ab6fc48932'; 130 | const initiateParams = createInitiateParams({ 131 | policy: { forcedVerificationId: forcedId } 132 | }); 133 | 134 | expect((await instance.initiate(initiateParams, tenantData)).verificationId).is.equals(forcedId); 135 | 136 | const isValid = (await instance.validate(forcedId, {code: '123'}, tenantData)).isValid; 137 | expect(isValid).is.eq(true); 138 | }); 139 | 140 | }); 141 | 142 | describe('Test EmailVerificationService', () => { 143 | let factory = container.resolve(VerificationServiceFactoryRegister); 144 | let instance: VerificationService = factory.create('email'); 145 | 146 | it('will initiate and send an email', async() => { 147 | const forcedId = '886aa661-1fdb-4f29-b8e1-85ab6fc48932'; 148 | const initiateParams = createInitiateParams({ 149 | consumer: 'test@test.com', 150 | template: { 151 | 'body': '[{{{CODE}}}]' 152 | }, 153 | policy: { forcedVerificationId: forcedId } 154 | }); 155 | 156 | expect(await instance.initiate(initiateParams, tenantData)).is.property('verificationId').equals(forcedId); 157 | }); 158 | 159 | }); 160 | 161 | }); 162 | -------------------------------------------------------------------------------- /src/services/storages.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import * as redis from 'redis'; 3 | import { RedisClient } from 'redis'; 4 | import 'reflect-metadata'; 5 | 6 | import config from '../config'; 7 | 8 | // Exceptions 9 | 10 | export class StorageException extends Error { } 11 | 12 | // Types 13 | 14 | export const StorageServiceType = Symbol('StorageServiceType'); 15 | 16 | /** 17 | * Very simple memory storage implementation. 18 | */ 19 | @injectable() 20 | export class SimpleInMemoryStorageService implements StorageService { 21 | 22 | private storage: { 23 | [key: string]: { 24 | ttl?: number; 25 | data: any; 26 | }; 27 | } = {}; 28 | private gcMinimalTimeInterval: number = 10; 29 | private lastGcTime: number = this.getNextGcTime(); 30 | 31 | /** 32 | * @inheritdoc 33 | */ 34 | public set(name: string, value: T, options?: ValueOptions): Promise { 35 | this.gc(); 36 | if (options) { 37 | this.storage[name] = { 38 | ttl: options.ttlInSeconds * 1000 + +new Date(), 39 | data: JSON.stringify(value) 40 | }; 41 | } else { 42 | this.storage[name] = { 43 | data: JSON.stringify(value) 44 | }; 45 | } 46 | return Promise.resolve(value); 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public remove(name: string): Promise { 53 | const value = typeof this.storage[name] === 'undefined' ? null : JSON.parse(this.storage[name].data); 54 | delete this.storage[name]; 55 | return Promise.resolve(value); 56 | } 57 | 58 | /** 59 | * @inheritdoc 60 | */ 61 | public get(name: string, defaultValue: T): Promise { 62 | this.gc(); 63 | return Promise.resolve(typeof this.storage[name] === 'undefined' ? defaultValue : JSON.parse(this.storage[name].data)); 64 | } 65 | 66 | /** 67 | * Execute simple garbage collection 68 | */ 69 | public gc() { 70 | /* istanbul ignore if */ 71 | if (this.lastGcTime < +new Date()) { 72 | Object.keys(this.storage).forEach((key) => { 73 | if (this.storage[key].ttl && this.storage[key].ttl < +new Date()) { 74 | delete this.storage[key]; 75 | } 76 | }); 77 | this.lastGcTime = this.getNextGcTime(); 78 | } 79 | } 80 | 81 | private getNextGcTime(): number { 82 | return this.gcMinimalTimeInterval * 1000 + (+new Date()); 83 | } 84 | 85 | } 86 | 87 | const redisConfig = config.redis; 88 | 89 | /** 90 | * Redis storage implementation. 91 | */ 92 | @injectable() 93 | export class RedisStorageService implements StorageService { 94 | 95 | private client: RedisClient; 96 | 97 | constructor() { 98 | this.client = this.createRedisClient(); 99 | } 100 | 101 | protected createRedisClient(): RedisClient { 102 | return redis.createClient( 103 | redisConfig.url 104 | ); 105 | } 106 | 107 | /** 108 | * @inheritdoc 109 | */ 110 | public set(name: string, value: T, options?: ValueOptions): Promise { 111 | if (options) { 112 | return new Promise((resolve, reject) => { 113 | this.client.setex(this.getKey(name), 114 | options.ttlInSeconds, 115 | JSON.stringify(value), 116 | (err: any, result) => { 117 | if (err) { 118 | return reject(new StorageException(err)); 119 | } 120 | resolve(value); 121 | } 122 | ); 123 | }); 124 | } else { 125 | return new Promise((resolve, reject) => { 126 | this.client.set(this.getKey(name), 127 | JSON.stringify(value), 128 | (err: any, result) => { 129 | if (err) { 130 | return reject(new StorageException(err)); 131 | } 132 | resolve(value); 133 | } 134 | ); 135 | }); 136 | } 137 | } 138 | 139 | /** 140 | * @inheritdoc 141 | */ 142 | public remove(name: string): Promise { 143 | return new Promise((resolve, reject) => { 144 | this.client.del(this.getKey(name), 145 | (err: any, result) => { 146 | if (err) { 147 | return reject(new StorageException(err)); 148 | } 149 | try { 150 | resolve(JSON.parse(result)); 151 | } catch (error) { 152 | reject(new StorageException(error)); 153 | } 154 | } 155 | ); 156 | }); 157 | } 158 | 159 | /** 160 | * @inheritdoc 161 | */ 162 | public get(name: string, defaultValue: T): Promise { 163 | return new Promise((resolve, reject) => { 164 | this.client.get(this.getKey(name), 165 | (err: any, result) => { 166 | if (err) { 167 | return reject(new StorageException(err)); 168 | } 169 | try { 170 | if (!result) { 171 | return resolve(defaultValue); 172 | } 173 | resolve(JSON.parse(result)); 174 | } catch (error) { 175 | reject(new StorageException(error)); 176 | } 177 | } 178 | ); 179 | }); 180 | } 181 | 182 | private getKey(key: string): string { 183 | return redisConfig.prefix + key; 184 | } 185 | 186 | } 187 | -------------------------------------------------------------------------------- /src/services/verifications.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'inversify'; 2 | import 'reflect-metadata'; 3 | 4 | import AuthenticatorVerificationService from './authenticator.verification'; 5 | import EmailVerificationService from './email.verification'; 6 | import { StorageServiceType } from './storages'; 7 | import { EmailProviderService, EmailProviderServiceType } from './providers/index'; 8 | import { InvalidParametersException } from '../exceptions/exceptions'; 9 | 10 | export const VerificationServiceFactoryType = Symbol('VerificationServiceFactoryType'); 11 | 12 | /** 13 | * VerificationServiceFactory interface. 14 | */ 15 | export interface VerificationServiceFactory { 16 | create(method: string): VerificationService; 17 | hasMethod(method: string): boolean; 18 | } 19 | 20 | const EMAIL_VERIFICATION_METHOD = 'email'; 21 | const AUTHENTICATOR_VERIFICATION_METHOD = 'google_auth'; 22 | 23 | /** 24 | * VerificationServiceFactory implementation. 25 | */ 26 | @injectable() 27 | export class VerificationServiceFactoryRegister implements VerificationServiceFactory { 28 | constructor( 29 | @inject(StorageServiceType) private storageService: StorageService, 30 | @inject(EmailProviderServiceType) private emailProviderService: EmailProviderService 31 | ) { } 32 | 33 | /** 34 | * Create concrete verificator service 35 | * 36 | * @param method 37 | */ 38 | create(method: string): VerificationService { 39 | switch (method) { 40 | case EMAIL_VERIFICATION_METHOD: 41 | return new EmailVerificationService(method, this.storageService, this.emailProviderService); 42 | case AUTHENTICATOR_VERIFICATION_METHOD: 43 | return new AuthenticatorVerificationService(method, this.storageService); 44 | default: 45 | throw new InvalidParametersException(`${method} not supported`); 46 | } 47 | } 48 | 49 | hasMethod(method: string): boolean { 50 | const allowedMethods = [ 51 | EMAIL_VERIFICATION_METHOD, 52 | AUTHENTICATOR_VERIFICATION_METHOD 53 | ]; 54 | 55 | return allowedMethods.indexOf(method) !== -1; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "buildOnSave": false, 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "target": "es6", 7 | "noImplicitAny": false, 8 | "sourceMap": false, 9 | "lib": ["es2015", "dom"], 10 | "outDir": "dist", 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true 13 | }, 14 | "exclude": [ 15 | "node_modules", 16 | "**/*.spec.ts", 17 | "dist" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "buildOnSave": false, 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "target": "es6", 7 | "noImplicitAny": false, 8 | "sourceMap": false, 9 | "lib": ["es2015", "dom"], 10 | "outDir": "dist", 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true 13 | }, 14 | "exclude": [ 15 | "node_modules", 16 | "dist" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-standard"], 3 | "rules": { 4 | "no-unused-variable": true, 5 | "quotemark": [true, "single", "jsx-double"], 6 | "semicolon": [true, "always"], 7 | "space-before-function-paren": [true, "never"], 8 | "member-ordering": [false], 9 | "handle-callback-err": [false] 10 | } 11 | } 12 | --------------------------------------------------------------------------------