├── src ├── providers │ ├── github.js │ ├── linkedin.js │ ├── twitter.js │ ├── facebook.js │ └── google.js ├── utils │ ├── env.js │ ├── passwords.js │ ├── jwt.js │ └── crypto.js ├── database │ ├── adapter.js │ ├── mongodb.js │ └── knex.js ├── constants.js ├── recaptcha.js ├── locales │ └── en.json ├── sms │ └── twilio.js ├── validations.js ├── services │ └── users.js ├── starter.js └── index.js ├── templates └── en │ ├── welcome-subject.ejs │ ├── password_reset-subject.ejs │ ├── password_reset_help-subject.ejs │ ├── welcome-body-html.ejs │ ├── password_reset-body-html.ejs │ └── password_reset_help-body-html.ejs ├── views ├── include-recaptcha.html ├── include-alert.html ├── include-head-recaptcha.html ├── include-head.html ├── include-providers.html ├── done.html ├── twofactorcodes.html ├── twofactor.html ├── include-javascript.html ├── twofactorconfigure.html └── index.html ├── test ├── passwords.js ├── _fetch.js ├── jwt.js ├── server.js ├── database.js ├── validations.js └── e2e.js ├── .gitignore ├── Dockerfile ├── Makefile ├── package.json ├── static ├── stylesheet.css └── jquery.cropit.js └── README.md /src/providers/github.js: -------------------------------------------------------------------------------- 1 | module.exports = (router, redirect, env) => false 2 | -------------------------------------------------------------------------------- /src/providers/linkedin.js: -------------------------------------------------------------------------------- 1 | module.exports = (router, redirectUrl, env) => false 2 | -------------------------------------------------------------------------------- /templates/en/welcome-subject.ejs: -------------------------------------------------------------------------------- 1 | Welcome to <%= projectName %>, <%- name %> 2 | -------------------------------------------------------------------------------- /templates/en/password_reset-subject.ejs: -------------------------------------------------------------------------------- 1 | Set up a new password for <%= projectName %> 2 | -------------------------------------------------------------------------------- /templates/en/password_reset_help-subject.ejs: -------------------------------------------------------------------------------- 1 | Set up a new password for <%= projectName %> 2 | -------------------------------------------------------------------------------- /views/include-recaptcha.html: -------------------------------------------------------------------------------- 1 | <% if (recaptchaSiteKey) { %> 2 |
6 | <% } %> 7 | -------------------------------------------------------------------------------- /views/include-alert.html: -------------------------------------------------------------------------------- 1 | <% if (error) { %> 2 |
3 |

<%= error === __(error) ? __('UNKNOWN_ERROR') : __(error) %>

4 |
5 | <% } %> 6 | 7 | <% if (info && info !== __(info)) { %> 8 |
9 |

<%= __(info) %>

10 |
11 | <% } %> 12 | -------------------------------------------------------------------------------- /src/utils/env.js: -------------------------------------------------------------------------------- 1 | const prefix = 'AUTH_' 2 | const defaults = { 3 | JWT_ALGORITHM: 'HS256' 4 | } 5 | 6 | module.exports = (env = {}) => (key, defaultValue) => { 7 | return [ 8 | env[prefix + key], 9 | env[key], 10 | process.env[prefix + key], 11 | process.env[key], 12 | defaultValue, 13 | defaults[key] 14 | ].find(value => value != null) 15 | } 16 | -------------------------------------------------------------------------------- /src/providers/twitter.js: -------------------------------------------------------------------------------- 1 | module.exports = (router, redirectUrl, env) => { 2 | const twitterClientId = env('TWITTER_CLIENT_ID') 3 | const twitterClientSecret = env('TWITTER_CLIENT_SECRET') 4 | if (!twitterClientId || !twitterClientSecret) return false 5 | 6 | router.get('/provider/twitter', (req, res) => { 7 | res.json({ twitter: true }) 8 | }) 9 | return true 10 | } 11 | -------------------------------------------------------------------------------- /views/include-head-recaptcha.html: -------------------------------------------------------------------------------- 1 | <% if (recaptchaSiteKey) { %> 2 | 3 | 11 | <% } %> 12 | -------------------------------------------------------------------------------- /src/utils/passwords.js: -------------------------------------------------------------------------------- 1 | const scrypt = require('scrypt') 2 | const scryptParameters = scrypt.paramsSync(0.1) 3 | 4 | exports.hash = (email, pass) => { 5 | pass = email + '#' + pass 6 | return scrypt.kdf(pass, scryptParameters) 7 | .then((result) => result.toString('base64')) 8 | } 9 | 10 | exports.check = (email, pass, hash) => { 11 | pass = email + '#' + pass 12 | return scrypt.verifyKdf(Buffer.from(hash, 'base64'), pass) 13 | } 14 | -------------------------------------------------------------------------------- /test/passwords.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const passwords = require('../src/utils/passwords') 3 | 4 | test('success hash and check', t => { 5 | return passwords.hash('user@example.com', '1234') 6 | .then(hash => passwords.check('user@example.com', '1234', hash)) 7 | .then(ok => t.true(ok)) 8 | }) 9 | 10 | test('failed hash and check', t => { 11 | return passwords.hash('user@example.com', '1234') 12 | .then(hash => passwords.check('user@example.com', '4321', hash)) 13 | .then(ok => t.false(ok)) 14 | }) 15 | -------------------------------------------------------------------------------- /test/_fetch.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | const querystring = require('querystring') 3 | const baseURL = `http://127.0.0.1:${process.env.MICROSERVICE_PORT || 3000}` 4 | 5 | module.exports = (path, options) => { 6 | const body = options && options.body 7 | if (Object.prototype.toString.call(body) === '[object Object]') { 8 | options.body = querystring.stringify(body) 9 | options.headers = Object.assign({}, options.headers, { 10 | 'Content-Type': 'application/x-www-form-urlencoded' 11 | }) 12 | } 13 | return fetch(baseURL + path, options) 14 | } 15 | -------------------------------------------------------------------------------- /src/database/adapter.js: -------------------------------------------------------------------------------- 1 | module.exports = env => { 2 | // If we don't have a DATABASE_ENGINE, default to postgres (as we had before) 3 | const databaseEngine = env('DATABASE_ENGINE', 'pg') 4 | // If we have a relational database, just grab the knex adapter 5 | if (['mysql', 'pg'].indexOf(databaseEngine) !== -1) { 6 | return require('./knex')(env) 7 | // Otherwise, grab the specific database adapter 8 | } else if (databaseEngine === 'mongodb') { 9 | return require('./mongodb')(env) 10 | } else { 11 | throw new Error(`Unknown database engine ${databaseEngine}`) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /views/include-head.html: -------------------------------------------------------------------------------- 1 | 2 | <%= projectName %> | <%= title %> 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | .env 39 | -------------------------------------------------------------------------------- /test/jwt.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const env = require('../src/utils/env')({ 3 | JWT_ALGORITHM: 'HS256', 4 | JWT_SECRET: 'shhhh' 5 | }) 6 | const jwt = require('../src/utils/jwt')(env) 7 | 8 | test('sign and verify', t => { 9 | return jwt.sign({ foo: 'bar' }) 10 | .then(response => jwt.verify(response)) 11 | .then(data => { 12 | t.is(data.foo, 'bar') 13 | }) 14 | }) 15 | 16 | test('expired sign and verify', t => { 17 | return jwt.sign({ foo: 'bar' }, { expiresIn: '100' }) 18 | .then(data => { 19 | return new Promise((resolve, reject) => { 20 | setTimeout(() => { 21 | jwt.verify(data).then(resolve).catch(reject) 22 | }, 200) 23 | }) 24 | }) 25 | .then(data => { 26 | throw new Error('Should have failed') 27 | }) 28 | .catch(err => { 29 | t.truthy(err.message.indexOf('jwt expired') >= 0) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:6.9.4-alpine 2 | MAINTAINER Clevertech DevOps 3 | 4 | # Update OS 5 | RUN apk --no-cache add ca-certificates wget && update-ca-certificates 6 | 7 | # Install yarn 8 | RUN mkdir -p /opt/yarn && cd /opt/yarn && wget https://yarnpkg.com/latest.tar.gz && tar zxf latest.tar.gz 9 | ENV PATH "$PATH:/opt/yarn/dist/bin" 10 | 11 | EXPOSE 3000 12 | CMD ["node", "src/index.js"] 13 | 14 | # Create the working dir 15 | RUN mkdir -p /opt/app && mkdir /cache 16 | WORKDIR /opt/app 17 | 18 | # Do not use cache when we change node dependencies in package.json 19 | ADD package.json yarn.lock /cache/ 20 | 21 | # Copy cache contents (if any) from local machine 22 | ADD .yarn-cache.tgz / 23 | 24 | # Install packages + Prepare cache file 25 | RUN cd /cache \ 26 | && yarn config set cache-folder /usr/local/share/.cache/yarn \ 27 | && yarn \ 28 | && cd /opt/app && ln -s /cache/node_modules node_modules \ 29 | && tar czf /.yarn-cache.tgz /usr/local/share/.cache/yarn 30 | 31 | COPY . /opt/app 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NS = 412241670359.dkr.ecr.us-east-1.amazonaws.com/clevertech 2 | VERSION ?= latest 3 | 4 | REPO = boilerplate-api 5 | NAME = boilerplate-api 6 | INSTANCE = default 7 | PORTS = -p 3000:3000 8 | ENV = \ 9 | -e NODE_ENV=development 10 | 11 | .PHONY: build push shell test run start stop rm release 12 | 13 | build: 14 | docker build -t $(NS)/$(REPO):$(VERSION) . 15 | 16 | push: 17 | docker push $(NS)/$(REPO):$(VERSION) 18 | 19 | shell: 20 | docker run --rm --name $(NAME)-$(INSTANCE) -i -t $(PORTS) $(VOLUMES) $(ENV) $(NS)/$(REPO):$(VERSION) /bin/bash 21 | 22 | test: 23 | docker run --rm $(NS)/$(REPO):$(VERSION) yarn test 24 | 25 | run: 26 | docker run --rm --name $(NAME)-$(INSTANCE) $(PORTS) $(VOLUMES) $(ENV) $(NS)/$(REPO):$(VERSION) 27 | 28 | start: 29 | docker run -d --name $(NAME)-$(INSTANCE) $(PORTS) $(VOLUMES) $(ENV) $(NS)/$(REPO):$(VERSION) 30 | 31 | stop: 32 | docker stop $(NAME)-$(INSTANCE) 33 | 34 | rm: 35 | docker rm $(NAME)-$(INSTANCE) 36 | 37 | release: build 38 | make push -e VERSION=$(VERSION) 39 | 40 | default: build 41 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const fetch = require('./_fetch') 3 | 4 | test('root path', t => { 5 | return fetch('/auth') 6 | .then(response => { 7 | t.is(response.status, 200) 8 | return response.text() 9 | }) 10 | .then(body => { 11 | t.truthy(body.indexOf('= 0) 12 | }) 13 | }) 14 | 15 | test('healthz', t => { 16 | return fetch('/healthz') 17 | .then(response => response.json()) 18 | .then(json => { 19 | t.deepEqual(json, {'status': 'OK'}) 20 | }) 21 | }) 22 | 23 | test('get /', t => { 24 | return fetch('/', {redirect: 'manual'}) 25 | .then(response => { 26 | t.is(response.status, 302) 27 | t.not(response.headers._headers.location[0].indexOf('signin'), -1) 28 | }) 29 | }) 30 | 31 | test('get /signin', t => { 32 | return fetch('/signin') 33 | .then(response => { 34 | return response.text() 35 | }).then(function (body) { 36 | // Test that we're on the Sign In page 37 | t.not(body.indexOf('Sign In'), -1) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /views/include-providers.html: -------------------------------------------------------------------------------- 1 | <% if (someProvidersAvailable) { %>

or

<% } %> 2 | <% if (availableProviders.google) { %> 3 | Connect with Google 4 | <% } %> 5 | <% if (availableProviders.facebook) { %> 6 | 7 | <% } %> 8 | <% if (availableProviders.github) { %> 9 | Connect with GitHub 10 | <% } %> 11 | <% if (availableProviders.linkedin) { %> 12 | Connect with LinkedIn 13 | <% } %> 14 | <% if (availableProviders.twitter) { %> 15 | 16 | <% } %> 17 | -------------------------------------------------------------------------------- /src/utils/jwt.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | 3 | module.exports = env => { 4 | const algorithm = env('JWT_ALGORITHM') 5 | const isHMAC = algorithm.substring(0, 1) === 'H' 6 | const secretOrPrivateKey = isHMAC ? env('JWT_SECRET') : env('JWT_PRIVATE_KEY') 7 | const secretOrPublicKey = isHMAC ? env('JWT_SECRET') : env('JWT_PUBLIC_KEY') 8 | const expiresIn = env('JWT_EXPIRES_IN') 9 | const notBefore = env('JWT_NOT_BEFORE') 10 | 11 | return { 12 | sign (payload, options) { 13 | const opts = Object.assign({ algorithm, expiresIn, notBefore }, options) 14 | return new Promise((resolve, reject) => { 15 | jwt.sign(payload, secretOrPrivateKey, opts, (err, token) => { 16 | err ? reject(err) : resolve(token) 17 | }) 18 | }) 19 | }, 20 | 21 | verify (token, options) { 22 | const opts = Object.assign({ algorithm }, options) 23 | return new Promise((resolve, reject) => { 24 | jwt.verify(token, secretOrPublicKey, opts, (err, decoded) => { 25 | err ? reject(err) : resolve(decoded) 26 | }) 27 | }) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | exports.availableFields = { 2 | name: { 3 | type: 'text', 4 | icon: 'user', 5 | description: 'Full name' 6 | }, 7 | firstName: { 8 | type: 'text', 9 | icon: 'user', 10 | description: 'First name' 11 | }, 12 | lastName: { 13 | type: 'text', 14 | icon: 'user', 15 | description: 'Last name' 16 | }, 17 | company: { 18 | type: 'text', 19 | icon: 'building', 20 | description: 'Company name' 21 | }, 22 | address: { 23 | type: 'text', 24 | icon: 'map', 25 | description: 'Address' 26 | }, 27 | city: { 28 | type: 'text', 29 | icon: 'map', 30 | description: 'City' 31 | }, 32 | state: { 33 | type: 'text', 34 | icon: 'map', 35 | description: 'State' 36 | }, 37 | zip: { 38 | type: 'text', 39 | icon: 'map', 40 | description: 'Zip code' 41 | }, 42 | country: { 43 | type: 'text', 44 | icon: 'map', 45 | description: 'Country' 46 | }, 47 | username: { 48 | type: 'text', 49 | icon: 'user', 50 | unique: true 51 | }, 52 | image: { 53 | type: 'image', 54 | description: 'User image' 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /views/done.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('./include-head.html', { projectName, title }) %> 5 | 6 | 7 |
8 |
9 |
10 |
11 | <% if (error) { %> 12 |

13 | <% } %> 14 | <% if (info && info !== __(info)) { %> 15 |

16 | <% } %> 17 |
18 |
19 | <% if (error) { %> 20 |

<%= error === __(error) ? __('UNKNOWN_ERROR') : __(error) %>

21 | <% } %> 22 | <% if (info && info !== __(info)) { %> 23 |

<%= __(info) %>

24 | <% } %> 25 | Continue to the website 26 |
27 |
28 |
29 |
30 | <%- include('./include-javascript.html', { forms }) %> 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/recaptcha.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring') 2 | 3 | module.exports = (env, fetch) => { 4 | const siteKey = env('RECAPTCHA_SITE_KEY') 5 | const secret = env('RECAPTCHA_SECRET_KEY') 6 | const enabled = siteKey && secret 7 | 8 | return { 9 | siteKey () { 10 | return enabled && siteKey 11 | }, 12 | middleware () { 13 | return (req, res, next) => { 14 | if (!enabled || req.method === 'GET') return next() 15 | const response = req.body['g-recaptcha-response'] 16 | const remoteip = req.ip 17 | const url = 'https://www.google.com/recaptcha/api/siteverify' 18 | const body = { secret, response, remoteip } 19 | fetch(url, { 20 | method: 'POST', 21 | body: querystring.stringify(body), 22 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' } 23 | }) 24 | .then(res => res.json()) 25 | .then(json => { 26 | // Other attributes 27 | // challenge_ts: '2017-04-07T16:22:44Z' 28 | // hostname: 'localhost' 29 | if (json.success !== true) { 30 | const err = new Error('RECAPTCHA_VALIDATION_FAILED') 31 | err.handled = true 32 | return next(err) 33 | } 34 | next() 35 | }) 36 | .catch(err => next(err)) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /views/twofactorcodes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('./include-head.html', { projectName, title }) %> 5 | <%- include('./include-head-recaptcha.html', { recaptchaSiteKey }) %> 6 | 7 | 8 | <%- include('./include-recaptcha.html', { recaptchaSiteKey }) %> 9 | <%- include('./include-alert.html', { error, info }) %> 10 |
11 |
12 |

13 |
14 | These are your recovery codes. 15 |
16 |
17 | Keep them secret, keep them safe. 18 |
19 |

20 |
21 |
22 | <% for (const code of codes) { %> 23 |
<%= code.decrypted.toUpperCase() %>
24 | <% } %> 25 |
26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | 34 | <%- include('./include-javascript.html', { forms }) %> 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "INVALID_CREDENTIALS": "Invalid email or password", 3 | "USER_ALREADY_EXISTS": "A user with that email address already exists", 4 | "USER_NOT_FOUND": "User not found", 5 | "EMAIL_CONFIRMATION_TOKEN_NOT_FOUND": "Email confirmation token expired or not found", 6 | "RESET_LINK_SENT": "We have sent you an email with reset instructions. Check your inbox", 7 | "PASSWORD_RESET": "Your password has been reset. Please sign in now", 8 | "PASSWORD_REQUIRED": "Password cannot be blank", 9 | "EMAIL_CONFIRMATION_REQUIRED": "You need to confirm your email first. Please, check your inbox", 10 | "EMAIL_CONFIRMATION_SENT": "Before signing in, please confirm your email address. Check your inbox and follow the instructions", 11 | "INTERNAL_ERROR": "Internal error", 12 | "UNKNOWN_ERROR": "Unknown error", 13 | "EMAIL_CONFIRMED": "Thank you! Your email address is now confirmed. Please sign in", 14 | "FORM_VALIDATION_FAILED": "The form contains errors", 15 | "RECAPTCHA_VALIDATION_FAILED": "Recaptcha validation failed", 16 | "INVALID_AUTHENTICATION_CODE": "Invalid authentication code", 17 | "TWO_FACTOR_AUTHENTICATION_CONFIGURATION_SUCCESS": "Two factor authentication was configured successfully", 18 | "TWO_FACTOR_AUTHENTICATION_DISABLED": "Two factor authentication was disabled successfully", 19 | "PASSWORD_CHANGED_SUCCESSFULLY": "Your password has been changed successfully" 20 | } 21 | -------------------------------------------------------------------------------- /src/sms/twilio.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring') 2 | 3 | module.exports = (env, fetch) => { 4 | const accountSid = env('TWILIO_ACCOUNT_SID') 5 | const authToken = env('TWILIO_AUTH_TOKEN') 6 | const numberFrom = env('TWILIO_NUMBER_FROM') 7 | 8 | if (!accountSid || !authToken || !numberFrom) return null 9 | 10 | // See https://www.twilio.com/docs/api/rest/sending-messages 11 | return { 12 | send (numberTo, text) { 13 | const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` 14 | const body = { 15 | To: numberTo, 16 | From: numberFrom, 17 | Body: text 18 | } 19 | return fetch(url, { 20 | method: 'POST', 21 | body: querystring.stringify(body), 22 | headers: { 23 | 'Content-Type': 'application/x-www-form-urlencoded', 24 | 'Authorization': 'Basic ' + new Buffer(accountSid + ':' + authToken).toString('base64') 25 | } 26 | }) 27 | .then(res => { 28 | return res.json() 29 | .then(json => { 30 | if (res.status >= 400) { 31 | return Promise.reject(new Error(`Twilio returned ${res.status}. ${json.code} ${json.message} ${json.more_info}`)) 32 | } 33 | }) 34 | .catch(err => { 35 | return Promise.reject(new Error(`Twilio returned ${res.status}. ${err.message || String(err)}`)) 36 | }) 37 | }) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /views/twofactor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('./include-head.html', { projectName, title }) %> 5 | <%- include('./include-head-recaptcha.html', { recaptchaSiteKey }) %> 6 | 7 | 8 | <%- include('./include-recaptcha.html', { recaptchaSiteKey }) %> 9 | <%- include('./include-alert.html', { error, info }) %> 10 |
11 |
12 |

13 |
14 | Enter an authentication code 15 |
16 |

17 |
18 | 19 |
20 | <% if (user.twofactor === 'qr') { %> 21 |

Use your app to enter a valid authentication code.

22 | <% } else if (user.twofactor === 'sms') { %> 23 |

We have sent an SMS to: <%= obfuscatePhone(user.twofactorPhone) %>
24 | <% } %> 25 |

26 |
27 | 28 | 29 |
30 |
31 | 32 |
33 |
34 |
35 | 36 | <%- include('./include-javascript.html', { forms }) %> 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/utils/crypto.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const _ = require('lodash') 3 | 4 | const separator = '.' 5 | const encoding = 'hex' 6 | 7 | module.exports = env => { 8 | const key = env('SYMMETRIC_KEY') 9 | const algorithm = env('SYMMETRIC_ALGORITHM', 'aes-256-gcm') 10 | return { 11 | encrypt (text) { 12 | return Promise.resolve() 13 | .then(() => { 14 | const iv = crypto.randomBytes(16) 15 | const cipher = crypto.createCipheriv(algorithm, key, iv) 16 | let encrypted = cipher.update(text, 'utf8', encoding) 17 | encrypted += cipher.final(encoding) 18 | const tag = cipher.getAuthTag() 19 | return [encrypted, tag.toString(encoding), iv.toString(encoding)].join(separator) 20 | }) 21 | }, 22 | decrypt (encrypted) { 23 | return Promise.resolve() 24 | .then(() => { 25 | const [content, tag, iv] = encrypted.split(separator) 26 | const decipher = crypto.createDecipheriv(algorithm, key, new Buffer(iv, encoding)) 27 | decipher.setAuthTag(new Buffer(tag, encoding)) 28 | let dec = decipher.update(content, encoding, 'utf8') 29 | dec += decipher.final('utf8') 30 | return dec 31 | }) 32 | }, 33 | decryptRecovery (recoveryCodes) { 34 | return Promise.all(_.map(recoveryCodes, (encrypted) => { 35 | return this.decrypt(encrypted.code) 36 | .then((decrypted) => { 37 | encrypted.decrypted = decrypted 38 | return encrypted 39 | }) 40 | })) 41 | } 42 | } 43 | } 44 | 45 | // const crypt = module.exports(key => key === 'SYMMETRIC_KEY' ? '3zTvzr3p67VC61jmV54rIYu1545x4TlY' : 'aes-256-gcm') 46 | // crypt.encrypt('hello world') 47 | // .then(encrypted => crypt.decrypt(encrypted)) 48 | // .then(plainText => console.log(plainText)) 49 | // .catch(err => console.error(err.stack)) 50 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pnp-authentication-service", 3 | "version": "0.1.18", 4 | "description": "Clevertech's micro service for authentication", 5 | "author": "Clevertech ", 6 | "main": "src/index.js", 7 | "scripts": { 8 | "start": "better-npm-run start", 9 | "start-dev": "better-npm-run start-dev", 10 | "test": "DATABASE_URL=postgresql://localhost/pnp PORT=3001 ava --verbose --fail-fast", 11 | "test:watch": "DATABASE_URL=postgresql://localhost/pnp PORT=3001 ava --verbose --fail-fast --watch", 12 | "test:dev": "NODE_ENV=test DATABASE_URL=postgresql://authtest:authtest@localhost/auth AUTH_SIGNUP_FIELDS=firstName,lastName,username AUTH_REDIRECT_URL=/auth/landing AUTH_BASE_URL=http://localhost:3000/auth SYMMETRIC_KEY=ee3b03dd1808d4172ee98ae6557c673c PORT=3001 ava --verbose", 13 | "lint": "standard" 14 | }, 15 | "betterScripts": { 16 | "start": "node src/index.js", 17 | "start-dev": "nodemon src/index.js" 18 | }, 19 | "bin": { 20 | "pnp-authentication-service": "./src/index.js", 21 | "pnp-authentication-service-starter": "./src/starter.js" 22 | }, 23 | "dependencies": { 24 | "body-parser": "1.17.2", 25 | "dedent": "^0.7.0", 26 | "ejs": "2.5.7", 27 | "express": "4.15.4", 28 | "i18n": "^0.8.3", 29 | "inquirer": "3.2.1", 30 | "joi": "10.6.0", 31 | "jsonwebtoken": "7.4.2", 32 | "knex": "0.12.9", 33 | "lodash": "^4.17.4", 34 | "mongodb": "^2.2.30", 35 | "mysql": "^2.13.0", 36 | "ncp": "^2.0.0", 37 | "node-fetch": "1.7.2", 38 | "pg": "6.4.2", 39 | "pify": "^2.3.0", 40 | "pnp-email-service": "^0.1.6", 41 | "pnp-media-service": "^0.1.3", 42 | "qrcode": "0.8.2", 43 | "request": "2.81.0", 44 | "scrypt": "^6.0.3", 45 | "speakeasy": "^2.0.0", 46 | "superagent": "^3.5.2", 47 | "useragent": "2.2.1", 48 | "uuid": "3.1.0", 49 | "winston": "2.3.1" 50 | }, 51 | "devDependencies": { 52 | "ava": "0.18.1", 53 | "better-npm-run": "^0.0.15", 54 | "chai": "4.1.1", 55 | "nodemon": "^1.11.0", 56 | "standard": "^8.6.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /views/include-javascript.html: -------------------------------------------------------------------------------- 1 | 66 | -------------------------------------------------------------------------------- /src/validations.js: -------------------------------------------------------------------------------- 1 | const constants = require('./constants') 2 | const Joi = require('joi') 3 | 4 | module.exports = env => { 5 | const signupFields = env('SIGNUP_FIELDS', '').split(',') 6 | .filter(name => constants.availableFields[name]) 7 | .map(name => Object.assign({ name }, constants.availableFields[name])) 8 | 9 | const termsAndConditions = env('TERMS_AND_CONDITIONS') 10 | return { 11 | forms (provider) { 12 | const forms = { 13 | register: { 14 | fields: { 15 | email: ['email', 'empty'] 16 | } 17 | }, 18 | signin: { 19 | fields: { 20 | email: ['email', 'empty'], 21 | password: ['empty'] 22 | } 23 | }, 24 | resetpassword: { 25 | fields: { 26 | email: ['email', 'empty'] 27 | } 28 | }, 29 | reset: { 30 | fields: { 31 | password: ['empty'] 32 | } 33 | }, 34 | changepassword: { 35 | fields: { 36 | oldpassword: ['empty'], 37 | newpassword: ['empty'] 38 | } 39 | }, 40 | changeemail: { 41 | fields: { 42 | email: ['email', 'empty'], 43 | password: ['empty'] 44 | } 45 | } 46 | } 47 | 48 | const registerFields = forms.register.fields 49 | if (!provider) registerFields.password = ['empty'] 50 | if (termsAndConditions) registerFields.termsAndConditions = ['checked'] 51 | for (const field of signupFields) { 52 | registerFields[field.name] = field.type === 'text' ? ['empty'] : [] 53 | } 54 | return forms 55 | }, 56 | schema (provider, formName) { 57 | const { fields } = this.forms(provider)[formName] 58 | const keys = Object.keys(fields).reduce((keys, key) => { 59 | const arr = fields[key] 60 | let constraint = Joi.string().trim() 61 | arr.indexOf('empty') >= 0 62 | ? constraint = constraint.required() 63 | : constraint = constraint.optional() 64 | if (arr.indexOf('email') >= 0) constraint.email().lowercase() 65 | keys[key] = constraint 66 | return keys 67 | }, {}) 68 | return Joi.object().keys(keys) 69 | }, 70 | validate (provider, formName, values) { 71 | values = Object.assign({}, values) 72 | delete values['g-recaptcha-response'] 73 | const schema = this.schema(provider, formName) 74 | return Joi.validate(values, schema, { abortEarly: false }) 75 | }, 76 | signupFields, 77 | termsAndConditions 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/providers/facebook.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring') 2 | const pify = require('pify') 3 | const request = pify(require('request'), { multiArgs: true }) 4 | const _ = require('lodash') 5 | 6 | module.exports = (router, redirect, env) => { 7 | const baseUrl = env('BASE_URL') 8 | const facebookAppId = env('FACEBOOK_APP_ID') 9 | const facebookAppSecret = env('FACEBOOK_APP_SECRET') 10 | if (!facebookAppId || !facebookAppSecret) return false 11 | 12 | const redirectUrl = baseUrl + '/provider/facebook/callback' 13 | 14 | router.get('/provider/facebook', (req, res, next) => { 15 | const base = 'https://www.facebook.com/v2.8/dialog/oauth?' 16 | const query = querystring.stringify({ 17 | app_id: facebookAppId, 18 | redirect_uri: redirectUrl, 19 | scope: 'email' 20 | }) 21 | res.redirect(base + query) 22 | }) 23 | 24 | router.get('/provider/facebook/callback', (req, res, next) => { 25 | const { code } = req.query 26 | const base = 'https://graph.facebook.com/v2.8/oauth/access_token?' 27 | const query = querystring.stringify({ 28 | client_id: facebookAppId, 29 | redirect_uri: redirectUrl, 30 | client_secret: facebookAppSecret, 31 | code 32 | }) 33 | request({ url: base + query, json: true }) 34 | .then(([response, body]) => { 35 | const { access_token } = body 36 | const url = 'https://graph.facebook.com/v2.8/me' 37 | const options = { 38 | url, 39 | qs: { 40 | access_token, 41 | fields: 'first_name,last_name,email,picture' 42 | }, 43 | json: true 44 | } 45 | return request.get(options) 46 | .then(([response, body]) => { 47 | return Promise.resolve() 48 | .then(() => { 49 | if (_.get(body, 'picture.data.is_silhouette') !== false) return 50 | const options = { 51 | url: 'https://graph.facebook.com/v2.8/me/picture', 52 | qs: { access_token, height: 160 }, 53 | followRedirect: false, 54 | json: true 55 | } 56 | return request.get(options).then(([response, body]) => response.headers.location) 57 | }) 58 | .then(image => { 59 | const user = { 60 | firstName: body.first_name, 61 | lastName: body.last_name, 62 | email: body.email, 63 | image, 64 | login: 'facebook:' + body.id, 65 | icon: 'facebook', 66 | description: [body.first_name, body.last_name].filter(Boolean).join(' '), 67 | data: { access_token } 68 | } 69 | return redirect(user, res) 70 | }) 71 | }) 72 | }) 73 | .catch(next) 74 | }) 75 | 76 | return true 77 | } 78 | -------------------------------------------------------------------------------- /src/providers/google.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring') 2 | const pify = require('pify') 3 | const request = pify(require('request'), { multiArgs: true }) 4 | 5 | module.exports = (router, redirect, env) => { 6 | const baseUrl = env('BASE_URL') 7 | const googleClientId = env('GOOGLE_CLIENT_ID') 8 | const googleClientSecret = env('GOOGLE_CLIENT_SECRET') 9 | if (!googleClientId || !googleClientSecret) return false 10 | 11 | const redirectUrl = baseUrl + '/provider/google/callback' 12 | 13 | router.get('/provider/google', (req, res, next) => { 14 | // See https://developers.google.com/identity/protocols/OAuth2UserAgent 15 | const base = 'https://accounts.google.com/o/oauth2/v2/auth?' 16 | const query = querystring.stringify({ 17 | client_id: googleClientId, 18 | redirect_uri: redirectUrl, 19 | scope: 'email', 20 | response_type: 'code' 21 | // TODO: state 22 | // TODO: prompt 23 | }) 24 | res.redirect(base + query) 25 | }) 26 | 27 | router.get('/provider/google/callback', (req, res, next) => { 28 | const { code } = req.query 29 | const options = { 30 | url: 'https://www.googleapis.com/oauth2/v4/token', 31 | method: 'POST', 32 | form: { 33 | code, 34 | client_id: googleClientId, 35 | client_secret: googleClientSecret, 36 | redirect_uri: redirectUrl, 37 | grant_type: 'authorization_code' 38 | }, 39 | json: true 40 | } 41 | return request(options) 42 | .then(([resp, body]) => { 43 | if (resp.statusCode !== 200) return Promise.reject(new Error(`${body.error}: ${body.error_description}`)) 44 | const { access_token } = body 45 | 46 | const url = 'https://www.googleapis.com/plus/v1/people/me' 47 | const options = { url, qs: { access_token }, json: true } 48 | return request.get(options) 49 | .then((result) => { 50 | const [resp, body] = result 51 | if (resp.statusCode !== 200) { 52 | const message = (body.error && body.error.message) || `Google returned a status code = ${resp.statusCode}` 53 | return Promise.reject(new Error(message)) 54 | } 55 | if (!body.id) return Promise.reject(new Error('Google returned a bad response')) 56 | let image = body.image && !body.image.isDefault && body.image.url 57 | image = image && image.replace('sz=50', 'sz=160') 58 | 59 | const user = { 60 | firstName: body.name.givenName, 61 | lastName: body.name.familyName, 62 | email: body.emails[0].value, 63 | image, 64 | login: 'google:' + body.id, 65 | icon: 'google', 66 | description: body.displayName, 67 | data: { access_token } 68 | } 69 | return redirect(user, res) 70 | }) 71 | }) 72 | .catch(next) 73 | }) 74 | 75 | return true 76 | } 77 | -------------------------------------------------------------------------------- /src/database/mongodb.js: -------------------------------------------------------------------------------- 1 | const mongo = require('mongodb') 2 | const _ = require('lodash') 3 | 4 | module.exports = env => { 5 | let db 6 | mongo.MongoClient.connect(env('DATABASE_URL'), (err, connection) => { 7 | if (err) { 8 | console.error(err) 9 | } 10 | db = connection 11 | }) 12 | 13 | function sanitizeOutputs (f) { 14 | return (key) => { 15 | return f(key).then(res => { 16 | if (res) { 17 | res.id = res._id.toString() 18 | return res 19 | } else { 20 | return null 21 | } 22 | }) 23 | } 24 | } 25 | 26 | return { 27 | engine: env('DATABASE_ENGINE'), 28 | init () { 29 | return Promise.resolve() 30 | }, 31 | findUserByEmail: sanitizeOutputs(function (email) { 32 | return db.collection('auth_users').findOne({ email }) 33 | }), 34 | findUserByEmailConfirmationToken: sanitizeOutputs(function (emailConfirmationToken) { 35 | return db.collection('auth_users').findOne({ emailConfirmationToken }) 36 | }), 37 | findUserById: sanitizeOutputs(function (id) { 38 | return db.collection('auth_users').findOne({ _id: mongo.ObjectID(id) }) 39 | }), 40 | findUserByProviderLogin: sanitizeOutputs(function (login) { 41 | return db.collection('auth_providers').findOne({ login }).then(function (provider) { 42 | if (!provider) { 43 | return Promise.resolve(null) 44 | } 45 | return db.collection('auth_users').findOne({ _id: mongo.ObjectID(provider.userId) }) 46 | }) 47 | }), 48 | findRecoveryCodesByUserId (userId) { 49 | return db.collection('auth_recovery_codes').find({ userId }).toArray() 50 | }, 51 | insertRecoveryCodes (userId, codes) { 52 | return db.collection('auth_recovery_codes').deleteMany({ userId }) 53 | .then(res => { 54 | return Promise.all(_.map(codes, (code) => { 55 | return db.collection('auth_recovery_codes').insertOne({ userId, code, used: false }) 56 | })) 57 | }).then(() => { 58 | return Promise.resolve(_.map(codes, code => ({ code, used: false }))) 59 | }).catch((err) => { 60 | console.error(err) 61 | return Promise.reject() 62 | }) 63 | }, 64 | useRecoveryCode (userId, code) { 65 | return db.collection('auth_recovery_codes') 66 | .updateOne({ userId, code: code.toLowerCase(), used: false }, { $set: { used: true } }) 67 | .then(res => !!res.result.nModified) 68 | }, 69 | insertUser (user) { 70 | return db.collection('auth_users').insert(user).then(res => { 71 | return res.insertedIds[0] 72 | }) 73 | }, 74 | updateUser (user) { 75 | return db.collection('auth_users') 76 | .update({ _id: mongo.ObjectID(user.id) }, {$set: _.omit(user, 'id')}) 77 | .then(res => { 78 | return res.result.nModified 79 | }) 80 | }, 81 | insertProvider (provider) { 82 | return db.collection('auth_providers').insert(provider).then(res => { 83 | return res.insertedIds[0] 84 | }) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /static/stylesheet.css: -------------------------------------------------------------------------------- 1 | @import url('https://cdn.jsdelivr.net/semantic-ui/2.2.7/semantic.min.css'); 2 | 3 | body { 4 | background-color: #DADADA; 5 | } 6 | 7 | body > .grid { 8 | height: 100%; 9 | } 10 | 11 | body > .ui.message:first-child { 12 | margin: 20px; 13 | text-align: center; 14 | } 15 | 16 | .ui.image { 17 | margin: 0 auto; 18 | margin-bottom: 20px; 19 | width: 150px; 20 | height: 150px; 21 | } 22 | 23 | .column { 24 | max-width: 450px; 25 | } 26 | 27 | a.ui.button { 28 | margin-bottom: 5px 29 | } 30 | .button.google, .button.google:hover { 31 | background-color: #DB402C; 32 | color: white 33 | } 34 | .button.facebook, .button.facebook:hover { 35 | background-color: #324D8F; 36 | color: white 37 | } 38 | .button.github, .button.github:hover { 39 | background-color: #3C3C3C; 40 | color: white 41 | } 42 | .button.linkedin, .button.linkedin:hover { 43 | background-color: #263E58; 44 | color: white 45 | } 46 | .button.twitter, .button.twitter:hover { 47 | background-color: #1EA1F1; 48 | color: white 49 | } 50 | .field { 51 | text-align: left; 52 | } 53 | .ui.form .field > label { 54 | display: none; 55 | } 56 | .ui.message { 57 | font-size: 0.9em; 58 | } 59 | 60 | 61 | .fill { 62 | width: 100%; 63 | } 64 | 65 | .aligned.center { 66 | text-align: center; 67 | } 68 | 69 | .aligned.left { 70 | text-align: left; 71 | } 72 | 73 | 74 | #done .green { 75 | color: #16ab39; 76 | } 77 | 78 | #done .red { 79 | color: #d01919; 80 | } 81 | 82 | #done .large.icon { 83 | font-size: 10em; 84 | margin: 0; 85 | } 86 | 87 | /* Cropit */ 88 | 89 | .cropit-preview { 90 | width: 160px; 91 | height: 160px; 92 | margin: 0 auto; 93 | margin-top: 30px; 94 | background-color: #eee; 95 | } 96 | 97 | .cropit-preview::before { 98 | font-family: 'Icons'; 99 | content: "\f030"; 100 | position: absolute; 101 | font-size: 100px; 102 | line-height: 1; 103 | top: 40px; 104 | text-align: center; 105 | right: 0; 106 | left: 0; 107 | } 108 | 109 | .cropit-image-loaded.cropit-preview::before { 110 | display: none; 111 | } 112 | 113 | .cropit-image-zoom-input { 114 | display: none; 115 | } 116 | 117 | .cropit-image-loaded + .cropit-image-zoom-input { 118 | display: inline; 119 | } 120 | 121 | /* 122 | * If the slider or anything else is covered by the background image, 123 | * use relative or absolute position on it 124 | */ 125 | input.cropit-image-zoom-input { 126 | position: relative; 127 | } 128 | 129 | /* Show load indicator when image is being loaded */ 130 | .cropit-preview.cropit-image-loading .spinner { 131 | opacity: 1; 132 | } 133 | 134 | /* Show move cursor when image has been loaded */ 135 | .cropit-preview.cropit-image-loaded .cropit-preview-image-container { 136 | cursor: move; 137 | } 138 | 139 | /* Gray out zoom slider when the image cannot be zoomed */ 140 | .cropit-image-zoom-input[disabled] { 141 | opacity: .2; 142 | } 143 | 144 | /* Hide default file input button if you want to use a custom button */ 145 | input.cropit-image-input { 146 | visibility: hidden; 147 | position: absolute; 148 | } 149 | 150 | /* The following styles are only relevant to when background image is enabled */ 151 | 152 | /* Translucent background image */ 153 | .cropit-preview-background { 154 | opacity: .2; 155 | } 156 | 157 | /* 158 | * If the slider or anything else is covered by the background image, 159 | * use non-static position on it 160 | */ 161 | input.cropit-image-zoom-input { 162 | position: relative; 163 | } 164 | 165 | /* Limit the background image by adding overflow: hidden */ 166 | #image-cropper { 167 | overflow: hidden; 168 | } 169 | 170 | .select-image-btn-wrapper { 171 | margin: 10px 0 20px 0; 172 | } 173 | 174 | .strikethrough { 175 | text-decoration: line-through; 176 | } 177 | -------------------------------------------------------------------------------- /test/database.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | 3 | const databases = [ 4 | { 5 | human: 'MySQL', 6 | DATABASE_ENGINE: 'mysql', 7 | DATABASE_URL: 'mysql://root:root@localhost/auth' 8 | }, { 9 | human: 'PostgreSQL', 10 | DATABASE_ENGINE: 'pg', 11 | DATABASE_URL: 'postgresql://authtest:authtest@localhost/auth' 12 | }, { 13 | human: 'MongoDB', 14 | DATABASE_ENGINE: 'mongodb', 15 | DATABASE_URL: 'mongodb://localhost/auth' 16 | } 17 | ] 18 | 19 | for (var database of databases) { 20 | let env = require('../src/utils/env')(database) 21 | let adapter = require('../src/database/adapter')(env) 22 | let randomId = parseInt(Math.random() * Number.MAX_SAFE_INTEGER) 23 | let userId 24 | 25 | test.serial(`${database.human} init()`, t => { 26 | t.plan(1) 27 | return adapter.init() 28 | .then(res => { 29 | // We're not concerned with the output; just that it didn't error 30 | t.pass() 31 | }).catch(err => { 32 | t.falsy(err) 33 | }) 34 | }) 35 | 36 | test.serial(`${database.human} insertUser()`, t => { 37 | t.plan(1) 38 | return adapter.insertUser({ email: `test+${randomId}@clevertech.biz`, emailConfirmationToken: `token${randomId}` }) 39 | .then(res => { 40 | // Capture the inserted ID so we can use it later 41 | userId = res 42 | // We're not concerned with the output; just that it didn't error 43 | t.pass() 44 | }).catch(err => { 45 | t.falsy(err) 46 | }) 47 | }) 48 | 49 | test.serial(`${database.human} insertProvider()`, t => { 50 | t.plan(1) 51 | return adapter.insertProvider({ userId, login: `login${randomId}`, data: {someKey: 'placeholder string'} }) 52 | .then(res => { 53 | // We're not concerned with the output; just that it didn't error 54 | t.pass() 55 | }).catch(err => { 56 | t.falsy(err) 57 | }) 58 | }) 59 | 60 | test(`${database.human} updateUser()`, t => { 61 | t.plan(3) 62 | return adapter.updateUser({ id: userId, emailConfirmed: 1, termsAndConditions: 1 }) 63 | .then(updateCount => { 64 | return adapter.findUserById(userId).then(res => { 65 | t.truthy(updateCount) 66 | t.truthy(res.emailConfirmed) 67 | t.truthy(res.termsAndConditions) 68 | }) 69 | }).catch(err => { 70 | t.falsy(err) 71 | }) 72 | }) 73 | 74 | test(`${database.human} findUserByEmail()`, t => { 75 | t.plan(3) 76 | return adapter.findUserByEmail(`test+${randomId}@clevertech.biz`) 77 | .then(res => { 78 | t.is(res.id, `${userId}`) 79 | t.is(res.email, `test+${randomId}@clevertech.biz`) 80 | t.is(res.emailConfirmationToken, `token${randomId}`) 81 | }).catch(err => { 82 | t.falsy(err) 83 | }) 84 | }) 85 | 86 | test(`${database.human} findUserByEmailConfirmationToken()`, t => { 87 | t.plan(3) 88 | return adapter.findUserByEmailConfirmationToken(`token${randomId}`) 89 | .then(res => { 90 | t.is(res.id, `${userId}`) 91 | t.is(res.email, `test+${randomId}@clevertech.biz`) 92 | t.is(res.emailConfirmationToken, `token${randomId}`) 93 | }).catch(err => { 94 | t.falsy(err) 95 | }) 96 | }) 97 | 98 | test(`${database.human} findUserById()`, t => { 99 | t.plan(3) 100 | return adapter.findUserById(userId) 101 | .then(res => { 102 | t.is(res.id, `${userId}`) 103 | t.is(res.email, `test+${randomId}@clevertech.biz`) 104 | t.is(res.emailConfirmationToken, `token${randomId}`) 105 | }).catch(err => { 106 | t.falsy(err) 107 | }) 108 | }) 109 | 110 | test(`${database.human} findUserByProviderLogin()`, t => { 111 | t.plan(3) 112 | return adapter.findUserByProviderLogin(`login${randomId}`) 113 | .then(res => { 114 | t.is(res.id, `${userId}`) 115 | t.is(res.email, `test+${randomId}@clevertech.biz`) 116 | t.is(res.emailConfirmationToken, `token${randomId}`) 117 | }).catch(err => { 118 | t.falsy(err) 119 | }) 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /test/validations.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const env = require('../src/utils/env')({ 3 | SIGNUP_FIELDS: 'firstName,lastName', 4 | TERMS_AND_CONDITIONS: 'http://example.com' 5 | }) 6 | const validations = require('../src/validations')(env) 7 | 8 | test('validation forms with provider', t => { 9 | t.deepEqual(validations.forms(true), { 10 | register: { 11 | fields: { 12 | email: [ 13 | 'email', 14 | 'empty' 15 | ], 16 | termsAndConditions: [ 17 | 'checked' 18 | ], 19 | firstName: ['empty'], 20 | lastName: ['empty'] 21 | } 22 | }, 23 | signin: { 24 | fields: { 25 | email: [ 26 | 'email', 27 | 'empty' 28 | ], 29 | password: [ 30 | 'empty' 31 | ] 32 | } 33 | }, 34 | resetpassword: { 35 | fields: { 36 | email: [ 37 | 'email', 38 | 'empty' 39 | ] 40 | } 41 | }, 42 | reset: { 43 | fields: { 44 | password: [ 45 | 'empty' 46 | ] 47 | } 48 | }, 49 | changepassword: { 50 | fields: { 51 | oldpassword: [ 52 | 'empty' 53 | ], 54 | newpassword: [ 55 | 'empty' 56 | ] 57 | } 58 | }, 59 | changeemail: { 60 | fields: { 61 | email: [ 62 | 'email', 63 | 'empty' 64 | ], 65 | password: [ 66 | 'empty' 67 | ] 68 | } 69 | } 70 | }) 71 | }) 72 | 73 | test('validation forms without provider', t => { 74 | t.deepEqual(validations.forms(false), { 75 | register: { 76 | fields: { 77 | email: [ 78 | 'email', 79 | 'empty' 80 | ], 81 | password: ['empty'], 82 | termsAndConditions: [ 83 | 'checked' 84 | ], 85 | firstName: ['empty'], 86 | lastName: ['empty'] 87 | } 88 | }, 89 | signin: { 90 | fields: { 91 | email: [ 92 | 'email', 93 | 'empty' 94 | ], 95 | password: [ 96 | 'empty' 97 | ] 98 | } 99 | }, 100 | resetpassword: { 101 | fields: { 102 | email: [ 103 | 'email', 104 | 'empty' 105 | ] 106 | } 107 | }, 108 | reset: { 109 | fields: { 110 | password: [ 111 | 'empty' 112 | ] 113 | } 114 | }, 115 | changepassword: { 116 | fields: { 117 | oldpassword: [ 118 | 'empty' 119 | ], 120 | newpassword: [ 121 | 'empty' 122 | ] 123 | } 124 | }, 125 | changeemail: { 126 | fields: { 127 | email: [ 128 | 'email', 129 | 'empty' 130 | ], 131 | password: [ 132 | 'empty' 133 | ] 134 | } 135 | } 136 | }) 137 | }) 138 | 139 | test('validate register form without provider', t => { 140 | const values = {} 141 | const result = validations.validate(false, 'register', values) 142 | t.truthy(result.error) 143 | t.deepEqual(result.error.details, [ 144 | { 145 | message: '"email" is required', 146 | path: 'email', 147 | type: 'any.required', 148 | context: { 149 | key: 'email' 150 | } 151 | }, 152 | { 153 | message: '"password" is required', 154 | path: 'password', 155 | type: 'any.required', 156 | context: { 157 | key: 'password' 158 | } 159 | }, 160 | { 161 | message: '"firstName" is required', 162 | path: 'firstName', 163 | type: 'any.required', 164 | context: { 165 | key: 'firstName' 166 | } 167 | }, 168 | { 169 | message: '"lastName" is required', 170 | path: 'lastName', 171 | type: 'any.required', 172 | context: { 173 | key: 'lastName' 174 | } 175 | } 176 | ]) 177 | }) 178 | 179 | test('validate register form with provider', t => { 180 | const values = {} 181 | const result = validations.validate(true, 'register', values) 182 | t.truthy(result.error) 183 | t.deepEqual(result.error.details, [ 184 | { 185 | message: '"email" is required', 186 | path: 'email', 187 | type: 'any.required', 188 | context: { 189 | key: 'email' 190 | } 191 | }, 192 | { 193 | message: '"firstName" is required', 194 | path: 'firstName', 195 | type: 'any.required', 196 | context: { 197 | key: 'firstName' 198 | } 199 | }, 200 | { 201 | message: '"lastName" is required', 202 | path: 'lastName', 203 | type: 'any.required', 204 | context: { 205 | key: 'lastName' 206 | } 207 | } 208 | ]) 209 | }) 210 | 211 | test('validate register form successfully', t => { 212 | const values = { 213 | email: 'user@example.com', 214 | termsAndConditions: 'on', 215 | firstName: 'Anakin', 216 | lastName: 'Skywalker', 217 | password: 'something' 218 | } 219 | const result = validations.validate(false, 'register', values) 220 | t.falsy(result.error) 221 | t.truthy(result.value) 222 | }) 223 | -------------------------------------------------------------------------------- /src/database/knex.js: -------------------------------------------------------------------------------- 1 | const knex = require('knex') 2 | const constants = require('../constants') 3 | const _ = require('lodash') 4 | const uuid = require('uuid/v4') 5 | 6 | module.exports = env => { 7 | const db = knex({ 8 | client: env('DATABASE_ENGINE', 'pg'), 9 | connection: env('DATABASE_URL'), 10 | searchPath: 'knex,public' 11 | }) 12 | 13 | const fieldNames = Object.keys(constants.availableFields) 14 | 15 | const last = result => Array.isArray(result) ? result[result.length - 1] : result 16 | 17 | const createTableIfNotExists = (tableName, callback) => { 18 | callback = _.once(callback) 19 | return db.schema.hasTable(tableName) 20 | .then((tableExists) => { 21 | return db.schema.createTableIfNotExists(tableName, table => !tableExists ? callback(table) : void 0) 22 | }) 23 | } 24 | 25 | const addColumn = (table, columnName, callback) => { 26 | return db.schema.hasColumn(table, columnName) 27 | .then(exists => (!exists && db.schema.alterTable(table, callback))) 28 | } 29 | 30 | return { 31 | engine: env('DATABASE_ENGINE'), 32 | init () { 33 | return Promise.resolve() 34 | .then(() => { 35 | return db.schema.hasTable('auth_users').then(tableExists => { 36 | let p 37 | if (!tableExists) { 38 | p = db.schema.createTableIfNotExists('auth_users', _.once(table => { 39 | table.uuid('id').primary() 40 | table.string('email').notNullable().unique() 41 | table.string('twofactor').nullable() 42 | table.string('password').nullable() 43 | table.boolean('emailConfirmed').notNullable().defaultTo(false) 44 | table.string('emailConfirmationToken').nullable().unique() 45 | table.string('termsAndConditions').nullable() 46 | table.timestamps() 47 | })) 48 | } else { 49 | p = Promise.resolve() 50 | } 51 | return p.then(function () { 52 | return fieldNames 53 | .reduce((prom, fieldName) => { 54 | return prom.then(missing => { 55 | return db.schema.hasColumn('auth_users', fieldName).then(exists => { 56 | if (!exists) missing.push(fieldName) 57 | return missing 58 | }) 59 | }) 60 | }, Promise.resolve([])) 61 | .then(missing => { 62 | if (missing) { 63 | return db.schema.alterTable( 64 | 'auth_users', 65 | _.once(table => { 66 | missing.forEach(fieldName => table.string(fieldName)) 67 | }) 68 | ) 69 | } else { 70 | return Promise.resolve() 71 | } 72 | }) 73 | }) 74 | }) 75 | }) 76 | .then(() => { 77 | return db.schema.hasTable('auth_providers').then(tableExists => { 78 | return tableExists ? Promise.resolve() : createTableIfNotExists('auth_providers', _.once(table => { 79 | table.uuid('userId').notNullable() 80 | table.foreign('userId').references('auth_users.id').onDelete('cascade') 81 | table.string('login').notNullable().unique() 82 | table.json('data').notNullable() 83 | table.timestamps() 84 | })) 85 | }) 86 | }) 87 | .then(() => { 88 | return db.schema.hasTable('auth_sessions').then(tableExists => { 89 | return tableExists ? Promise.resolve() : createTableIfNotExists('auth_sessions', _.once(table => { 90 | table.uuid('userId').notNullable() 91 | table.foreign('userId').references('auth_users.id').onDelete('cascade') 92 | table.string('userAgent').notNullable() 93 | table.string('ip').notNullable() 94 | table.timestamps() 95 | })) 96 | }) 97 | }) 98 | .then(() => { 99 | return db.schema.hasTable('auth_recovery_codes').then(tableExists => { 100 | return tableExists ? Promise.resolve() : createTableIfNotExists('auth_recovery_codes', _.once(table => { 101 | table.uuid('userId').notNullable() 102 | table.foreign('userId').references('auth_users.id').onDelete('cascade') 103 | table.string('code').notNullable() 104 | table.boolean('used').notNullable().defaultTo(false) 105 | })) 106 | }) 107 | }) 108 | .then(() => addColumn('auth_users', 'twofactorSecret', table => table.string('twofactorSecret'))) 109 | .then(() => addColumn('auth_users', 'twofactorPhone', table => table.string('twofactorPhone'))) 110 | }, 111 | findUserByEmail (email) { 112 | return db('auth_users').where({ email }).then(last) 113 | }, 114 | findUserByEmailConfirmationToken (emailConfirmationToken) { 115 | return db('auth_users').where({ emailConfirmationToken }).then(last) 116 | }, 117 | findUserById (id) { 118 | return db('auth_users').where({ id }).select().then(last) 119 | }, 120 | findUserByProviderLogin (login) { 121 | return db('auth_providers') 122 | .where({ login }) 123 | .leftJoin('auth_users', 'auth_providers.userId', 'auth_users.id') 124 | .then(last) 125 | }, 126 | findRecoveryCodesByUserId (userId) { 127 | return db('auth_recovery_codes') 128 | .where({ userId }) 129 | .then(codes => codes) 130 | }, 131 | insertRecoveryCodes (userId, codes) { 132 | return db.transaction(trx => { 133 | return db('auth_recovery_codes') 134 | .where({ userId }) 135 | .del() 136 | .then(res => { 137 | return Promise.all(_.map(codes, (code) => { 138 | return db('auth_recovery_codes') 139 | .transacting(trx) 140 | .insert({ userId, code }) 141 | })) 142 | }) 143 | .then(trx.commit) 144 | .catch(trx.rollback) 145 | }).then(() => { 146 | return Promise.resolve(_.map(codes, code => ({ code, used: false }))) 147 | }).catch((err) => { 148 | console.error(err) 149 | return Promise.reject() 150 | }) 151 | }, 152 | useRecoveryCode (userId, code) { 153 | return db('auth_recovery_codes') 154 | .where({ userId, code: code.toLowerCase(), used: false }) 155 | .update({ used: true }) 156 | .then(updateCount => !!updateCount) 157 | }, 158 | insertUser (user) { 159 | user = _.omit(user, ['id', '_id']) 160 | const userId = uuid() 161 | user.id = userId 162 | return db('auth_users').insert(user).then(res => { 163 | return userId 164 | }) 165 | }, 166 | updateUser (user) { 167 | return db('auth_users').where('id', '=', user.id).update(user) 168 | }, 169 | insertProvider (provider) { 170 | if (env('DATABASE_ENGINE') === 'mysql') { 171 | provider.data = JSON.stringify(provider.data) 172 | } 173 | return db('auth_providers').insert(provider) 174 | } 175 | } 176 | } 177 | 178 | -------------------------------------------------------------------------------- /views/twofactorconfigure.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('./include-head.html', { projectName, title }) %> 5 | <%- include('./include-head-recaptcha.html', { recaptchaSiteKey }) %> 6 | 7 | 8 | <%- include('./include-recaptcha.html', { recaptchaSiteKey }) %> 9 | <%- include('./include-alert.html', { error, info }) %> 10 |
11 |
12 |

13 |
14 | How would you like to receive your authentication codes? 15 |
16 |

17 | <% if (user.twofactor) { %> 18 |
19 |

20 | <% if (user.twofactor === 'qr') { %> 21 | Two factor authentication is currently configured with an application. 22 | <% } else if (user.twofactor === 'sms') { %> 23 | Two factor authentication is currently configured with this mobile phone: <%= obfuscatePhone(user.twofactorPhone) %>
24 | <% } %> 25 | Any change will override the current configuration. 26 |

27 |

Or you can disable 2FA

28 |
29 | <% } %> 30 | <% if (smsService) { %> 31 |
32 |

SMS text message

33 |

Receive a text message to your mobile device

34 | Configure phone 35 |
36 | <% } %> 37 |
38 |

Use an app

39 |

Scan a QR code with Google Authenticator or similar apps

40 | Configure app 41 |
42 |
43 |
44 |
45 |
46 |

47 |
48 | Configure Two-Factor Authentication 49 |
50 |

51 | 52 | 53 |
54 |

Get the App

55 |

Download and install the Google Authenticator for your phone or tablet

56 |
57 |
58 |

Scan this Barcode

59 |

Open the authentication app and scan the QR code

60 |
61 | 62 |
63 |
64 |
65 |

Enter Verification Code

66 |

Enter the 6-digit verification code generated in the app

67 |
68 |
69 |
70 | 71 | 72 |
73 |
74 | 75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 |
83 |
84 |

85 |
86 | Add SMS Authentication 87 |
88 |

89 |
90 | 91 |
92 |

What's your phone number?

93 |
94 |
95 | 96 | 97 |
98 |
99 | 100 |
101 |
102 |
103 |
104 |
105 |
106 |

107 |
108 | Add SMS Authentication 109 |
110 |

111 |
112 | 113 |
114 |

Please enter the 6-digit authentication code we just sent to:

115 |

<%= obfuscatePhone(locals.phone) %>

116 |
117 |
118 | 119 | 120 |
121 |
122 | 123 |
124 |
125 |
126 |
127 |
128 |
129 |

130 |
131 | Disable two factor authentication 132 |
133 |

134 |
135 | 136 |
137 |

Please, confirm that you want to disable two factor authentication

138 | 139 |
140 |
141 |
142 |
143 | <%- include('./include-javascript.html', { forms }) %> 144 | 145 | 146 | -------------------------------------------------------------------------------- /src/services/users.js: -------------------------------------------------------------------------------- 1 | const passwords = require('../utils/passwords') 2 | const querystring = require('querystring') 3 | const _ = require('lodash') 4 | 5 | // We need to create this invalid hash, with passwords.hash, to prevent timing attacks (see below) 6 | let invalidHash = null 7 | passwords.hash('invalidEmail', 'anypasswordyoucanimagine') 8 | .then(hash => (invalidHash = hash)) 9 | .catch(err => console.error(err)) 10 | 11 | const NUMBER_OF_RECOVERY_CODES = 10 12 | 13 | const normalizeEmail = email => email.toLowerCase() 14 | 15 | const userName = user => { 16 | return user.name || 17 | user.firstName || 18 | user.lastName || 19 | user.username || 20 | user.email.substring(0, user.email.indexOf('@')) 21 | } 22 | 23 | const reject = reason => { 24 | const err = new Error(reason) 25 | err.handled = true 26 | return Promise.reject(err) 27 | } 28 | 29 | module.exports = (env, jwt, database, sendEmail, mediaClient, validations) => { 30 | const baseUrl = env('BASE_URL') 31 | const projectName = env('PROJECT_NAME') 32 | const crypto = require('../utils/crypto')(env) 33 | const random = (length = 16) => require('crypto').randomBytes(length).toString('hex') 34 | const createToken = () => { 35 | return jwt.sign({ code: random() }, { expiresIn: '24h' }) 36 | } 37 | 38 | return { 39 | login (email, password, client) { 40 | email = normalizeEmail(email) 41 | return database.findUserByEmail(email) 42 | .then(user => { 43 | // If the user does not exist, use the check function anyways 44 | // to avoid timing attacks. 45 | // See https://en.wikipedia.org/wiki/Timing_attack 46 | return passwords.check(email, password, (user && user.password) || invalidHash) 47 | .then(ok => user && ok ? user : reject('INVALID_CREDENTIALS')) 48 | .catch(err => reject(err)) 49 | }) 50 | }, 51 | createRecoveryCodes (user) { 52 | return Promise.all(_.map(Array(NUMBER_OF_RECOVERY_CODES), () => { 53 | return crypto.encrypt(random(4)) 54 | })) 55 | .then((codes) => { 56 | return database.insertRecoveryCodes(user.id, codes) 57 | }) 58 | }, 59 | register (params, client) { 60 | const email = normalizeEmail(params.email) 61 | const { provider } = params 62 | delete params.provider 63 | if (!params.image) delete params.image // removes empty strings 64 | return createToken() 65 | .then(emailConfirmationToken => { 66 | return database.findUserByEmail(email) 67 | .then(exists => { 68 | if (exists) return reject('USER_ALREADY_EXISTS') 69 | if (!params.password && !provider) return reject('PASSWORD_REQUIRED') 70 | if (params.password) { 71 | return passwords.hash(params.email, params.password) 72 | .then(hash => (params.password = hash)) 73 | } 74 | }) 75 | .then(() => { 76 | return (provider ? jwt.verify(provider) : Promise.resolve()) 77 | .then(userInfo => { 78 | const validation = validations.validate(provider, 'register', params) 79 | if (validation.error) { 80 | return reject('FORM_VALIDATION_FAILED: ' + validation.error.details.map(detail => detail.message).join(', ')) 81 | } 82 | const user = validation.value 83 | return Promise.resolve() 84 | .then(() => { 85 | if (user.image) { 86 | return mediaClient.upload({ 87 | buffer: user.image, 88 | destinationPath: 'user-' + Date.now(), 89 | imageOperations: { 90 | width: 160, 91 | height: 160, 92 | autoRotate: true, 93 | appendExtension: true 94 | } 95 | }) 96 | .then((response) => { 97 | user.image = response.url 98 | }) 99 | } 100 | }) 101 | .then(() => { 102 | return database.insertUser(Object.assign({}, user, { 103 | emailConfirmationToken 104 | })) 105 | .then((id) => { 106 | const user = userInfo && userInfo.user 107 | if (user) return database.insertProvider({ userId: id, login: user.login, data: user.data || {} }) 108 | }) 109 | }) 110 | }) 111 | }) 112 | .then(() => database.findUserByEmail(email)) 113 | .then(user => { 114 | sendEmail({ to: email }, 'welcome', { 115 | user, 116 | name: userName(user), 117 | client, 118 | projectName, 119 | link: baseUrl + '/confirm?' + querystring.stringify({ emailConfirmationToken }) 120 | }) 121 | return user 122 | }) 123 | }) 124 | }, 125 | forgotPassword (email, client) { 126 | email = normalizeEmail(email) 127 | return createToken() 128 | .then(emailConfirmationToken => { 129 | return database.findUserByEmail(email) 130 | .then(user => { 131 | if (!user) { 132 | sendEmail({ to: email }, 'password_reset_help', { 133 | emailAddress: email, 134 | client, 135 | projectName, 136 | tryDifferentEmailUrl: baseUrl + '/resetpassword' 137 | }) 138 | return 139 | } 140 | return database.updateUser({ id: user.id, emailConfirmationToken }) 141 | .then(() => { 142 | sendEmail({ to: user.email }, 'password_reset', { 143 | user, 144 | name: userName(user), 145 | client, 146 | projectName, 147 | link: baseUrl + '/reset?' + querystring.stringify({ emailConfirmationToken }) 148 | }) 149 | }) 150 | }) 151 | }) 152 | }, 153 | resetPassword (token, password, client) { 154 | return database.findUserByEmailConfirmationToken(token) 155 | .then(user => { 156 | if (!user) return reject('EMAIL_CONFIRMATION_TOKEN_NOT_FOUND') 157 | return passwords.hash(user.email, password) 158 | .then(hash => database.updateUser({ 159 | id: user.id, 160 | password: hash, 161 | emailConfirmed: true, 162 | emailConfirmationToken: null 163 | })) 164 | }) 165 | }, 166 | changePassword (id, oldPassword, newPassword) { 167 | return database.findUserById(id) 168 | .then(user => { 169 | if (!user) return reject('USER_NOT_FOUND') 170 | return passwords.check(user.email, oldPassword, user.password) 171 | .then(ok => { 172 | if (!ok) return reject('INVALID_CREDENTIALS') 173 | return passwords.hash(user.email, newPassword) 174 | }) 175 | .then(hash => database.updateUser({ 176 | id: user.id, 177 | password: hash 178 | })) 179 | }) 180 | }, 181 | confirmEmail (token, password) { 182 | return database.findUserByEmailConfirmationToken(token) 183 | .then(user => { 184 | if (!user) return reject('EMAIL_CONFIRMATION_TOKEN_NOT_FOUND') 185 | return database.updateUser({ 186 | id: user.id, 187 | emailConfirmed: true, 188 | emailConfirmationToken: null 189 | }) 190 | }) 191 | }, 192 | useRecoveryCode (userId, token) { 193 | return database.findRecoveryCodesByUserId(userId) 194 | .then((codes) => { 195 | return crypto.decryptRecovery(codes) 196 | .then((decrypted) => { 197 | const toUse = _.find(decrypted, (code) => { 198 | return code.decrypted.toUpperCase() === token.toUpperCase() && code.used === false 199 | }) 200 | if (toUse) { 201 | return database.useRecoveryCode(userId, toUse.code) 202 | } else { 203 | return Promise.reject() 204 | } 205 | }) 206 | }) 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /test/e2e.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const superagent = require('superagent') 3 | const baseUrl = `http://127.0.0.1:${process.env.MICROSERVICE_PORT || 3000}` 4 | const jwt = require('jsonwebtoken') 5 | 6 | const settings = { 7 | JWT_ALGORITHM: 'HS256', 8 | JWT_SECRET: 'shhhh' 9 | } 10 | 11 | const env = require('../src/utils/env')(settings) 12 | const db = require('../src/database/adapter')(env) 13 | 14 | require('../').startServer(settings) // starts the app server 15 | 16 | // Declare some variables for storing things between tests 17 | let _jwtToken 18 | let _2FAtoken 19 | let userId 20 | // Random number so that we don't have unique key collisions 21 | const r = Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER) 22 | 23 | test('GET /auth/register', t => { 24 | t.plan(2) 25 | return superagent.get(`${baseUrl}/auth/register`) 26 | .then((response) => { 27 | t.is(response.statusCode, 200) 28 | t.truthy(response.text.indexOf('= 0) 29 | }) 30 | .catch((error) => { 31 | t.falsy(error) 32 | }) 33 | }) 34 | 35 | // Create a new account 36 | test.serial('POST /auth/register', t => { 37 | return superagent.post(`${baseUrl}/auth/register`) 38 | .send(`firstName=Ian`) 39 | .send(`lastName=McDevitt`) 40 | .send(`username=ian`) 41 | .send(`email=test%2B${r}@clevertech.biz`) 42 | .send(`password=thisistechnicallyapassword`) 43 | .then((response) => { 44 | t.truthy(response.text.indexOf('

Before signing in, please confirm your email address.') >= 0) 45 | }) 46 | .catch((error) => { 47 | t.falsy(error) 48 | }) 49 | }) 50 | 51 | // Mark that account as having a confirmed email address 52 | // Then sign into that account 53 | test.serial('POST /auth/signin', t => { 54 | return db.findUserByEmail(`test+${r}@clevertech.biz`) 55 | .then(user => { 56 | user.emailConfirmed = true 57 | return db.updateUser(user) 58 | .then((success) => { 59 | t.truthy(success) 60 | return superagent.post(`${baseUrl}/auth/signin`) 61 | .send(`email=test%2B${r}@clevertech.biz`) 62 | .send('password=thisistechnicallyapassword') 63 | .then((response) => { 64 | // Store the JWT for later use 65 | _jwtToken = response.body 66 | // Confirm that the JWT does indeed contain the data we want 67 | const decoded = jwt.decode(_jwtToken) 68 | t.is(decoded.user.email, `test+${r}@clevertech.biz`) 69 | }) 70 | .catch((error) => { 71 | t.falsy(error) 72 | }) 73 | }) 74 | }) 75 | }) 76 | 77 | // Mark the account as having QR twofactor authentication 78 | // Create two factor recovery codes 79 | test.serial('GET /auth/twofactorrecoveryregenerate', t => { 80 | return db.findUserByEmail(`test+${r}@clevertech.biz`) 81 | .then(user => { 82 | userId = user.id 83 | user.twofactor = 'qr' 84 | user.twofactorSecret = 'af4e0e215741432aa2d3aa8fbfa8f15da6ae3b1f90df15498aa471792728e513cd4d3add6235591354ec552336a1292c7b60499a.a3fe82198c6eb29349ffbed773701230.a261c30328f73ec693b9a1b208eb4935' 85 | return db.updateUser(user) 86 | .then((success) => { 87 | t.truthy(success) 88 | return superagent.get(`${baseUrl}/auth/twofactorrecoveryregenerate?jwt=${_jwtToken}`) 89 | .then((response) => { 90 | const body = response.text 91 | const codeDiv = '

' 92 | const codeDivLocation = body.indexOf(codeDiv) 93 | // Make sure the div actually showed up in the response 94 | t.truthy(codeDivLocation >= 0) 95 | 96 | _2FAtoken = body.substring(codeDivLocation + codeDiv.length, codeDivLocation + codeDiv.length + 8) 97 | // Make sure that the recovery code is an 8-character hexadecimal string 98 | t.truthy(/^[0-9A-F]{8}$/.test(_2FAtoken)) 99 | }) 100 | .catch((error) => { 101 | t.falsy(error) 102 | }) 103 | }) 104 | }) 105 | }) 106 | 107 | // Test that we can find the same 2FA codes that were just generated via POST /auth/twofactorrecoverycodes 108 | test.serial('GET /auth/twofactorrecoverycodes', t => { 109 | return superagent.get(`${baseUrl}/auth/twofactorrecoverycodes?jwt=${_jwtToken}`) 110 | .then((response) => { 111 | const body = response.text 112 | const codeDiv = '
' 113 | const codeDivLocation = body.indexOf(codeDiv) 114 | // Make sure the div actually showed up in the response 115 | t.truthy(codeDivLocation >= 0) 116 | 117 | const new2FAtoken = body.substring(codeDivLocation + codeDiv.length, codeDivLocation + codeDiv.length + 8) 118 | // Make sure that the recovery code is an 8-character hexadecimal string 119 | t.truthy(/^[0-9A-F]{8}$/.test(new2FAtoken)) 120 | t.is(_2FAtoken, new2FAtoken) 121 | }) 122 | .catch((error) => { 123 | t.falsy(error) 124 | }) 125 | }) 126 | 127 | // Confirm that an invalid code _does not_ allow us to sign in 128 | test.serial('POST /auth/twofactor invalid code', t => { 129 | return superagent.post(`${baseUrl}/auth/signin`) 130 | .send(`email=test%2B${r}@clevertech.biz`) 131 | .send('password=thisistechnicallyapassword') 132 | .then((response) => { 133 | t.truthy(response.redirects) 134 | t.truthy(response.redirects[0]) 135 | const redirect = response.redirects[0] 136 | // Store the new JWT 137 | _jwtToken = redirect.substring(redirect.indexOf('?jwt=') + 5, redirect.length) 138 | // Confirm that the JWT does indeed contain the data we want 139 | const decoded = jwt.decode(_jwtToken) 140 | t.is(decoded.userId, userId) 141 | 142 | return superagent.post(`${baseUrl}/auth/twofactor?jwt=${_jwtToken}`) 143 | .send(`token=ABCDEFGH`) 144 | .then((response) => { 145 | // Confirm that we were redirected 146 | t.truthy(response.redirects.length) 147 | }) 148 | .catch((error) => { 149 | console.log('Error:', error) 150 | t.truthy(error) 151 | }) 152 | }) 153 | }) 154 | 155 | // Confirm that the code captured above allows us to sign in 156 | test.serial('POST /auth/twofactor valid code', t => { 157 | return superagent.post(`${baseUrl}/auth/signin`) 158 | .send(`email=test%2B${r}@clevertech.biz`) 159 | .send('password=thisistechnicallyapassword') 160 | .then((response) => { 161 | t.truthy(response.redirects) 162 | t.truthy(response.redirects[0]) 163 | const redirect = response.redirects[0] 164 | // Store the new JWT 165 | _jwtToken = redirect.substring(redirect.indexOf('?jwt=') + 5, redirect.length) 166 | // Confirm that the JWT does indeed contain the data we want 167 | const decoded = jwt.decode(_jwtToken) 168 | t.is(decoded.userId, userId) 169 | 170 | return superagent.post(`${baseUrl}/auth/twofactor?jwt=${_jwtToken}`) 171 | .send(`token=${_2FAtoken}`) 172 | .then((response) => { 173 | // Confirm that the JWT does indeed contain the data we want 174 | const decoded = jwt.decode(response.body) 175 | t.is(decoded.user.email, `test+${r}@clevertech.biz`) 176 | }) 177 | .catch((error) => { 178 | t.falsy(error) 179 | }) 180 | }) 181 | }) 182 | 183 | // Confirm that the code captured above DOES NOT allow us to sign in a second time 184 | test.serial('POST /auth/twofactor duplicate code', t => { 185 | return superagent.post(`${baseUrl}/auth/signin`) 186 | .send(`email=test%2B${r}@clevertech.biz`) 187 | .send('password=thisistechnicallyapassword') 188 | .then((response) => { 189 | t.truthy(response.redirects) 190 | t.truthy(response.redirects[0]) 191 | const redirect = response.redirects[0] 192 | // Store the new JWT 193 | _jwtToken = redirect.substring(redirect.indexOf('?jwt=') + 5, redirect.length) 194 | // Confirm that the JWT does indeed contain the data we want 195 | const decoded = jwt.decode(_jwtToken) 196 | t.is(decoded.userId, userId) 197 | 198 | return superagent.post(`${baseUrl}/auth/twofactor?jwt=${_jwtToken}`) 199 | .send(`token=${_2FAtoken}`) 200 | .then((response) => { 201 | // Confirm that we were redirected 202 | t.truthy(response.redirects.length) 203 | }) 204 | .catch((error) => { 205 | console.log('Error:', error) 206 | t.truthy(error) 207 | }) 208 | }) 209 | }) 210 | 211 | // Confirm that disabling 2FA deletes recovery codes 212 | test.serial('POST /auth/configuretwofactordisable deletes recovery codes', t => { 213 | return superagent.post(`${baseUrl}/auth/configuretwofactordisable?jwt=${_jwtToken}`) 214 | .then((response) => { 215 | t.truthy(response.redirects) 216 | t.truthy(response.redirects[0]) 217 | db.findRecoveryCodesByUserId(userId) 218 | .then((codes) => { 219 | t.is(codes, []) 220 | }) 221 | }) 222 | .catch((error) => { 223 | t.falsy(error) 224 | }) 225 | }) 226 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('./include-head.html', { projectName, title }) %> 5 | <%- include('./include-head-recaptcha.html', { recaptchaSiteKey }) %> 6 | 7 | 8 | <%- include('./include-recaptcha.html', { recaptchaSiteKey }) %> 9 | <%- include('./include-alert.html', { error, info }) %> 10 |
11 |
12 |

13 |
14 | Sign in to your account 15 |
16 |

17 |
18 |
19 |
20 | 21 |
22 | 23 | 24 |
25 |
26 |
27 | 28 |
29 | 30 | 31 |
32 |
33 | 34 | <%- include('./include-providers.html', { someProvidersAvailable, availableProviders }) %> 35 |
36 | 37 |
38 | 39 |
40 | 41 |
42 |

New to us? Register

43 |

Forgot password? Request a password reset

44 |
45 |
46 |
47 |
48 |
49 |

50 |
51 | Register a new account 52 |
53 |

54 |
55 | <% if (provider) { %> 56 | 57 | <% } %> 58 |
59 | <% if (imageField) { %> 60 |
61 |
62 | 63 | 64 | 65 |
66 |
Select new image
67 |
68 |
69 | <% } %> 70 | <% if (userInfo.icon && userInfo.description) { %> 71 |

<%= userInfo.description %>

72 | <% } %> 73 | <% for (const field of signupFields) { %> 74 | <% if (field.type !== 'text') continue %> 75 |
76 | 77 |
78 | 79 | 80 |
81 |
82 | <% } %> 83 |
84 | 85 |
86 | 87 | 88 |
89 |
90 | <% if (!provider) { %> 91 |
92 | 93 |
94 | 95 | 96 |
97 |
98 | <% } %> 99 | <% if (termsAndConditions) { %> 100 |
101 |
102 | 103 | 104 |
105 |
106 | <% } %> 107 | 108 | <% if (!provider) { %> 109 | <%- include('./include-providers.html', { someProvidersAvailable, availableProviders }) %> 110 | <% } %> 111 |
112 | 113 |
114 | 115 |
116 | 117 |
118 |

Already have an account? Sign In

119 | <% if (provider) { %> 120 |

Or Register with another account

121 | <% } %> 122 |
123 |
124 |
125 |
126 |
127 |

128 |
129 | Reset your password 130 |
131 |

132 |
133 |
134 |
135 | 136 |
137 | 138 | 139 |
140 |
141 | 142 |
143 | 144 |
145 | 146 |
147 | 148 |
149 |

Already have an account? Sign In

150 |

New to us? Register

151 |
152 |
153 |
154 |
155 |
156 |

157 |
158 | Reset your password 159 |
160 |

161 |
162 | 163 |
164 |
165 | 166 |
167 | 168 | 169 |
170 |
171 | 172 |
173 | 174 |
175 | 176 |
177 | 178 |
179 |

Already have an account? Sign In

180 |

New to us? Register

181 |
182 |
183 |
184 |
185 |
186 |

187 |
188 | Change your password 189 |
190 |

191 |
192 | 193 |
194 |
195 | 196 |
197 | 198 | 199 |
200 |
201 |
202 | 203 |
204 | 205 | 206 |
207 |
208 | 209 |
210 | 211 |
212 |
213 |
214 |
215 |
216 |
217 |

218 |
219 | Change your email address 220 |
221 |

222 |
223 | 224 |
225 |
226 | 227 |
228 | 229 | 230 |
231 |
232 |
233 | 234 |
235 | 236 | 237 |
238 |
239 | 240 |
241 | 242 |
243 | 244 |
245 |
246 |
247 | <%- include('./include-javascript.html', { forms }) %> 248 | 249 | 250 | -------------------------------------------------------------------------------- /src/starter.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const ncp = require('ncp').ncp 6 | const inquirer = require('inquirer') 7 | const dedent = require('dedent') 8 | 9 | const availableFields = require('./constants').availableFields 10 | 11 | const normalize = str => (str.toLowerCase().match(/\w+/g) || []).join('-') 12 | const envFile = path.join(process.cwd(), '.env') 13 | const envFileExists = fs.existsSync(envFile) 14 | 15 | console.log('This utility will help you generate a .env file and optionally email templates to use pnp-authentication-service') 16 | 17 | Promise.resolve() 18 | .then(() => { 19 | if (envFileExists) { 20 | const question = { 21 | type: 'confirm', 22 | message: 'The .env file already exists. Do you want to override it?', 23 | name: '_override', 24 | filter: (dont) => console.log('dont', dont) || dont && process.exit(0) 25 | } 26 | return inquirer.prompt([question]) 27 | .then((answers) => { 28 | if (!answers._override) process.exit(0) 29 | }) 30 | } 31 | }) 32 | .then(() => { 33 | const questions = [] 34 | questions.push({ 35 | type: 'input', 36 | message: 'What\'s the name of the project?', 37 | name: 'AUTH_PROJECT_NAME', 38 | default: path.basename(process.cwd()) 39 | }) 40 | 41 | questions.push({ 42 | type: 'input', 43 | message: 'What\'s the URL where the microservice will be served from?', 44 | name: 'AUTH_BASE_URL', 45 | default: 'http://localhost:3000/auth' 46 | }) 47 | 48 | questions.push({ 49 | type: 'input', 50 | message: 'Which DBMS will you use? (pg, mysql, mongodb)', 51 | name: 'DATABASE_ENGINE', 52 | default: answers => `pg` 53 | }) 54 | 55 | questions.push({ 56 | type: 'input', 57 | message: 'What\'s the database URL?', 58 | name: 'DATABASE_URL', 59 | default: (answers) => `postgresql://localhost/${normalize(answers.AUTH_PROJECT_NAME)}` 60 | }) 61 | 62 | questions.push({ 63 | type: 'checkbox', 64 | message: 'Which additional fields you want users to have?', 65 | name: 'AUTH_SIGNUP_FIELDS', 66 | choices: Object.keys(availableFields) 67 | }) 68 | 69 | questions.push({ 70 | type: 'input', 71 | message: 'What URL users will be redirected to after login/register?', 72 | name: 'AUTH_REDIRECT_URL', 73 | default: 'http://localhost:3000/callback' 74 | }) 75 | 76 | questions.push({ 77 | type: 'confirm', 78 | message: 'Do you want to support signin with Facebook?', 79 | name: '_facebook' 80 | }) 81 | 82 | questions.push({ 83 | type: 'input', 84 | message: 'What\'s your Facebook app id?', 85 | name: 'FACEBOOK_APP_ID', 86 | when: (answers) => answers._facebook 87 | }) 88 | 89 | questions.push({ 90 | type: 'input', 91 | message: 'What\'s your Facebook app secret?', 92 | name: 'FACEBOOK_APP_SECRET', 93 | when: (answers) => answers._facebook 94 | }) 95 | 96 | questions.push({ 97 | type: 'confirm', 98 | message: 'Do you want to support signin with Google?', 99 | name: '_google' 100 | }) 101 | 102 | questions.push({ 103 | type: 'input', 104 | message: 'What\'s your Google app client id?', 105 | name: 'GOOGLE_CLIENT_ID', 106 | when: (answers) => answers._google 107 | }) 108 | 109 | questions.push({ 110 | type: 'input', 111 | message: 'What\'s your Google app client secret?', 112 | name: 'GOOGLE_CLIENT_SECRET', 113 | when: (answers) => answers._google 114 | }) 115 | 116 | questions.push({ 117 | type: 'confirm', 118 | message: 'Do you want to make email confirmation mandatory?', 119 | name: 'AUTH_EMAIL_CONFIRMATION' 120 | }) 121 | 122 | questions.push({ 123 | type: 'confirm', 124 | message: 'Even for people that have signed in with third party services?', 125 | name: 'AUTH_EMAIL_CONFIRMATION_PROVIDERS', 126 | when: (answers) => answers.AUTH_EMAIL_CONFIRMATION && (answers.facebook || answers.google) 127 | }) 128 | 129 | questions.push({ 130 | type: 'input', 131 | message: 'Optionally specify a URL for the Terms and Conditions', 132 | name: 'AUTH_TERMS_AND_CONDITIONS' 133 | }) 134 | 135 | questions.push({ 136 | type: 'confirm', 137 | message: 'Do you want to use ReCAPTCHA?', 138 | name: '_recaptcha' 139 | }) 140 | 141 | questions.push({ 142 | type: 'input', 143 | message: 'What\'s your ReCAPTCHA site key?', 144 | name: 'RECAPTCHA_SITE_KEY', 145 | when: (answers) => answers._recaptcha 146 | }) 147 | 148 | questions.push({ 149 | type: 'input', 150 | message: 'What\'s your ReCAPTCHA secret key?', 151 | name: 'RECAPTCHA_SECRET_KEY', 152 | when: (answers) => answers._recaptcha 153 | }) 154 | 155 | questions.push({ 156 | type: 'input', 157 | message: 'What JWT algorithm you want to use?', 158 | name: 'JWT_ALGORITHM', 159 | default: 'HS256' 160 | }) 161 | 162 | questions.push({ 163 | type: 'input', 164 | message: 'Specify a JWT secret (the one suggested has just been randomnly generated)', 165 | name: 'JWT_SECRET', 166 | default: require('crypto').randomBytes(128 / 8).toString('hex'), 167 | when: (answers) => (answers.JWT_ALGORITHM || '').startsWith('H') 168 | }) 169 | 170 | questions.push({ 171 | type: 'confirm', 172 | message: 'Do you want to support 2 Factor Authentication?', 173 | name: '_2fa' 174 | }) 175 | 176 | questions.push({ 177 | type: 'input', 178 | message: 'Specify a symmetric key for storing 2FA users\' seeds (the one suggested has just been randomnly generated)', 179 | name: 'SYMMETRIC_KEY', 180 | default: require('crypto').randomBytes(128 / 8).toString('hex'), 181 | when: (answers) => answers._2fa 182 | }) 183 | 184 | questions.push({ 185 | type: 'input', 186 | message: 'Specify a symmetric algorithm for storing 2FA users\' seeds', 187 | name: 'SYMMETRIC_ALGORITHM', 188 | default: 'aes-256-gcm', 189 | when: (answers) => answers._2fa 190 | }) 191 | 192 | questions.push({ 193 | type: 'confirm', 194 | message: 'Do you want to support sending SMS with Twilio for 2 Factor Authentication?', 195 | name: '_twilio', 196 | when: (answers) => answers._2fa 197 | }) 198 | 199 | questions.push({ 200 | type: 'input', 201 | message: 'Specify your Twilio Account SID', 202 | name: 'TWILIO_ACCOUNT_SID', 203 | when: (answers) => answers._twilio 204 | }) 205 | 206 | questions.push({ 207 | type: 'input', 208 | message: 'Specify your Twilio Auth Token', 209 | name: 'TWILIO_AUTH_TOKEN', 210 | when: (answers) => answers._twilio 211 | }) 212 | 213 | questions.push({ 214 | type: 'input', 215 | message: 'Specify your Twilio number from which messages will be sent (you can use an Alphanumeric Sender ID)', 216 | name: 'TWILIO_NUMBER_FROM', 217 | when: (answers) => answers._twilio 218 | }) 219 | 220 | questions.push({ 221 | type: 'input', 222 | message: 'What Amazon SES email from address do you want to use?', 223 | name: 'EMAIL_DEFAULT_FROM' 224 | }) 225 | 226 | questions.push({ 227 | type: 'input', 228 | message: 'What Amazon Key do you want to use? (required for Amazon SES)', 229 | name: 'AWS_KEY' 230 | }) 231 | 232 | questions.push({ 233 | type: 'input', 234 | message: 'What Amazon Secret do you want to use? (required for Amazon SES)', 235 | name: 'AWS_SECRET' 236 | }) 237 | 238 | questions.push({ 239 | type: 'list', 240 | message: 'What Amazon Region do you want to use? (required for Amazon SES)', 241 | name: 'AWS_REGION', 242 | choices: [ 243 | 'us-east-1', 244 | 'us-east-2', 245 | 'us-west-1', 246 | 'us-west-2', 247 | 'ca-central-1', 248 | 'eu-west-1', 249 | 'eu-central-1', 250 | 'eu-west-2', 251 | 'ap-northeast-1', 252 | 'ap-northeast-2', 253 | 'ap-southeast-1', 254 | 'ap-southeast-2', 255 | 'ap-south-1', 256 | 'sa-east-1' 257 | ] 258 | }) 259 | 260 | questions.push({ 261 | type: 'input', 262 | message: 'What Amazon S3 bucket you want to upload the user images to', 263 | name: 'AWS_S3_BUCKET', 264 | when: (answers) => answers.AUTH_SIGNUP_FIELDS.indexOf('image') >= 0 265 | }) 266 | 267 | questions.push({ 268 | type: 'confirm', 269 | message: `Do you want to copy the email templates to your project?`, 270 | name: '_emailTemplates' 271 | }) 272 | 273 | questions.push({ 274 | type: 'input', 275 | message: `Where do you want to copy the email templates to?`, 276 | name: '_emailTemplatesDir', 277 | default: path.join(process.cwd(), 'templates'), 278 | when: (answers) => answers._emailTemplates 279 | }) 280 | 281 | questions.push({ 282 | type: 'confirm', 283 | message: `Do you use Express.js?`, 284 | name: '_expressSnippet' 285 | }) 286 | 287 | let _expressSnippet = null 288 | return inquirer.prompt(questions) 289 | .then((answers) => { 290 | _expressSnippet = answers._expressSnippet 291 | const data = Object.keys(answers).reduce((arr, key) => { 292 | if (!key.startsWith('_')) { 293 | const value = answers[key] 294 | const str = Array.isArray(value) ? value.join(',') : String(value) 295 | arr.push(`${key}=${str}`) 296 | } 297 | return arr 298 | }, []).join('\n') 299 | fs.writeFileSync(envFile, data, 'utf8') 300 | 301 | const destination = answers._emailTemplatesDir 302 | if (destination) { 303 | const source = path.join(__dirname, '..', 'templates') 304 | return new Promise((resolve, reject) => { 305 | ncp(source, destination, (err) => err ? reject(err) : resolve()) 306 | }) 307 | } 308 | }) 309 | .then(() => { 310 | if (_expressSnippet) { 311 | const code = dedent` 312 | require('dotenv').config() // for reading the .env file 313 | 314 | const { createJwtClient, createRouter } = require('pnp-authentication-service') 315 | const config = { EMAIL_TEMPLATES_DIR: path.join(__dirname, 'templates') } // adjust if necessary 316 | const jwt = createJwtClient(config) 317 | 318 | // If you want to run it as an express router 319 | app.use('/auth', createRouter(config)) 320 | 321 | // Callback URL. Adjust if necessary 322 | app.get('/callback', (req, res, next) => { 323 | jwt.verify(req.query.jwt) 324 | .then(data => { 325 | // User information inside \`data.user\` 326 | // Handle that information here 327 | res.redirect('/home') 328 | }) 329 | .catch(next) 330 | }) 331 | ` 332 | console.log('// Here you have a code snippet to integrate pnp-authentication-service into your app') 333 | console.log(code) 334 | console.log() 335 | } 336 | console.log('Done!') 337 | }) 338 | }) 339 | .catch((err) => { 340 | console.error(err) 341 | }) 342 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Authentication service 2 | 3 | This microservice exposes a web UI that implements all the flow and features for authenticating users. Such as: 4 | 5 | - Sign in 6 | - Registering a new account 7 | - Forgot password 8 | - Change email 9 | - Change password 10 | 11 | The communication between your app and the microservice is by using simple redirects and JWT tokens. You redirect the user to the signin / register URLs and when the user is authenticated it is redirected to a callback endpoint where you get a JWT token that needs to be verified. 12 | 13 | There are many configuration options such as: 14 | 15 | - Can authenticate users with email + password and optionally with third party services (facebook, google, etc.) 16 | - Customizable fields when registering a new account (optinal first name, last name, company name, etc.) 17 | - Optional terms and conditions checkbox 18 | - Imports users's image from third party services 19 | - Whether asking for email confirmation 20 | 21 | The microservice requires a PostgreSQL database (other databases will be supported soon). The microservice creates the tables needed if they don't exist. 22 | 23 | ## Running as a command line application 24 | 25 | The npm package configures an `pnp-authentication-service` executable. You will pass configuration options through ENV variables. Check the configuration options below. 26 | 27 | Then visit `http://127.0.0.1:3000/auth` 28 | 29 | ## Usage as an express router 30 | 31 | Basically you create an express router, mount it in some path (such as `/auth`) then you can redirect your users to `/auth/signin` or `/auth/register` and once they login or register they will be redirected to a callback endpoint with a JWT token that you will verify to get the user data. 32 | 33 | ### Basic example 34 | 35 | ```javascript 36 | const app = express() 37 | const { createJwtClient, createRouter } = require('pnp-authentication-service') 38 | 39 | const config = { EMAIL_TEMPLATES_DIR: path.join(__dirname, 'templates') } 40 | const jwt = createJwtClient(config) 41 | const router = createRouter(config) 42 | app.use('/auth', router) 43 | 44 | app.get('/callback', (req, res, next) => { 45 | jwt.verify(req.query.jwt) 46 | .then(data => { 47 | const user = _.pick(data.user, ['id', 'firstName', 'lastName', 'image']) 48 | req.session.user = user 49 | res.redirect('/home') 50 | }) 51 | .catch(next) 52 | }) 53 | ``` 54 | 55 | ### Full example 56 | 57 | You can find a [full example here](https://github.com/gimenete/authentication-service-example/blob/master/index.js) 58 | 59 | That example application can be [tested online](https://pnp-authentication-service.herokuapp.com) 60 | 61 | ## Email integration 62 | 63 | Behind the scenes this microservice uses other microservice for sending emails [email service](https://github.com/clevertech/email-service). You will need to set up correctly its [configuration options](https://github.com/clevertech/email-service#configuration-options) such as: 64 | 65 | ``` 66 | EMAIL_DEFAULT_FROM=hello@yourserver.com 67 | EMAIL_TRANSPORT=ses 68 | AWS_KEY=xxxx 69 | AWS_SECRET=xxxx 70 | AWS_REGION=us-east-1 71 | ``` 72 | 73 | You need also to configure where your email templates are with `EMAIL_TEMPLATES_DIR`. That is better done from code if you are mounting the microservice as an express router: 74 | 75 | ```javascript 76 | const config = { EMAIL_TEMPLATES_DIR: path.join(__dirname, 'templates') } 77 | const router = createRouter(config) 78 | app.use('/auth', router) 79 | ``` 80 | 81 | You will find [example templates here](https://github.com/clevertech/authentication-service/tree/master/templates/en). 82 | 83 | ## Configuration options 84 | 85 | __Quickly getting started__: if you want to use an `.env` file you can run `pnp-authentication-service-starter` which will guide you to configure almost all configuration options, will copy the email templates and will even give you a code snippet to integrate `pnp-authentication-service` in your app. 86 | 87 | All configuration options can be configured using ENV variables. If using it as an express router, then configuration variables can also be passed as an argument to this method. All ENV variables can be prefixed with `AUTH_`. Since one value can be configured in many ways some take precedence over others. For example for the `DEFAULT_FROM` variable the value used will be the first found following this list: 88 | 89 | - `AUTH_PROJECT_NAME` parameter passed to `createRouter()` 90 | - `PROJECT_NAME` parameter passed to `createRouter()` 91 | - `AUTH_PROJECT_NAME` ENV variable 92 | - `PROJECT_NAME` ENV variable 93 | 94 | This is the list of available configuration options: 95 | 96 | | Variable | Description | 97 | | --- | --- | 98 | | `DATABASE_ENGINE` | The engine to use for your database of choice. Supported values: [`pg`, `mysql`, `mongo`] | 99 | | `DATABASE_URL` | Connection string for your database. Example: `postgresql://user:pass@host/database` | 100 | | `EMAIL_CONFIRMATION_PROVIDERS` | Set to true if you want to send a confirmation email to your users to confirm their email addresses even when they signup with third party services such as Facebook | 101 | | `EMAIL_CONFIRMATION` | Set to true if you want to send a confirmation email to your users to confirm their email addresses | 102 | | Email transport configuration | There are a number of configuration options used to control email sending behavior. See [the `pnp-email-service` README](https://github.com/clevertech/email-service#configuration-options) for more information. | 103 | | `FACEBOOK_APP_ID` | Required if you want to sign in your users with Facebook | 104 | | `FACEBOOK_APP_SECRET` | Required if you want to sign in your users with Facebook | 105 | | `GOOGLE_CLIENT_ID` | Required if you want to sign in your users with Google | 106 | | `GOOGLE_CLIENT_SECRET` | Required if you want to sign in your users with Google | 107 | | `JWT_ALGORITHM` | The algorithm to be used in the JWT tokens. `HS256` by default | 108 | | `JWT_EXPIRES_IN` | Optional. Default `expiresIn` value when generating JWT tokens | 109 | | `JWT_NOT_BEFORE` | Optional. Default `notBefore` value when generating JWT tokens | 110 | | `JWT_PRIVATE_KEY` | The PEM encoded private key for RSA and ECDSA algorithms | 111 | | `JWT_PUBLIC_KEY` | The PEM encoded public key for RSA and ECDSA algorithms | 112 | | `JWT_SECRET` | The JWT secret to be used when a HMAC algorithm is being used (such as for `HS256`) | 113 | | `PROJECT_NAME` | Your project's name. Will be used in emails, SMS messages, and page titles. | 114 | | `RECAPTCHA_SECRET_KEY` | If you want to use reCAPTCHA, set this configuration option and all forms will require to pass through reCAPTCHA | 115 | | `RECAPTCHA_SITE_KEY` | If you want to use reCAPTCHA, set this configuration option and all forms will require to pass through reCAPTCHA | 116 | | `REDIRECT_URL` | Callback URL that the user will be redirected to when authenticated | 117 | | `SIGNUP_FIELDS` | List of additional fields for the sign up form separated by commas. Available values are: name, firstName, lastName, company, address, city, state, zip, country | 118 | | `STYLESHEET` | Optionally specify a URL with the stylesheet to be used in the authentication service. The default one can be found in `http://localhost:3000/auth/stylesheet.css` (change the URL if you are running the microservice somewhere else) | 119 | | `SYMMETRIC_ALGORITHM` | Optional. It's the algorithm used for encrypting users's 2FA seeds. Defaults to `aes-256-gcm` | 120 | | `SYMMETRIC_KEY` | Optional. Required for 2FA. This is the key that will be used for encrypting users's 2FA seeds. You can easily create a key using `require('crypto').randomBytes(128 / 8).toString('hex')` on a Node.js interactive prompt. This generates a secure random 128bit key encoded as hexadecimal | 121 | | `TERMS_AND_CONDITIONS` | Optionally specify the URL to the terms and conditions. If you specify one, a checkbox will be added with a link to them and the user will be required to accept the terms for signing up. Then this value is stored in the database, so you can for example specify a different URL every time you update the terms and conditions and you will know which version of the terms and conditions the user accepted. | 122 | | `TWILIO_ACCOUNT_SID` | Optional. Configure this for adding SMS support for 2FA | 123 | | `TWILIO_AUTH_TOKEN` | Optional. Configure this for adding SMS support for 2FA | 124 | | `TWILIO_NUMBER_FROM` | Optional. Configure this for adding SMS support for 2FA | 125 | 126 | The simplest JWT configuration is just setting up the `JWT_SECRET` value. 127 | 128 | Any and all configuration options can be optionally prepended with `AUTH_`, while any Email configration can be prepended with `EMAIL_`, if you prefer to differentiate them. 129 | 130 | ## Configuration example 131 | 132 | ``` 133 | AUTH_BASE_URL=http://yourserver/auth 134 | AUTH_DATABASE_URL=postgresql://localhost/database 135 | AUTH_SIGNUP_FIELDS=firstName,lastName,company 136 | AUTH_PROJECT_NAME=Your project name 137 | AUTH_FACEBOOK_APP_ID=xxxx 138 | AUTH_FACEBOOK_APP_SECRET=xxxx 139 | AUTH_REDIRECT_URL=http://yourserver/callback 140 | AUTH_EMAIL_CONFIRMATION=true 141 | AUTH_STYLESHEET=http://yourserver/stylesheet.css 142 | JWT_SECRET=shhhh 143 | 144 | EMAIL_DEFAULT_FROM=hello@yourserver.com 145 | EMAIL_TRANSPORT=ses 146 | AWS_KEY=xxxx 147 | AWS_SECRET=xxxx 148 | AWS_REGION=us-east-1 149 | ``` 150 | 151 | ## Two factor authentication 152 | 153 | Two factor authentication is optional. If you want to allow your users to have 2FA you just need to redirect them to `/auth/configuretwofactor?jwt=${jwtToken}`. The `jwtToken` only needs the `userId`: 154 | 155 | ```javascript 156 | jwt.sign({ userId: user.id }) 157 | .then(jwtToken => { 158 | // redirect or use the `jwtToken` in a template 159 | }) 160 | ``` 161 | 162 | There are two supported mechanisms for 2FA: via authenticator app (such as Google Authenticator) or via SMS. If you want to enable SMS you will need to configure the `TWILIO_xxx` env variables. 163 | 164 | You will also need to configure a `SYMMETRIC_KEY` that will be used to encrypt users's 2FA seeds. 165 | 166 | ## Change password 167 | 168 | To allow a user to change his/her password you just need to redirect him/her to `/auth/changepassword?jwt=${jwtToken}`. The `jwtToken` only needs the `userId`: 169 | 170 | ```javascript 171 | jwt.sign({ userId: user.id }) 172 | .then(jwtToken => { 173 | // redirect or use the `jwtToken` in a template 174 | }) 175 | ``` 176 | 177 | ## Security 178 | 179 | This microservice is intended to be very secure. 180 | 181 | ### Forgot password functionality 182 | 183 | When an unknown email address is used in this functionality, an email is sent to that email address telling the user somebody tried to get access to that account. The email conteins information about the OS and browser versions used. 184 | 185 | This way: 186 | 187 | - We don't inform the attacker whether the account exists or not 188 | - The user is informed about an attempt to get access to the account 189 | 190 | ### Passwords 191 | 192 | Passwords are hashed with a `kdf` derivation that uses the `scrypt` hash function that incorporates HMAC (protecting against length extension attacks) into its format. More information [here](https://security.stackexchange.com/questions/88678/why-does-node-js-scrypt-function-use-hmac-this-way/91050#91050). The email address is also used as an additional salt so 193 | 194 | - It's impossible to swap the hash between two users 195 | - A user can only change his email address knowing his password 196 | 197 | Why `scrypt`: 198 | 199 | - Because it was specifically designed to make it costly to perform large-scale custom hardware attacks by requiring large amounts of memory 200 | - Protects against brute-force attacks because it is computationally intensive 201 | 202 | ### JWT 203 | 204 | JWT is used for exchanging information between the microservice and your app. You can configure the JWT algorithm (check the configuration options above). You can choose between just hashing with HMAC or using private key algorithms such as RSA and ECDSA. 205 | 206 | JWT is also used for passing information around between some redirects. For example when a user signs up with Facebook and needs to accept the terms and conditions or confirm or fill more information to sign up. In that case the Facebook accessToken and other information is passed in the URL inside a JWT token. 207 | 208 | Email confirmation tokens are JWT tokens with a expiration date. This could be enough, but we also make the token contain a random value and we store it in the database. So one user can only have one confirmation token at a time and can be used only once. 209 | 210 | ### To be done regarding security 211 | 212 | - Protection against brute force attacks slowing down the server response: 213 | - From same IP 214 | - To the same login 215 | - Using the same password 216 | - Password strength calculator 217 | - Re-confirm email after long inactivity 218 | -------------------------------------------------------------------------------- /templates/en/welcome-body-html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Welcome to <%= projectName %>, <%= name %>! 7 | 12 | 389 | 390 | 391 | Thanks for trying out <%= projectName %>. We’ve pulled together some information and resources to help you get started. 392 | 393 | 394 | 467 | 468 | 469 | 470 | 471 | -------------------------------------------------------------------------------- /templates/en/password_reset-body-html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Set up a new password for <%= projectName %> 7 | 12 | 389 | 390 | 391 | Use this link to reset your password. The link is only valid for 24 hours. 392 | 393 | 394 | 469 | 470 | 471 | 472 | 473 | -------------------------------------------------------------------------------- /templates/en/password_reset_help-body-html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Set up a new password for <%= projectName %> 7 | 12 | 389 | 390 | 391 | We received a request to reset your password with this email address. (<%= emailAddress %>) 392 | 393 | 394 | 469 | 470 | 471 | 472 | 473 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const querystring = require('querystring') 4 | const path = require('path') 5 | const express = require('express') 6 | const bodyParser = require('body-parser') 7 | const winston = require('winston') 8 | const ejs = require('ejs') 9 | const emailService = require('pnp-email-service') 10 | const mediaService = require('pnp-media-service') 11 | const fetch = require('node-fetch') 12 | const useragent = require('useragent') 13 | const speakeasy = require('speakeasy') 14 | const QRCode = require('qrcode') 15 | const i18n = require('i18n') 16 | i18n.configure({ 17 | locales: ['en'], 18 | defaultLocale: 'en', 19 | directory: path.join(__dirname, '/locales'), 20 | updateFiles: false 21 | }) 22 | 23 | const providers = { 24 | google: require('./providers/google'), 25 | twitter: require('./providers/twitter'), 26 | facebook: require('./providers/facebook'), 27 | linkedin: require('./providers/linkedin'), 28 | github: require('./providers/github') 29 | } 30 | 31 | const qrForUrl = url => new Promise((resolve, reject) => { 32 | QRCode.toDataURL(url, (err, qrCode) => err ? reject(err) : resolve(qrCode)) 33 | }) 34 | 35 | exports.createJwtClient = (config = {}) => { 36 | const env = require('./utils/env')(config) 37 | const jwt = require('./utils/jwt')(env) 38 | return jwt 39 | } 40 | 41 | exports.createRouter = (config = {}) => { 42 | const env = require('./utils/env')(config) 43 | const jwt = require('./utils/jwt')(env) 44 | const crypto = require('./utils/crypto')(env) 45 | const validations = require('./validations')(env) 46 | const recaptcha = require('./recaptcha')(env, fetch) 47 | const database = require('./database/adapter')(env) 48 | const emailServer = emailService.startServer(config) 49 | const smsService = require('./sms/twilio')(env, fetch) 50 | const sendEmail = (emailOptions, templateName, templateOptions) => { 51 | const port = emailServer.address().port 52 | const url = `http://0.0.0.0:${port}/email/send` 53 | const body = { templateName, emailOptions, templateOptions } 54 | return fetch(url, { 55 | method: 'POST', 56 | body: JSON.stringify(body), 57 | headers: { 'Content-Type': 'application/json' } 58 | }) 59 | .catch((err) => { 60 | winston.error(err) 61 | return Promise.reject(err) 62 | }) 63 | } 64 | const mediaClient = mediaService.createServerAndClient({}) 65 | const users = require('./services/users')(env, jwt, database, sendEmail, mediaClient, validations) 66 | 67 | database.init().catch(err => console.error(err.stack)) 68 | 69 | const views = env('VIEWS_DIR') || path.join(__dirname, '..', 'views') 70 | const baseUrl = env('BASE_URL') 71 | const projectName = env('PROJECT_NAME') 72 | const redirectUrl = env('REDIRECT_URL') 73 | const stylesheet = env('STYLESHEET', baseUrl + '/stylesheet.css') 74 | const emailConfirmation = env('EMAIL_CONFIRMATION', 'true') === 'true' 75 | const emailConfirmationProviders = emailConfirmation && env('EMAIL_CONFIRMATION_PROVIDERS', 'true') === 'true' 76 | const redirect = (user) => 77 | jwt.sign({ user }, { expiresIn: '1h' }).then(token => redirectUrl + '?jwt=' + token) 78 | 79 | const signupRedirect = (user) => 80 | jwt.sign({ user }, { expiresIn: '1h' }).then(token => baseUrl + '/register?provider=' + token) 81 | 82 | const redirectToDone = (res, qs) => { 83 | res.redirect(baseUrl + '/done?' + querystring.stringify(qs)) 84 | } 85 | 86 | const redirectTwofactor = (user) => { 87 | if (user.twofactor) { 88 | return Promise.resolve() 89 | .then(() => { 90 | if (user.twofactor === 'sms') { 91 | return crypto.decrypt(user.twofactorSecret) 92 | .then(secret => { 93 | const phone = user.twofactorPhone 94 | const token = speakeasy.totp({ secret, encoding: 'base32' }) 95 | return smsService.send(phone, `${token} is your ${projectName} verification code`) 96 | }) 97 | } 98 | }) 99 | .then(() => { 100 | return jwt.sign({ userId: user.id }, { expiresIn: '1h' }).then(token => baseUrl + '/twofactor?jwt=' + token) 101 | }) 102 | } 103 | return redirect(user).then(url => url) 104 | } 105 | 106 | const providerSignup = (user, res) => { 107 | return database.findUserByProviderLogin(user.login) 108 | .then(existingUser => { 109 | if (existingUser) { 110 | if (emailConfirmationProviders && !existingUser.emailConfirmed) { 111 | return baseUrl + '/signin?error=EMAIL_CONFIRMATION_REQUIRED' 112 | } 113 | return redirectTwofactor(existingUser) 114 | } 115 | return signupRedirect(user) 116 | }) 117 | .then(url => res.redirect(url)) 118 | } 119 | 120 | const router = express.Router() 121 | router.use(bodyParser.urlencoded({ extended: false })) 122 | router.use(recaptcha.middleware()) 123 | router.use(i18n.init) 124 | 125 | const availableProviders = Object.keys(providers).reduce((obj, provider) => { 126 | obj[provider] = providers[provider](router, providerSignup, env, database) 127 | return obj 128 | }, {}) 129 | const someProvidersAvailable = Object.keys(availableProviders) 130 | .map(key => availableProviders[key]) 131 | .filter(Boolean).length > 0 132 | 133 | const client = req => { 134 | const agent = useragent.lookup(req.headers['user-agent']) 135 | return { 136 | agent: agent.toAgent(), 137 | os: agent.os.toString(), 138 | device: agent.device.toString(), 139 | ip: req.ip 140 | } 141 | } 142 | 143 | const authenticated = (req, res, next) => { 144 | const token = req.query.jwt || req.body.jwt 145 | jwt.verify(token) 146 | .then(data => { 147 | return database.findUserById(data.userId || data.user.id) 148 | .then(user => { 149 | if (!user) return Promise.reject(new Error('USER_NOT_FOUND')) 150 | req.user = user 151 | req.jwt = token 152 | req.jwtData = data 153 | next() 154 | }) 155 | }) 156 | .catch(err => { 157 | console.error(err.stack) 158 | res.render('Error') 159 | }) 160 | } 161 | 162 | const fetchRecoveryCodes = (req, res, next) => { 163 | return database.findRecoveryCodesByUserId(req.user.id) 164 | .then(codes => { 165 | if (!codes) return Promise.reject(new Error('RECOVERY_CODES_NOT_FOUND')) 166 | req.user.recoveryCodes = codes 167 | next() 168 | }) 169 | } 170 | 171 | const renderFile = (req, res, next, file, data) => { 172 | const { baseUrl, query } = req 173 | const { error, info, provider, token } = query 174 | const filename = path.join(views, file) 175 | const allData = Object.assign({ 176 | projectName, 177 | baseUrl, 178 | error, 179 | info, 180 | provider, 181 | token, 182 | stylesheet, 183 | forms: validations.forms(provider), 184 | recaptchaSiteKey: recaptcha.siteKey(), 185 | __: res.__ 186 | }, data) 187 | const options = {} 188 | ejs.renderFile(filename, allData, options, (err, html) => { 189 | err ? next(err) : res.type('html').send(html) 190 | }) 191 | } 192 | 193 | const renderIndex = (req, res, next, data) => { 194 | const { query } = req 195 | const { provider } = query 196 | const { signupFields, termsAndConditions } = validations 197 | const imageField = signupFields.find(field => field.name === 'image') 198 | Promise.resolve() 199 | .then(() => provider ? jwt.verify(provider) : {}) 200 | .then(userData => { 201 | const allData = Object.assign({ 202 | someProvidersAvailable, 203 | availableProviders, 204 | termsAndConditions, 205 | signupFields, 206 | imageField, 207 | userInfo: userData.user || {} 208 | }, data) 209 | renderFile(req, res, next, 'index.html', allData) 210 | }) 211 | .catch(next) 212 | } 213 | 214 | router.get('/', (req, res, next) => { 215 | res.redirect(req.baseUrl + '/signin') 216 | }) 217 | 218 | router.get('/signin', (req, res, next) => { 219 | renderIndex(req, res, next, { 220 | title: 'Sign In', 221 | action: 'signin' 222 | }) 223 | }) 224 | 225 | router.post('/signin', (req, res, next) => { 226 | const { email, password } = req.body 227 | users.login(email, password, client(req)) 228 | .then(user => { 229 | if (emailConfirmation && !user.emailConfirmed) { 230 | return res.redirect(req.baseUrl + '/signin?error=EMAIL_CONFIRMATION_REQUIRED') 231 | } 232 | return redirectTwofactor(user) 233 | .then(url => res.redirect(url)) 234 | }) 235 | .catch(next) 236 | }) 237 | 238 | if (env('NODE_ENV') === 'test') { 239 | router.get('/landing', authenticated, (req, res, next) => { 240 | res.status(200).json(req.query.jwt) 241 | }) 242 | } 243 | 244 | router.get('/register', (req, res, next) => { 245 | renderIndex(req, res, next, { 246 | title: 'Register', 247 | action: 'register' 248 | }) 249 | }) 250 | 251 | router.post('/register', (req, res, next) => { 252 | const { body } = req 253 | users.register(body, client(req)) 254 | .then(user => { 255 | if (emailConfirmation && user.password) { 256 | return res.redirect(req.baseUrl + '/signin?info=EMAIL_CONFIRMATION_SENT') 257 | } 258 | if (emailConfirmationProviders && !user.password) { 259 | return res.redirect(req.baseUrl + '/signin?info=EMAIL_CONFIRMATION_SENT') 260 | } 261 | return redirect(user) 262 | .then(url => res.redirect(url)) 263 | }) 264 | .catch((err) => { 265 | console.error(err) 266 | res.status(500).send(err) 267 | }) 268 | }) 269 | 270 | router.get('/resetpassword', (req, res, next) => { 271 | renderIndex(req, res, next, { 272 | title: 'Reset your password', 273 | action: 'resetpassword' 274 | }) 275 | }) 276 | 277 | router.post('/resetpassword', (req, res, next) => { 278 | const { email } = req.body 279 | users.forgotPassword(email, client(req)) 280 | .then(() => { 281 | res.redirect(req.baseUrl + req.path + '?info=RESET_LINK_SENT') 282 | }) 283 | .catch(next) 284 | }) 285 | 286 | router.get('/reset', (req, res, next) => { 287 | const { emailConfirmationToken: token } = req.query 288 | renderIndex(req, res, next, { 289 | title: 'Reset your password', 290 | action: 'reset', 291 | token 292 | }) 293 | }) 294 | 295 | router.post('/reset', (req, res, next) => { 296 | const { token, password } = req.body 297 | users.resetPassword(token, password, client(req)) 298 | .then(() => { 299 | res.redirect(req.baseUrl + '/signin?info=PASSWORD_RESET') 300 | }) 301 | .catch(next) 302 | }) 303 | 304 | router.get('/changepassword', authenticated, (req, res, next) => { 305 | const { jwt } = req 306 | renderIndex(req, res, next, { 307 | title: 'Change your password', 308 | action: 'changepassword', 309 | jwt 310 | }) 311 | }) 312 | 313 | router.post('/changepassword', authenticated, (req, res, next) => { 314 | const { user } = req 315 | const { oldPassword, newPassword } = req.body 316 | users.changePassword(user.id, oldPassword, newPassword) 317 | .then(() => { 318 | redirectToDone(res, { 319 | info: 'PASSWORD_CHANGED_SUCCESSFULLY' 320 | }) 321 | }) 322 | .catch(next) 323 | }) 324 | 325 | router.get('/changeemail', authenticated, (req, res, next) => { 326 | renderIndex(req, res, next, { 327 | title: 'Change your email address', 328 | action: 'changeemail' 329 | }) 330 | }) 331 | 332 | router.post('/changeemail', authenticated, (req, res, next) => { 333 | res.send('WIP') 334 | }) 335 | 336 | router.get('/confirm', (req, res, next) => { 337 | const { emailConfirmationToken } = req.query 338 | users.confirmEmail(emailConfirmationToken) 339 | .then(() => { 340 | res.redirect(req.baseUrl + '/signin?info=EMAIL_CONFIRMED') 341 | }) 342 | .catch(next) 343 | }) 344 | 345 | const normalizePhone = str => { 346 | const match = str.match(/\d+/g) 347 | if (!match) return '' 348 | str = match.join('') 349 | if (str.startsWith('00')) str = '+' + str.substring(2) 350 | else if (!str.startsWith('+')) str = '+' + str 351 | return str 352 | } 353 | 354 | const obfuscatePhone = phone => { 355 | if (!phone) return '' 356 | return phone.substring(0, 4) + 357 | phone.substring(4, phone.length - 4).replace(/\d/g, '*') + 358 | phone.substring(phone.length - 4) 359 | } 360 | 361 | const renderConfigureTwofactor = (req, res, next, data) => { 362 | const { user, jwt } = req 363 | const { twofactorSecret } = req.jwtData 364 | const url = speakeasy.otpauthURL({ 365 | secret: twofactorSecret, 366 | encoding: 'base32', 367 | label: user.email, 368 | issuer: projectName 369 | }) 370 | qrForUrl(url) 371 | .then(qrCode => { 372 | renderFile(req, res, next, 'twofactorconfigure.html', Object.assign({ 373 | qrCode, 374 | jwt, 375 | user, 376 | obfuscatePhone, 377 | smsService: !!smsService 378 | }, data)) 379 | }) 380 | .catch(next) 381 | } 382 | 383 | router.get('/configuretwofactor', authenticated, (req, res, next) => { 384 | const { user } = req 385 | const secret = speakeasy.generateSecret({ name: user.email }) 386 | const jwtData = { userId: user.id, twofactorSecret: secret.base32 } 387 | jwt.sign(jwtData, { expiresIn: '1h' }) 388 | .then((jwt) => { 389 | req.jwtData = jwtData 390 | req.jwt = jwt 391 | renderConfigureTwofactor(req, res, next, { 392 | title: 'Configure Two-Factor Authentication', 393 | action: 'configuretwofactor' 394 | }) 395 | }) 396 | .catch(next) 397 | }) 398 | 399 | router.get('/configuretwofactorqr', authenticated, (req, res, next) => { 400 | renderConfigureTwofactor(req, res, next, { 401 | title: 'Configure Two-Factor Authentication', 402 | action: 'configuretwofactorqr' 403 | }) 404 | }) 405 | 406 | router.get('/configuretwofactorsms', authenticated, (req, res, next) => { 407 | renderConfigureTwofactor(req, res, next, { 408 | title: 'Add SMS Authentication', 409 | action: 'configuretwofactorsms' 410 | }) 411 | }) 412 | 413 | router.post('/configuretwofactorqr', authenticated, (req, res, next) => { 414 | const { user, jwtData } = req 415 | const { twofactorSecret: secret } = jwtData 416 | const { token } = req.body 417 | 418 | const tokenValidates = speakeasy.totp.verify({ 419 | secret, 420 | encoding: 'base32', 421 | token, 422 | window: 6 423 | }) 424 | if (tokenValidates) { 425 | crypto.encrypt(secret) 426 | .then(encryptedSecret => { 427 | return database.updateUser({ 428 | id: user.id, 429 | twofactor: 'qr', 430 | twofactorSecret: encryptedSecret, 431 | twofactorPhone: null 432 | }) 433 | }) 434 | .then(() => { 435 | redirectToDone(res, { info: 'TWO_FACTOR_AUTHENTICATION_CONFIGURATION_SUCCESS' }) 436 | }) 437 | .catch(next) 438 | } else { 439 | res.redirect(baseUrl + '/configuretwofactorsms?' + querystring.stringify({ 440 | error: 'INVALID_AUTHENTICATION_CODE', 441 | jwt: req.jwt 442 | })) 443 | } 444 | }) 445 | 446 | router.post('/configuretwofactorsms', authenticated, (req, res, next) => { 447 | const { user, jwtData } = req 448 | const { twofactorSecret: secret } = jwtData 449 | const phone = normalizePhone(req.body.phone) // TODO: handle error if missing 450 | const token = speakeasy.totp({ secret, encoding: 'base32' }) 451 | 452 | // send sms 453 | smsService.send(phone, `${token} is your ${projectName} verification code`) 454 | .then(() => { 455 | return jwt.sign({ twofactorSecret: secret, phone, userId: user.id }) 456 | }) 457 | .then(jwt => { 458 | res.redirect(baseUrl + '/configuretwofactorsmsconfirm?jwt=' + jwt) 459 | }) 460 | .catch(next) 461 | }) 462 | 463 | router.get('/configuretwofactorsmsconfirm', authenticated, (req, res, next) => { 464 | const { jwtData } = req 465 | const { phone } = jwtData 466 | renderConfigureTwofactor(req, res, next, { 467 | title: 'Add SMS Authentication', 468 | action: 'configuretwofactorsmsconfirm', 469 | phone 470 | }) 471 | }) 472 | 473 | router.post('/configuretwofactorsmsconfirm', authenticated, (req, res, next) => { 474 | const { user, jwtData } = req 475 | const { twofactorSecret: secret, phone } = jwtData 476 | const { token } = req.body 477 | 478 | const tokenValidates = speakeasy.totp.verify({ 479 | secret, 480 | encoding: 'base32', 481 | token, 482 | window: 6 483 | }) 484 | if (tokenValidates) { 485 | crypto.encrypt(secret) 486 | .then(encryptedSecret => { 487 | return database.updateUser({ 488 | id: user.id, 489 | twofactor: 'sms', 490 | twofactorSecret: encryptedSecret, 491 | twofactorPhone: phone 492 | }) 493 | }) 494 | .then(() => { 495 | redirectToDone(res, { info: 'TWO_FACTOR_AUTHENTICATION_CONFIGURATION_SUCCESS' }) 496 | }) 497 | .catch(next) 498 | } else { 499 | res.redirect(baseUrl + '/configuretwofactorsms?' + querystring.stringify({ 500 | error: 'INVALID_AUTHENTICATION_CODE', 501 | jwt: req.jwt 502 | })) 503 | } 504 | }) 505 | 506 | router.get('/configuretwofactordisable', authenticated, (req, res, next) => { 507 | renderConfigureTwofactor(req, res, next, { 508 | title: 'Disable two factor authentication', 509 | action: 'configuretwofactordisable' 510 | }) 511 | }) 512 | 513 | router.post('/configuretwofactordisable', authenticated, (req, res, next) => { 514 | const { user } = req 515 | return database.updateUser({ 516 | id: user.id, 517 | twofactor: null, 518 | twofactorSecret: null, 519 | twofactorPhone: null 520 | }) 521 | .then(() => { 522 | // This will actually delete all of the existing codes, and insert nothing to replace them 523 | return database.insertRecoveryCodes(user.id, []) 524 | .then(() => { 525 | redirectToDone(res, { info: 'TWO_FACTOR_AUTHENTICATION_DISABLED' }) 526 | }) 527 | .catch(next) 528 | }) 529 | }) 530 | 531 | router.get('/twofactorrecoverycodes', authenticated, fetchRecoveryCodes, (req, res, next) => { 532 | const { user, jwt } = req 533 | return crypto.decryptRecovery(user.recoveryCodes) 534 | .then((codes) => { 535 | renderFile(req, res, next, 'twofactorcodes.html', { 536 | title: 'Your recovery codes', 537 | codes, 538 | user, 539 | jwt 540 | }) 541 | }) 542 | }) 543 | 544 | router.get('/twofactorrecoveryregenerate', authenticated, (req, res, next) => { 545 | const { user, jwt } = req 546 | users.createRecoveryCodes(user) 547 | .then((codes) => { 548 | return crypto.decryptRecovery(codes) 549 | .then((codes) => { 550 | renderFile(req, res, next, 'twofactorcodes.html', { 551 | title: 'Your recovery codes', 552 | codes, 553 | user, 554 | jwt 555 | }) 556 | }) 557 | }) 558 | }) 559 | 560 | router.get('/twofactor', authenticated, (req, res, next) => { 561 | const { user, jwt } = req 562 | renderFile(req, res, next, 'twofactor.html', { 563 | title: 'Enter an authentication code', 564 | action: 'twofactor', 565 | obfuscatePhone, 566 | user, 567 | jwt 568 | }) 569 | }) 570 | 571 | router.post('/twofactor', authenticated, (req, res, next) => { 572 | const { user } = req 573 | const { token } = req.body 574 | crypto.decrypt(user.twofactorSecret) 575 | .then(secret => { 576 | const tokenValidates = speakeasy.totp.verify({ 577 | secret, 578 | encoding: 'base32', 579 | token, 580 | window: 6 581 | }) 582 | if (tokenValidates) { 583 | return redirect(user) 584 | .then(url => res.redirect(url)) 585 | } else { 586 | return users.useRecoveryCode(user.id, token) 587 | .then(recoveryValidates => { 588 | if (recoveryValidates) { 589 | return redirect(user) 590 | .then(url => res.redirect(url)) 591 | } else { 592 | return Promise.reject(new Error('INVALID_TOKEN')) 593 | } 594 | }) 595 | } 596 | }) 597 | .catch(next) 598 | }) 599 | 600 | router.get('/done', (req, res, next) => { 601 | renderFile(req, res, next, 'done.html', { redirectUrl, title: '' }) 602 | }) 603 | 604 | const staticFiles = [ 605 | 'stylesheet.css', 606 | 'jquery.cropit.js' 607 | ] 608 | 609 | for (const staticFile of staticFiles) { 610 | const fullPath = path.join(__dirname, '..', 'static', staticFile) 611 | router.get('/' + staticFile, (req, res, next) => { 612 | res.sendFile(fullPath, {}, err => err && next(err)) 613 | }) 614 | } 615 | 616 | router.use((err, req, res, next) => { 617 | console.error(err.stack) 618 | if (!err.handled) return redirectToDone(res, { error: 'INTERNAL_ERROR' }) 619 | const jwt = req.query.jwt || req.body.jwt 620 | const index = err.message.indexOf(':') 621 | const error = err.message.substring(0, index >= 0 ? index : undefined) 622 | const qs = querystring.stringify(Object.assign({ jwt }, { error })) 623 | res.redirect(req.baseUrl + req.path + '?' + qs) 624 | if (!err.handled) winston.error(err) 625 | }) 626 | 627 | return router 628 | } 629 | 630 | exports.startServer = (config, callback) => { 631 | const env = require('./utils/env')(config) 632 | const app = express() 633 | const router = exports.createRouter(config) 634 | const port = +env('MICROSERVICE_PORT') || 3000 635 | 636 | app.use('/auth', router) 637 | 638 | app.get('/healthz', (req, res) => { 639 | res.status(200).send({ 'status': 'OK' }) 640 | }) 641 | 642 | app.get('/robots.txt', (req, res) => { 643 | res.type('text/plain') 644 | const pattern = process.env.ROBOTS_INDEX === 'true' ? '' : ' /' 645 | res.send(`User-agent: *\nDisallow:${pattern}\n`) 646 | }) 647 | 648 | app.all('*', (req, res, next) => { 649 | res.redirect('/auth/signin') 650 | }) 651 | 652 | return app.listen(port, callback) 653 | } 654 | 655 | if (require.main === module) { 656 | const server = exports.startServer({}, () => { 657 | const port = server.address().port 658 | winston.info(`Listening on port ${port}! Visit http://127.0.0.1:${port}/auth`) 659 | }) 660 | } 661 | 662 | -------------------------------------------------------------------------------- /static/jquery.cropit.js: -------------------------------------------------------------------------------- 1 | /*! cropit - v0.5.1 */ 2 | (function webpackUniversalModuleDefinition(root, factory) { 3 | if(typeof exports === 'object' && typeof module === 'object') 4 | module.exports = factory(require("jquery")); 5 | else if(typeof define === 'function' && define.amd) 6 | define(["jquery"], factory); 7 | else if(typeof exports === 'object') 8 | exports["cropit"] = factory(require("jquery")); 9 | else 10 | root["cropit"] = factory(root["jQuery"]); 11 | })(this, function(__WEBPACK_EXTERNAL_MODULE_1__) { 12 | return /******/ (function(modules) { // webpackBootstrap 13 | /******/ // The module cache 14 | /******/ var installedModules = {}; 15 | 16 | /******/ // The require function 17 | /******/ function __webpack_require__(moduleId) { 18 | 19 | /******/ // Check if module is in cache 20 | /******/ if(installedModules[moduleId]) 21 | /******/ return installedModules[moduleId].exports; 22 | 23 | /******/ // Create a new module (and put it into the cache) 24 | /******/ var module = installedModules[moduleId] = { 25 | /******/ exports: {}, 26 | /******/ id: moduleId, 27 | /******/ loaded: false 28 | /******/ }; 29 | 30 | /******/ // Execute the module function 31 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 32 | 33 | /******/ // Flag the module as loaded 34 | /******/ module.loaded = true; 35 | 36 | /******/ // Return the exports of the module 37 | /******/ return module.exports; 38 | /******/ } 39 | 40 | 41 | /******/ // expose the modules object (__webpack_modules__) 42 | /******/ __webpack_require__.m = modules; 43 | 44 | /******/ // expose the module cache 45 | /******/ __webpack_require__.c = installedModules; 46 | 47 | /******/ // __webpack_public_path__ 48 | /******/ __webpack_require__.p = ""; 49 | 50 | /******/ // Load entry module and return exports 51 | /******/ return __webpack_require__(0); 52 | /******/ }) 53 | /************************************************************************/ 54 | /******/ ([ 55 | /* 0 */ 56 | /***/ function(module, exports, __webpack_require__) { 57 | 58 | var _slice = Array.prototype.slice; 59 | 60 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 61 | 62 | var _jquery = __webpack_require__(1); 63 | 64 | var _jquery2 = _interopRequireDefault(_jquery); 65 | 66 | var _cropit = __webpack_require__(2); 67 | 68 | var _cropit2 = _interopRequireDefault(_cropit); 69 | 70 | var _constants = __webpack_require__(4); 71 | 72 | var _utils = __webpack_require__(6); 73 | 74 | var applyOnEach = function applyOnEach($el, callback) { 75 | return $el.each(function () { 76 | var cropit = _jquery2['default'].data(this, _constants.PLUGIN_KEY); 77 | 78 | if (!cropit) { 79 | return; 80 | } 81 | callback(cropit); 82 | }); 83 | }; 84 | 85 | var callOnFirst = function callOnFirst($el, method, options) { 86 | var cropit = $el.first().data(_constants.PLUGIN_KEY); 87 | 88 | if (!cropit || !_jquery2['default'].isFunction(cropit[method])) { 89 | return null; 90 | } 91 | return cropit[method](options); 92 | }; 93 | 94 | var methods = { 95 | init: function init(options) { 96 | return this.each(function () { 97 | // Only instantiate once per element 98 | if (_jquery2['default'].data(this, _constants.PLUGIN_KEY)) { 99 | return; 100 | } 101 | 102 | var cropit = new _cropit2['default'](_jquery2['default'], this, options); 103 | _jquery2['default'].data(this, _constants.PLUGIN_KEY, cropit); 104 | }); 105 | }, 106 | 107 | destroy: function destroy() { 108 | return this.each(function () { 109 | _jquery2['default'].removeData(this, _constants.PLUGIN_KEY); 110 | }); 111 | }, 112 | 113 | isZoomable: function isZoomable() { 114 | return callOnFirst(this, 'isZoomable'); 115 | }, 116 | 117 | 'export': function _export(options) { 118 | return callOnFirst(this, 'getCroppedImageData', options); 119 | } 120 | }; 121 | 122 | var delegate = function delegate($el, fnName) { 123 | return applyOnEach($el, function (cropit) { 124 | cropit[fnName](); 125 | }); 126 | }; 127 | 128 | var prop = function prop($el, name, value) { 129 | if ((0, _utils.exists)(value)) { 130 | return applyOnEach($el, function (cropit) { 131 | cropit[name] = value; 132 | }); 133 | } else { 134 | var cropit = $el.first().data(_constants.PLUGIN_KEY); 135 | return cropit[name]; 136 | } 137 | }; 138 | 139 | _jquery2['default'].fn.cropit = function (method) { 140 | if (methods[method]) { 141 | return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); 142 | } else if (['imageState', 'imageSrc', 'offset', 'previewSize', 'imageSize', 'zoom', 'initialZoom', 'exportZoom', 'minZoom', 'maxZoom'].indexOf(method) >= 0) { 143 | return prop.apply(undefined, [this].concat(_slice.call(arguments))); 144 | } else if (['rotateCW', 'rotateCCW', 'disable', 'reenable'].indexOf(method) >= 0) { 145 | return delegate.apply(undefined, [this].concat(_slice.call(arguments))); 146 | } else { 147 | return methods.init.apply(this, arguments); 148 | } 149 | }; 150 | 151 | /***/ }, 152 | /* 1 */ 153 | /***/ function(module, exports) { 154 | 155 | module.exports = __WEBPACK_EXTERNAL_MODULE_1__; 156 | 157 | /***/ }, 158 | /* 2 */ 159 | /***/ function(module, exports, __webpack_require__) { 160 | 161 | Object.defineProperty(exports, '__esModule', { 162 | value: true 163 | }); 164 | 165 | var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); 166 | 167 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 168 | 169 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } 170 | 171 | var _jquery = __webpack_require__(1); 172 | 173 | var _jquery2 = _interopRequireDefault(_jquery); 174 | 175 | var _Zoomer = __webpack_require__(3); 176 | 177 | var _Zoomer2 = _interopRequireDefault(_Zoomer); 178 | 179 | var _constants = __webpack_require__(4); 180 | 181 | var _options = __webpack_require__(5); 182 | 183 | var _utils = __webpack_require__(6); 184 | 185 | var Cropit = (function () { 186 | function Cropit(jQuery, element, options) { 187 | _classCallCheck(this, Cropit); 188 | 189 | this.$el = (0, _jquery2['default'])(element); 190 | 191 | var defaults = (0, _options.loadDefaults)(this.$el); 192 | this.options = _jquery2['default'].extend({}, defaults, options); 193 | 194 | this.init(); 195 | } 196 | 197 | _createClass(Cropit, [{ 198 | key: 'init', 199 | value: function init() { 200 | var _this = this; 201 | 202 | this.image = new Image(); 203 | this.preImage = new Image(); 204 | this.image.onload = this.onImageLoaded.bind(this); 205 | this.preImage.onload = this.onPreImageLoaded.bind(this); 206 | this.image.onerror = this.preImage.onerror = function () { 207 | _this.onImageError.call(_this, _constants.ERRORS.IMAGE_FAILED_TO_LOAD); 208 | }; 209 | 210 | this.$preview = this.options.$preview.css('position', 'relative'); 211 | this.$fileInput = this.options.$fileInput.attr({ accept: 'image/*' }); 212 | this.$zoomSlider = this.options.$zoomSlider.attr({ min: 0, max: 1, step: 0.01 }); 213 | 214 | this.previewSize = { 215 | width: this.options.width || this.$preview.innerWidth(), 216 | height: this.options.height || this.$preview.innerHeight() 217 | }; 218 | 219 | this.$image = (0, _jquery2['default'])('').addClass(_constants.CLASS_NAMES.PREVIEW_IMAGE).attr('alt', '').css({ 220 | transformOrigin: 'top left', 221 | webkitTransformOrigin: 'top left', 222 | willChange: 'transform' 223 | }); 224 | this.$imageContainer = (0, _jquery2['default'])('
').addClass(_constants.CLASS_NAMES.PREVIEW_IMAGE_CONTAINER).css({ 225 | position: 'absolute', 226 | overflow: 'hidden', 227 | left: 0, 228 | top: 0, 229 | width: '100%', 230 | height: '100%' 231 | }).append(this.$image); 232 | this.$preview.append(this.$imageContainer); 233 | 234 | if (this.options.imageBackground) { 235 | if (_jquery2['default'].isArray(this.options.imageBackgroundBorderWidth)) { 236 | this.bgBorderWidthArray = this.options.imageBackgroundBorderWidth; 237 | } else { 238 | this.bgBorderWidthArray = [0, 1, 2, 3].map(function () { 239 | return _this.options.imageBackgroundBorderWidth; 240 | }); 241 | } 242 | 243 | this.$bg = (0, _jquery2['default'])('').addClass(_constants.CLASS_NAMES.PREVIEW_BACKGROUND).attr('alt', '').css({ 244 | position: 'relative', 245 | left: this.bgBorderWidthArray[3], 246 | top: this.bgBorderWidthArray[0], 247 | transformOrigin: 'top left', 248 | webkitTransformOrigin: 'top left', 249 | willChange: 'transform' 250 | }); 251 | this.$bgContainer = (0, _jquery2['default'])('
').addClass(_constants.CLASS_NAMES.PREVIEW_BACKGROUND_CONTAINER).css({ 252 | position: 'absolute', 253 | zIndex: 0, 254 | top: -this.bgBorderWidthArray[0], 255 | right: -this.bgBorderWidthArray[1], 256 | bottom: -this.bgBorderWidthArray[2], 257 | left: -this.bgBorderWidthArray[3] 258 | }).append(this.$bg); 259 | if (this.bgBorderWidthArray[0] > 0) { 260 | this.$bgContainer.css('overflow', 'hidden'); 261 | } 262 | this.$preview.prepend(this.$bgContainer); 263 | } 264 | 265 | this.initialZoom = this.options.initialZoom; 266 | 267 | this.imageLoaded = false; 268 | 269 | this.moveContinue = false; 270 | 271 | this.zoomer = new _Zoomer2['default'](); 272 | 273 | if (this.options.allowDragNDrop) { 274 | _jquery2['default'].event.props.push('dataTransfer'); 275 | } 276 | 277 | this.bindListeners(); 278 | 279 | if (this.options.imageState && this.options.imageState.src) { 280 | this.loadImage(this.options.imageState.src); 281 | } 282 | } 283 | }, { 284 | key: 'bindListeners', 285 | value: function bindListeners() { 286 | this.$fileInput.on('change.cropit', this.onFileChange.bind(this)); 287 | this.$imageContainer.on(_constants.EVENTS.PREVIEW, this.onPreviewEvent.bind(this)); 288 | this.$zoomSlider.on(_constants.EVENTS.ZOOM_INPUT, this.onZoomSliderChange.bind(this)); 289 | 290 | if (this.options.allowDragNDrop) { 291 | this.$imageContainer.on('dragover.cropit dragleave.cropit', this.onDragOver.bind(this)); 292 | this.$imageContainer.on('drop.cropit', this.onDrop.bind(this)); 293 | } 294 | } 295 | }, { 296 | key: 'unbindListeners', 297 | value: function unbindListeners() { 298 | this.$fileInput.off('change.cropit'); 299 | this.$imageContainer.off(_constants.EVENTS.PREVIEW); 300 | this.$imageContainer.off('dragover.cropit dragleave.cropit drop.cropit'); 301 | this.$zoomSlider.off(_constants.EVENTS.ZOOM_INPUT); 302 | } 303 | }, { 304 | key: 'onFileChange', 305 | value: function onFileChange(e) { 306 | this.options.onFileChange(e); 307 | 308 | if (this.$fileInput.get(0).files) { 309 | this.loadFile(this.$fileInput.get(0).files[0]); 310 | } 311 | } 312 | }, { 313 | key: 'loadFile', 314 | value: function loadFile(file) { 315 | var fileReader = new FileReader(); 316 | if (file && file.type.match('image')) { 317 | fileReader.readAsDataURL(file); 318 | fileReader.onload = this.onFileReaderLoaded.bind(this); 319 | fileReader.onerror = this.onFileReaderError.bind(this); 320 | } else if (file) { 321 | this.onFileReaderError(); 322 | } 323 | } 324 | }, { 325 | key: 'onFileReaderLoaded', 326 | value: function onFileReaderLoaded(e) { 327 | this.loadImage(e.target.result); 328 | } 329 | }, { 330 | key: 'onFileReaderError', 331 | value: function onFileReaderError() { 332 | this.options.onFileReaderError(); 333 | } 334 | }, { 335 | key: 'onDragOver', 336 | value: function onDragOver(e) { 337 | e.preventDefault(); 338 | e.dataTransfer.dropEffect = 'copy'; 339 | this.$preview.toggleClass(_constants.CLASS_NAMES.DRAG_HOVERED, e.type === 'dragover'); 340 | } 341 | }, { 342 | key: 'onDrop', 343 | value: function onDrop(e) { 344 | var _this2 = this; 345 | 346 | e.preventDefault(); 347 | e.stopPropagation(); 348 | 349 | var files = Array.prototype.slice.call(e.dataTransfer.files, 0); 350 | files.some(function (file) { 351 | if (!file.type.match('image')) { 352 | return false; 353 | } 354 | 355 | _this2.loadFile(file); 356 | return true; 357 | }); 358 | 359 | this.$preview.removeClass(_constants.CLASS_NAMES.DRAG_HOVERED); 360 | } 361 | }, { 362 | key: 'loadImage', 363 | value: function loadImage(imageSrc) { 364 | var _this3 = this; 365 | 366 | if (!imageSrc) { 367 | return; 368 | } 369 | 370 | this.options.onImageLoading(); 371 | this.setImageLoadingClass(); 372 | 373 | if (imageSrc.indexOf('data') === 0) { 374 | this.preImage.src = imageSrc; 375 | } else { 376 | var xhr = new XMLHttpRequest(); 377 | xhr.onload = function (e) { 378 | if (e.target.status >= 300) { 379 | _this3.onImageError.call(_this3, _constants.ERRORS.IMAGE_FAILED_TO_LOAD); 380 | return; 381 | } 382 | 383 | _this3.loadFile(e.target.response); 384 | }; 385 | xhr.open('GET', imageSrc); 386 | xhr.responseType = 'blob'; 387 | xhr.send(); 388 | } 389 | } 390 | }, { 391 | key: 'onPreImageLoaded', 392 | value: function onPreImageLoaded() { 393 | if (this.shouldRejectImage({ 394 | imageWidth: this.preImage.width, 395 | imageHeight: this.preImage.height, 396 | previewSize: this.previewSize, 397 | maxZoom: this.options.maxZoom, 398 | exportZoom: this.options.exportZoom, 399 | smallImage: this.options.smallImage 400 | })) { 401 | this.onImageError(_constants.ERRORS.SMALL_IMAGE); 402 | if (this.image.src) { 403 | this.setImageLoadedClass(); 404 | } 405 | return; 406 | } 407 | 408 | this.image.src = this.preImage.src; 409 | } 410 | }, { 411 | key: 'onImageLoaded', 412 | value: function onImageLoaded() { 413 | this.rotation = 0; 414 | this.setupZoomer(this.options.imageState && this.options.imageState.zoom || this._initialZoom); 415 | if (this.options.imageState && this.options.imageState.offset) { 416 | this.offset = this.options.imageState.offset; 417 | } else { 418 | this.centerImage(); 419 | } 420 | 421 | this.options.imageState = {}; 422 | 423 | this.$image.attr('src', this.image.src); 424 | if (this.options.imageBackground) { 425 | this.$bg.attr('src', this.image.src); 426 | } 427 | 428 | this.setImageLoadedClass(); 429 | 430 | this.imageLoaded = true; 431 | 432 | this.options.onImageLoaded(); 433 | } 434 | }, { 435 | key: 'onImageError', 436 | value: function onImageError() { 437 | this.options.onImageError.apply(this, arguments); 438 | this.removeImageLoadingClass(); 439 | } 440 | }, { 441 | key: 'setImageLoadingClass', 442 | value: function setImageLoadingClass() { 443 | this.$preview.removeClass(_constants.CLASS_NAMES.IMAGE_LOADED).addClass(_constants.CLASS_NAMES.IMAGE_LOADING); 444 | } 445 | }, { 446 | key: 'setImageLoadedClass', 447 | value: function setImageLoadedClass() { 448 | this.$preview.removeClass(_constants.CLASS_NAMES.IMAGE_LOADING).addClass(_constants.CLASS_NAMES.IMAGE_LOADED); 449 | } 450 | }, { 451 | key: 'removeImageLoadingClass', 452 | value: function removeImageLoadingClass() { 453 | this.$preview.removeClass(_constants.CLASS_NAMES.IMAGE_LOADING); 454 | } 455 | }, { 456 | key: 'getEventPosition', 457 | value: function getEventPosition(e) { 458 | if (e.originalEvent && e.originalEvent.touches && e.originalEvent.touches[0]) { 459 | e = e.originalEvent.touches[0]; 460 | } 461 | if (e.clientX && e.clientY) { 462 | return { x: e.clientX, y: e.clientY }; 463 | } 464 | } 465 | }, { 466 | key: 'onPreviewEvent', 467 | value: function onPreviewEvent(e) { 468 | if (!this.imageLoaded) { 469 | return; 470 | } 471 | 472 | this.moveContinue = false; 473 | this.$imageContainer.off(_constants.EVENTS.PREVIEW_MOVE); 474 | 475 | if (e.type === 'mousedown' || e.type === 'touchstart') { 476 | this.origin = this.getEventPosition(e); 477 | this.moveContinue = true; 478 | this.$imageContainer.on(_constants.EVENTS.PREVIEW_MOVE, this.onMove.bind(this)); 479 | } else { 480 | (0, _jquery2['default'])(document.body).focus(); 481 | } 482 | 483 | e.stopPropagation(); 484 | return false; 485 | } 486 | }, { 487 | key: 'onMove', 488 | value: function onMove(e) { 489 | var eventPosition = this.getEventPosition(e); 490 | 491 | if (this.moveContinue && eventPosition) { 492 | this.offset = { 493 | x: this.offset.x + eventPosition.x - this.origin.x, 494 | y: this.offset.y + eventPosition.y - this.origin.y 495 | }; 496 | } 497 | 498 | this.origin = eventPosition; 499 | 500 | e.stopPropagation(); 501 | return false; 502 | } 503 | }, { 504 | key: 'fixOffset', 505 | value: function fixOffset(offset) { 506 | if (!this.imageLoaded) { 507 | return offset; 508 | } 509 | 510 | var ret = { x: offset.x, y: offset.y }; 511 | 512 | if (!this.options.freeMove) { 513 | if (this.imageWidth * this.zoom >= this.previewSize.width) { 514 | ret.x = Math.min(0, Math.max(ret.x, this.previewSize.width - this.imageWidth * this.zoom)); 515 | } else { 516 | ret.x = Math.max(0, Math.min(ret.x, this.previewSize.width - this.imageWidth * this.zoom)); 517 | } 518 | 519 | if (this.imageHeight * this.zoom >= this.previewSize.height) { 520 | ret.y = Math.min(0, Math.max(ret.y, this.previewSize.height - this.imageHeight * this.zoom)); 521 | } else { 522 | ret.y = Math.max(0, Math.min(ret.y, this.previewSize.height - this.imageHeight * this.zoom)); 523 | } 524 | } 525 | 526 | ret.x = (0, _utils.round)(ret.x); 527 | ret.y = (0, _utils.round)(ret.y); 528 | 529 | return ret; 530 | } 531 | }, { 532 | key: 'centerImage', 533 | value: function centerImage() { 534 | if (!this.image.width || !this.image.height || !this.zoom) { 535 | return; 536 | } 537 | 538 | this.offset = { 539 | x: (this.previewSize.width - this.imageWidth * this.zoom) / 2, 540 | y: (this.previewSize.height - this.imageHeight * this.zoom) / 2 541 | }; 542 | } 543 | }, { 544 | key: 'onZoomSliderChange', 545 | value: function onZoomSliderChange() { 546 | if (!this.imageLoaded) { 547 | return; 548 | } 549 | 550 | this.zoomSliderPos = Number(this.$zoomSlider.val()); 551 | var newZoom = this.zoomer.getZoom(this.zoomSliderPos); 552 | if (newZoom === this.zoom) { 553 | return; 554 | } 555 | this.zoom = newZoom; 556 | } 557 | }, { 558 | key: 'enableZoomSlider', 559 | value: function enableZoomSlider() { 560 | this.$zoomSlider.removeAttr('disabled'); 561 | this.options.onZoomEnabled(); 562 | } 563 | }, { 564 | key: 'disableZoomSlider', 565 | value: function disableZoomSlider() { 566 | this.$zoomSlider.attr('disabled', true); 567 | this.options.onZoomDisabled(); 568 | } 569 | }, { 570 | key: 'setupZoomer', 571 | value: function setupZoomer(zoom) { 572 | this.zoomer.setup({ 573 | imageSize: this.imageSize, 574 | previewSize: this.previewSize, 575 | exportZoom: this.options.exportZoom, 576 | maxZoom: this.options.maxZoom, 577 | minZoom: this.options.minZoom, 578 | smallImage: this.options.smallImage 579 | }); 580 | this.zoom = (0, _utils.exists)(zoom) ? zoom : this._zoom; 581 | 582 | if (this.isZoomable()) { 583 | this.enableZoomSlider(); 584 | } else { 585 | this.disableZoomSlider(); 586 | } 587 | } 588 | }, { 589 | key: 'fixZoom', 590 | value: function fixZoom(zoom) { 591 | return this.zoomer.fixZoom(zoom); 592 | } 593 | }, { 594 | key: 'isZoomable', 595 | value: function isZoomable() { 596 | return this.zoomer.isZoomable(); 597 | } 598 | }, { 599 | key: 'renderImage', 600 | value: function renderImage() { 601 | var transformation = '\n translate(' + this.rotatedOffset.x + 'px, ' + this.rotatedOffset.y + 'px)\n scale(' + this.zoom + ')\n rotate(' + this.rotation + 'deg)'; 602 | 603 | this.$image.css({ 604 | transform: transformation, 605 | webkitTransform: transformation 606 | }); 607 | if (this.options.imageBackground) { 608 | this.$bg.css({ 609 | transform: transformation, 610 | webkitTransform: transformation 611 | }); 612 | } 613 | } 614 | }, { 615 | key: 'rotateCW', 616 | value: function rotateCW() { 617 | if (this.shouldRejectImage({ 618 | imageWidth: this.image.height, 619 | imageHeight: this.image.width, 620 | previewSize: this.previewSize, 621 | maxZoom: this.options.maxZoom, 622 | exportZoom: this.options.exportZoom, 623 | smallImage: this.options.smallImage 624 | })) { 625 | this.rotation = (this.rotation + 180) % 360; 626 | } else { 627 | this.rotation = (this.rotation + 90) % 360; 628 | } 629 | } 630 | }, { 631 | key: 'rotateCCW', 632 | value: function rotateCCW() { 633 | if (this.shouldRejectImage({ 634 | imageWidth: this.image.height, 635 | imageHeight: this.image.width, 636 | previewSize: this.previewSize, 637 | maxZoom: this.options.maxZoom, 638 | exportZoom: this.options.exportZoom, 639 | smallImage: this.options.smallImage 640 | })) { 641 | this.rotation = (this.rotation + 180) % 360; 642 | } else { 643 | this.rotation = (this.rotation + 270) % 360; 644 | } 645 | } 646 | }, { 647 | key: 'shouldRejectImage', 648 | value: function shouldRejectImage(_ref) { 649 | var imageWidth = _ref.imageWidth; 650 | var imageHeight = _ref.imageHeight; 651 | var previewSize = _ref.previewSize; 652 | var maxZoom = _ref.maxZoom; 653 | var exportZoom = _ref.exportZoom; 654 | var smallImage = _ref.smallImage; 655 | 656 | if (smallImage !== 'reject') { 657 | return false; 658 | } 659 | 660 | return imageWidth * maxZoom < previewSize.width * exportZoom || imageHeight * maxZoom < previewSize.height * exportZoom; 661 | } 662 | }, { 663 | key: 'getCroppedImageData', 664 | value: function getCroppedImageData(exportOptions) { 665 | if (!this.image.src) { 666 | return; 667 | } 668 | 669 | var exportDefaults = { 670 | type: 'image/png', 671 | quality: 0.75, 672 | originalSize: false, 673 | fillBg: '#fff' 674 | }; 675 | exportOptions = _jquery2['default'].extend({}, exportDefaults, exportOptions); 676 | 677 | var exportZoom = exportOptions.originalSize ? 1 / this.zoom : this.options.exportZoom; 678 | 679 | var zoomedSize = { 680 | width: this.zoom * exportZoom * this.image.width, 681 | height: this.zoom * exportZoom * this.image.height 682 | }; 683 | 684 | var canvas = (0, _jquery2['default'])('').attr({ 685 | width: this.previewSize.width * exportZoom, 686 | height: this.previewSize.height * exportZoom 687 | }).get(0); 688 | var canvasContext = canvas.getContext('2d'); 689 | 690 | if (exportOptions.type === 'image/jpeg') { 691 | canvasContext.fillStyle = exportOptions.fillBg; 692 | canvasContext.fillRect(0, 0, canvas.width, canvas.height); 693 | } 694 | 695 | canvasContext.translate(this.rotatedOffset.x * exportZoom, this.rotatedOffset.y * exportZoom); 696 | canvasContext.rotate(this.rotation * Math.PI / 180); 697 | canvasContext.drawImage(this.image, 0, 0, zoomedSize.width, zoomedSize.height); 698 | 699 | return canvas.toDataURL(exportOptions.type, exportOptions.quality); 700 | } 701 | }, { 702 | key: 'disable', 703 | value: function disable() { 704 | this.unbindListeners(); 705 | this.disableZoomSlider(); 706 | this.$el.addClass(_constants.CLASS_NAMES.DISABLED); 707 | } 708 | }, { 709 | key: 'reenable', 710 | value: function reenable() { 711 | this.bindListeners(); 712 | this.enableZoomSlider(); 713 | this.$el.removeClass(_constants.CLASS_NAMES.DISABLED); 714 | } 715 | }, { 716 | key: '$', 717 | value: function $(selector) { 718 | if (!this.$el) { 719 | return null; 720 | } 721 | return this.$el.find(selector); 722 | } 723 | }, { 724 | key: 'offset', 725 | set: function (position) { 726 | if (!position || !(0, _utils.exists)(position.x) || !(0, _utils.exists)(position.y)) { 727 | return; 728 | } 729 | 730 | this._offset = this.fixOffset(position); 731 | this.renderImage(); 732 | 733 | this.options.onOffsetChange(position); 734 | }, 735 | get: function () { 736 | return this._offset; 737 | } 738 | }, { 739 | key: 'zoom', 740 | set: function (newZoom) { 741 | newZoom = this.fixZoom(newZoom); 742 | 743 | if (this.imageLoaded) { 744 | var oldZoom = this.zoom; 745 | 746 | var newX = this.previewSize.width / 2 - (this.previewSize.width / 2 - this.offset.x) * newZoom / oldZoom; 747 | var newY = this.previewSize.height / 2 - (this.previewSize.height / 2 - this.offset.y) * newZoom / oldZoom; 748 | 749 | this._zoom = newZoom; 750 | this.offset = { x: newX, y: newY }; // Triggers renderImage() 751 | } else { 752 | this._zoom = newZoom; 753 | } 754 | 755 | this.zoomSliderPos = this.zoomer.getSliderPos(this.zoom); 756 | this.$zoomSlider.val(this.zoomSliderPos); 757 | 758 | this.options.onZoomChange(newZoom); 759 | }, 760 | get: function () { 761 | return this._zoom; 762 | } 763 | }, { 764 | key: 'rotatedOffset', 765 | get: function () { 766 | return { 767 | x: this.offset.x + (this.rotation === 90 ? this.image.height * this.zoom : 0) + (this.rotation === 180 ? this.image.width * this.zoom : 0), 768 | y: this.offset.y + (this.rotation === 180 ? this.image.height * this.zoom : 0) + (this.rotation === 270 ? this.image.width * this.zoom : 0) 769 | }; 770 | } 771 | }, { 772 | key: 'rotation', 773 | set: function (newRotation) { 774 | this._rotation = newRotation; 775 | 776 | if (this.imageLoaded) { 777 | // Change in image size may lead to change in zoom range 778 | this.setupZoomer(); 779 | } 780 | }, 781 | get: function () { 782 | return this._rotation; 783 | } 784 | }, { 785 | key: 'imageState', 786 | get: function () { 787 | return { 788 | src: this.image.src, 789 | offset: this.offset, 790 | zoom: this.zoom 791 | }; 792 | } 793 | }, { 794 | key: 'imageSrc', 795 | get: function () { 796 | return this.image.src; 797 | }, 798 | set: function (imageSrc) { 799 | this.loadImage(imageSrc); 800 | } 801 | }, { 802 | key: 'imageWidth', 803 | get: function () { 804 | return this.rotation % 180 === 0 ? this.image.width : this.image.height; 805 | } 806 | }, { 807 | key: 'imageHeight', 808 | get: function () { 809 | return this.rotation % 180 === 0 ? this.image.height : this.image.width; 810 | } 811 | }, { 812 | key: 'imageSize', 813 | get: function () { 814 | return { 815 | width: this.imageWidth, 816 | height: this.imageHeight 817 | }; 818 | } 819 | }, { 820 | key: 'initialZoom', 821 | get: function () { 822 | return this.options.initialZoom; 823 | }, 824 | set: function (initialZoomOption) { 825 | this.options.initialZoom = initialZoomOption; 826 | if (initialZoomOption === 'min') { 827 | this._initialZoom = 0; // Will be fixed when image loads 828 | } else if (initialZoomOption === 'image') { 829 | this._initialZoom = 1; 830 | } else { 831 | this._initialZoom = 0; 832 | } 833 | } 834 | }, { 835 | key: 'exportZoom', 836 | get: function () { 837 | return this.options.exportZoom; 838 | }, 839 | set: function (exportZoom) { 840 | this.options.exportZoom = exportZoom; 841 | this.setupZoomer(); 842 | } 843 | }, { 844 | key: 'minZoom', 845 | get: function () { 846 | return this.options.minZoom; 847 | }, 848 | set: function (minZoom) { 849 | this.options.minZoom = minZoom; 850 | this.setupZoomer(); 851 | } 852 | }, { 853 | key: 'maxZoom', 854 | get: function () { 855 | return this.options.maxZoom; 856 | }, 857 | set: function (maxZoom) { 858 | this.options.maxZoom = maxZoom; 859 | this.setupZoomer(); 860 | } 861 | }, { 862 | key: 'previewSize', 863 | get: function () { 864 | return this._previewSize; 865 | }, 866 | set: function (size) { 867 | if (!size || size.width <= 0 || size.height <= 0) { 868 | return; 869 | } 870 | 871 | this._previewSize = { 872 | width: size.width, 873 | height: size.height 874 | }; 875 | this.$preview.innerWidth(this.previewSize.width).innerHeight(this.previewSize.height); 876 | 877 | if (this.imageLoaded) { 878 | this.setupZoomer(); 879 | } 880 | } 881 | }]); 882 | 883 | return Cropit; 884 | })(); 885 | 886 | exports['default'] = Cropit; 887 | module.exports = exports['default']; 888 | 889 | /***/ }, 890 | /* 3 */ 891 | /***/ function(module, exports) { 892 | 893 | Object.defineProperty(exports, '__esModule', { 894 | value: true 895 | }); 896 | 897 | var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); 898 | 899 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } 900 | 901 | var Zoomer = (function () { 902 | function Zoomer() { 903 | _classCallCheck(this, Zoomer); 904 | 905 | this.minZoom = this.maxZoom = 1; 906 | } 907 | 908 | _createClass(Zoomer, [{ 909 | key: 'setup', 910 | value: function setup(_ref) { 911 | var imageSize = _ref.imageSize; 912 | var previewSize = _ref.previewSize; 913 | var exportZoom = _ref.exportZoom; 914 | var maxZoom = _ref.maxZoom; 915 | var minZoom = _ref.minZoom; 916 | var smallImage = _ref.smallImage; 917 | 918 | var widthRatio = previewSize.width / imageSize.width; 919 | var heightRatio = previewSize.height / imageSize.height; 920 | 921 | if (minZoom === 'fit') { 922 | this.minZoom = Math.min(widthRatio, heightRatio); 923 | } else { 924 | this.minZoom = Math.max(widthRatio, heightRatio); 925 | } 926 | 927 | if (smallImage === 'allow') { 928 | this.minZoom = Math.min(this.minZoom, 1); 929 | } 930 | 931 | this.maxZoom = Math.max(this.minZoom, maxZoom / exportZoom); 932 | } 933 | }, { 934 | key: 'getZoom', 935 | value: function getZoom(sliderPos) { 936 | if (!this.minZoom || !this.maxZoom) { 937 | return null; 938 | } 939 | 940 | return sliderPos * (this.maxZoom - this.minZoom) + this.minZoom; 941 | } 942 | }, { 943 | key: 'getSliderPos', 944 | value: function getSliderPos(zoom) { 945 | if (!this.minZoom || !this.maxZoom) { 946 | return null; 947 | } 948 | 949 | if (this.minZoom === this.maxZoom) { 950 | return 0; 951 | } else { 952 | return (zoom - this.minZoom) / (this.maxZoom - this.minZoom); 953 | } 954 | } 955 | }, { 956 | key: 'isZoomable', 957 | value: function isZoomable() { 958 | if (!this.minZoom || !this.maxZoom) { 959 | return null; 960 | } 961 | 962 | return this.minZoom !== this.maxZoom; 963 | } 964 | }, { 965 | key: 'fixZoom', 966 | value: function fixZoom(zoom) { 967 | return Math.max(this.minZoom, Math.min(this.maxZoom, zoom)); 968 | } 969 | }]); 970 | 971 | return Zoomer; 972 | })(); 973 | 974 | exports['default'] = Zoomer; 975 | module.exports = exports['default']; 976 | 977 | /***/ }, 978 | /* 4 */ 979 | /***/ function(module, exports) { 980 | 981 | Object.defineProperty(exports, '__esModule', { 982 | value: true 983 | }); 984 | var PLUGIN_KEY = 'cropit'; 985 | 986 | exports.PLUGIN_KEY = PLUGIN_KEY; 987 | var CLASS_NAMES = { 988 | PREVIEW: 'cropit-preview', 989 | PREVIEW_IMAGE_CONTAINER: 'cropit-preview-image-container', 990 | PREVIEW_IMAGE: 'cropit-preview-image', 991 | PREVIEW_BACKGROUND_CONTAINER: 'cropit-preview-background-container', 992 | PREVIEW_BACKGROUND: 'cropit-preview-background', 993 | FILE_INPUT: 'cropit-image-input', 994 | ZOOM_SLIDER: 'cropit-image-zoom-input', 995 | 996 | DRAG_HOVERED: 'cropit-drag-hovered', 997 | IMAGE_LOADING: 'cropit-image-loading', 998 | IMAGE_LOADED: 'cropit-image-loaded', 999 | DISABLED: 'cropit-disabled' 1000 | }; 1001 | 1002 | exports.CLASS_NAMES = CLASS_NAMES; 1003 | var ERRORS = { 1004 | IMAGE_FAILED_TO_LOAD: { code: 0, message: 'Image failed to load.' }, 1005 | SMALL_IMAGE: { code: 1, message: 'Image is too small.' } 1006 | }; 1007 | 1008 | exports.ERRORS = ERRORS; 1009 | var eventName = function eventName(events) { 1010 | return events.map(function (e) { 1011 | return '' + e + '.cropit'; 1012 | }).join(' '); 1013 | }; 1014 | var EVENTS = { 1015 | PREVIEW: eventName(['mousedown', 'mouseup', 'mouseleave', 'touchstart', 'touchend', 'touchcancel', 'touchleave']), 1016 | PREVIEW_MOVE: eventName(['mousemove', 'touchmove']), 1017 | ZOOM_INPUT: eventName(['mousemove', 'touchmove', 'change']) 1018 | }; 1019 | exports.EVENTS = EVENTS; 1020 | 1021 | /***/ }, 1022 | /* 5 */ 1023 | /***/ function(module, exports, __webpack_require__) { 1024 | 1025 | Object.defineProperty(exports, '__esModule', { 1026 | value: true 1027 | }); 1028 | 1029 | var _constants = __webpack_require__(4); 1030 | 1031 | var options = { 1032 | elements: [{ 1033 | name: '$preview', 1034 | description: 'The HTML element that displays image preview.', 1035 | defaultSelector: '.' + _constants.CLASS_NAMES.PREVIEW 1036 | }, { 1037 | name: '$fileInput', 1038 | description: 'File input element.', 1039 | defaultSelector: 'input.' + _constants.CLASS_NAMES.FILE_INPUT 1040 | }, { 1041 | name: '$zoomSlider', 1042 | description: 'Range input element that controls image zoom.', 1043 | defaultSelector: 'input.' + _constants.CLASS_NAMES.ZOOM_SLIDER 1044 | }].map(function (o) { 1045 | o.type = 'jQuery element'; 1046 | o['default'] = '$imageCropper.find(\'' + o.defaultSelector + '\')'; 1047 | return o; 1048 | }), 1049 | 1050 | values: [{ 1051 | name: 'width', 1052 | type: 'number', 1053 | description: 'Width of image preview in pixels. If set, it will override the CSS property.', 1054 | 'default': null 1055 | }, { 1056 | name: 'height', 1057 | type: 'number', 1058 | description: 'Height of image preview in pixels. If set, it will override the CSS property.', 1059 | 'default': null 1060 | }, { 1061 | name: 'imageBackground', 1062 | type: 'boolean', 1063 | description: 'Whether or not to display the background image beyond the preview area.', 1064 | 'default': false 1065 | }, { 1066 | name: 'imageBackgroundBorderWidth', 1067 | type: 'array or number', 1068 | description: 'Width of background image border in pixels.\n The four array elements specify the width of background image width on the top, right, bottom, left side respectively.\n The background image beyond the width will be hidden.\n If specified as a number, border with uniform width on all sides will be applied.', 1069 | 'default': [0, 0, 0, 0] 1070 | }, { 1071 | name: 'exportZoom', 1072 | type: 'number', 1073 | description: 'The ratio between the desired image size to export and the preview size.\n For example, if the preview size is `300px * 200px`, and `exportZoom = 2`, then\n the exported image size will be `600px * 400px`.\n This also affects the maximum zoom level, since the exported image cannot be zoomed to larger than its original size.', 1074 | 'default': 1 1075 | }, { 1076 | name: 'allowDragNDrop', 1077 | type: 'boolean', 1078 | description: 'When set to true, you can load an image by dragging it from local file browser onto the preview area.', 1079 | 'default': true 1080 | }, { 1081 | name: 'minZoom', 1082 | type: 'string', 1083 | description: 'This options decides the minimal zoom level of the image.\n If set to `\'fill\'`, the image has to fill the preview area, i.e. both width and height must not go smaller than the preview area.\n If set to `\'fit\'`, the image can shrink further to fit the preview area, i.e. at least one of its edges must not go smaller than the preview area.', 1084 | 'default': 'fill' 1085 | }, { 1086 | name: 'maxZoom', 1087 | type: 'number', 1088 | description: 'Determines how big the image can be zoomed. E.g. if set to 1.5, the image can be zoomed to 150% of its original size.', 1089 | 'default': 1 1090 | }, { 1091 | name: 'initialZoom', 1092 | type: 'string', 1093 | description: 'Determines the zoom when an image is loaded.\n When set to `\'min\'`, image is zoomed to the smallest when loaded.\n When set to `\'image\'`, image is zoomed to 100% when loaded.', 1094 | 'default': 'min' 1095 | }, { 1096 | name: 'freeMove', 1097 | type: 'boolean', 1098 | description: 'When set to true, you can freely move the image instead of being bound to the container borders', 1099 | 'default': false 1100 | }, { 1101 | name: 'smallImage', 1102 | type: 'string', 1103 | description: 'When set to `\'reject\'`, `onImageError` would be called when cropit loads an image that is smaller than the container.\n When set to `\'allow\'`, images smaller than the container can be zoomed down to its original size, overiding `minZoom` option.\n When set to `\'stretch\'`, the minimum zoom of small images would follow `minZoom` option.', 1104 | 'default': 'reject' 1105 | }], 1106 | 1107 | callbacks: [{ 1108 | name: 'onFileChange', 1109 | description: 'Called when user selects a file in the select file input.', 1110 | params: [{ 1111 | name: 'event', 1112 | type: 'object', 1113 | description: 'File change event object' 1114 | }] 1115 | }, { 1116 | name: 'onFileReaderError', 1117 | description: 'Called when `FileReader` encounters an error while loading the image file.' 1118 | }, { 1119 | name: 'onImageLoading', 1120 | description: 'Called when image starts to be loaded.' 1121 | }, { 1122 | name: 'onImageLoaded', 1123 | description: 'Called when image is loaded.' 1124 | }, { 1125 | name: 'onImageError', 1126 | description: 'Called when image cannot be loaded.', 1127 | params: [{ 1128 | name: 'error', 1129 | type: 'object', 1130 | description: 'Error object.' 1131 | }, { 1132 | name: 'error.code', 1133 | type: 'number', 1134 | description: 'Error code. `0` means generic image loading failure. `1` means image is too small.' 1135 | }, { 1136 | name: 'error.message', 1137 | type: 'string', 1138 | description: 'A message explaining the error.' 1139 | }] 1140 | }, { 1141 | name: 'onZoomEnabled', 1142 | description: 'Called when image the zoom slider is enabled.' 1143 | }, { 1144 | name: 'onZoomDisabled', 1145 | description: 'Called when image the zoom slider is disabled.' 1146 | }, { 1147 | name: 'onZoomChange', 1148 | description: 'Called when zoom changes.', 1149 | params: [{ 1150 | name: 'zoom', 1151 | type: 'number', 1152 | description: 'New zoom.' 1153 | }] 1154 | }, { 1155 | name: 'onOffsetChange', 1156 | description: 'Called when image offset changes.', 1157 | params: [{ 1158 | name: 'offset', 1159 | type: 'object', 1160 | description: 'New offset, with `x` and `y` values.' 1161 | }] 1162 | }].map(function (o) { 1163 | o.type = 'function';return o; 1164 | }) 1165 | }; 1166 | 1167 | var loadDefaults = function loadDefaults($el) { 1168 | var defaults = {}; 1169 | if ($el) { 1170 | options.elements.forEach(function (o) { 1171 | defaults[o.name] = $el.find(o.defaultSelector); 1172 | }); 1173 | } 1174 | options.values.forEach(function (o) { 1175 | defaults[o.name] = o['default']; 1176 | }); 1177 | options.callbacks.forEach(function (o) { 1178 | defaults[o.name] = function () {}; 1179 | }); 1180 | 1181 | return defaults; 1182 | }; 1183 | 1184 | exports.loadDefaults = loadDefaults; 1185 | exports['default'] = options; 1186 | 1187 | /***/ }, 1188 | /* 6 */ 1189 | /***/ function(module, exports) { 1190 | 1191 | Object.defineProperty(exports, '__esModule', { 1192 | value: true 1193 | }); 1194 | var exists = function exists(v) { 1195 | return typeof v !== 'undefined'; 1196 | }; 1197 | 1198 | exports.exists = exists; 1199 | var round = function round(x) { 1200 | return +(Math.round(x * 100) + 'e-2'); 1201 | }; 1202 | exports.round = round; 1203 | 1204 | /***/ } 1205 | /******/ ]) 1206 | }); 1207 | ; --------------------------------------------------------------------------------