'
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 |
47 |
48 |
49 |
54 |
116 |
117 |
123 |
124 |
125 |
126 |
127 |
132 |
147 |
148 |
152 |
153 |
154 |
155 |
156 |
161 |
177 |
178 |
182 |
183 |
184 |
215 |
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 |
392 |
393 |
394 |
395 |
396 |
397 | |
398 |
399 | <%= projectName %>
400 |
401 | |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 | Welcome, <%= name %>!
411 | Thanks for trying <%= projectName %>. We’re thrilled to have you on board.
412 | To get the most out of <%= projectName %>, do this primary next step:
413 |
414 |
415 |
416 |
417 |
419 |
420 |
421 | |
422 |
429 | |
430 |
431 |
432 | |
433 |
434 |
435 |
436 |
437 |
438 | |
439 | If you’re having trouble with the button above, copy and paste the URL below into your web browser.
440 | <%= link %>
441 | |
442 |
443 |
444 | |
445 |
446 |
447 | |
448 |
449 |
450 | |
451 |
463 | |
464 |
465 |
466 | |
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 |
392 |
393 |
394 |
395 |
396 |
397 | |
398 |
399 | <%= projectName %>
400 |
401 | |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 | Hi <%= name %>,
411 | You recently requested to reset your password for your <%= projectName %> account. Use the button below to reset it. This password reset is only valid for the next 24 hours.
412 |
413 |
414 |
415 |
416 |
418 |
419 |
420 | |
421 |
428 | |
429 |
430 |
431 | |
432 |
433 |
434 | For security, this request was received from a <%= client.os %> device using <%= client.agent %>. If you did not request a password reset, please ignore this email.
435 | Thanks,
436 | The <%= projectName %> Team
437 |
438 |
439 |
440 | |
441 | If you’re having trouble with the button above, copy and paste the URL below into your web browser.
442 | <%= link %>
443 | |
444 |
445 |
446 | |
447 |
448 |
449 | |
450 |
451 |
452 | |
453 |
465 | |
466 |
467 |
468 | |
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 |
392 |
393 |
394 |
395 |
396 |
397 | |
398 |
399 | <%= projectName %>
400 |
401 | |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 | |
410 | We received a request to reset the password to access <%= projectName %> with your email address (<%= emailAddress %>) from a <%= client.os %> device using <%= client.agent %>, but we were unable to find an account associated with this address.
411 | If you use <%= projectName %> and were expecting this email, consider trying to request a password reset using the email address associated with your account.
412 |
413 |
414 |
415 |
416 |
418 |
419 |
420 | |
421 |
428 | |
429 |
430 |
431 | |
432 |
433 |
434 | If you do not use <%= projectName %> or did not request a password reset, please ignore this email.
435 | Thanks,
436 | The <%= projectName %> Team
437 |
438 |
439 |
440 | |
441 | If you’re having trouble with the button above, copy and paste the URL below into your web browser.
442 | <%= tryDifferentEmailUrl %>
443 | |
444 |
445 |
446 | |
447 |
448 |
449 | |
450 |
451 |
452 | |
453 |
465 | |
466 |
467 |
468 | |
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 | ;
--------------------------------------------------------------------------------