Merged release-0.9.0 into master

master
Keith Irwin 2018-03-06 03:47:16 +00:00
commit 10d0fe986e
No known key found for this signature in database
GPG Key ID: 378933C743E2BBC0
31 changed files with 2485 additions and 621 deletions

40
.gitignore vendored
View File

@ -1,9 +1,13 @@
# npm # npm
node_modules/ node_modules/
npm-debug.log
# Docker # Docker
Dockerfile Dockerfile
# Istanbul reports
coverage/
# Secret stuff # Secret stuff
config/env/* config/env/*
!config/env/sample.js !config/env/sample.js
@ -16,39 +20,3 @@ static/**/*.bun.*
# Ignore docs files # Ignore docs files
_gh_pages _gh_pages
_site _site
# Numerous always-ignore extensions
*.diff
*.err
*.orig
*.log
*.rej
*.swo
*.swp
*.zip
*.vi
*~
# OS or Editor folders
.DS_Store
._*
Thumbs.db
.cache
.project
.settings
.tmproj
*.esproj
nbproject
*.sublime-project
*.sublime-workspace
.idea
.c9
c9d
# Komodo
*.komodoproject
.komodotools
# grunt-html-validation
validation-status.json
validation-report.json

View File

@ -5,4 +5,9 @@ branches:
only: only:
- master - master
build: build:
echo "module.exports = require('./travis.js');" > config/env/env.js - echo "module.exports = require('./travis.js')" > config/env/env.js
script:
- npm run cover
# Send coverage data to Coveralls
after_script: "cat coverage/lcov.info | node_modules/coveralls/bin/coveralls.js"

View File

@ -1,5 +1,13 @@
# Tracman Server Changelog # Tracman Server Changelog
### v0.8.2 ### v0.9.0
###### v0.9.0
* [#121](https://github.com/Tracman-org/Server/issues/121) Fixed various security holes
* [#68](https://github.com/Tracman-org/Server/issues/68) Added tests, mostly for authentication
* [#120](https://github.com/Tracman-org/Server/issues/120) Split config/routes/settings.js into two files
* Removed express validator and replaced with homegrown function
* Fixed showing welcome message on every login
* Removed naked domains
###### v0.8.1/2 ###### v0.8.1/2
* Hotfixed service worker bugs * Hotfixed service worker bugs

View File

@ -1,11 +1,12 @@
# <img align="left" src="/static/img/icon/by/48.png" alt="T" title="The Tracman Logo">Tracman # <img align="left" src="/static/img/icon/by/48.png" alt="T" title="The Tracman Logo">Tracman
###### v 0.8.2 ###### v 0.9.0
node.js application to display a sharable map with user's location. node.js application to display a sharable map with user's location.
[![Build Status](https://travis-ci.org/Tracman-org/Server.svg?branch=develop)](https://travis-ci.org/Tracman-org/Server) [![Travis Build Status](https://travis-ci.org/Tracman-org/Server.svg?branch=develop)](https://travis-ci.org/Tracman-org/Server)
[![Coverage Status](https://coveralls.io/repos/github/Tracman-org/Server/badge.svg?branch=master)](https://coveralls.io/github/Tracman-org/Server?branch=master)
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
[![Waffle.io - Columns and their card count](https://badge.waffle.io/Tracman-org/Server.svg?columns=all)](https://waffle.io/Tracman-org/Server) [![Snyk Vulnerabilities](https://snyk.io/test/github/Tracman-org/Server/badge.svg)](https://snyk.io/test/github/Tracman-org/Servr)
## Installation ## Installation
@ -23,7 +24,7 @@ A good method is to simply copy the sample configuration and point `config/env/e
```sh ```sh
cp config/env/sample.js config/env/local-config.js cp config/env/sample.js config/env/local-config.js
echo "module.exports = require('./local-config.js');" > config/env/env.js echo "module.exports = require('./local-config.js')" > config/env/env.js
``` ```
Then edit `config/env/local-config.js` to match your local environment. Then edit `config/env/local-config.js` to match your local environment.
@ -55,10 +56,16 @@ Tracman will be updated according to [this branching model](http://nvie.com/post
[view full changelog](CHANGELOG.md) [view full changelog](CHANGELOG.md)
###### v0.8.1/2 ###### v0.9.0
* Hotfixed service worker bugs * [#121](https://github.com/Tracman-org/Server/issues/121) Fixed various security holes
* [#68](https://github.com/Tracman-org/Server/issues/68) Added tests, mostly for authentication
* [#120](https://github.com/Tracman-org/Server/issues/120) Split config/routes/settings.js into two files
* Removed express validator and replaced with homegrown function
* Fixed showing welcome message on every login
* Removed naked domains
#### v0.8.0 ###### v0.8.x
* Hotfixed service worker bugs
* Added check to ensure only the newest location is sent * Added check to ensure only the newest location is sent
* Removed buggy login/-out redirects * Removed buggy login/-out redirects
* [#111](https://github.com/Tracman-org/Server/issues/111) Implemented service worker * [#111](https://github.com/Tracman-org/Server/issues/111) Implemented service worker
@ -93,7 +100,7 @@ Tracman will be updated according to [this branching model](http://nvie.com/post
[view full license](LICENSE.md) [view full license](LICENSE.md)
Tracman: GPS tracking service in node.js Tracman: GPS tracking service in node.js
Copyright © 2017 [Keith Irwin](https://keithirwin.us/) Copyright © 2018 [Keith Irwin](https://www.keithirwin.us/)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@ -24,6 +24,7 @@ const userSchema = new mongoose.Schema({
isPro: {type: Boolean, required: true, default: false}, isPro: {type: Boolean, required: true, default: false},
created: {type: Date, required: true}, created: {type: Date, required: true},
lastLogin: Date, lastLogin: Date,
isNewUser: Boolean,
settings: { settings: {
units: {type: String, default: 'standard'}, units: {type: String, default: 'standard'},
defaultMap: {type: String, default: 'road'}, defaultMap: {type: String, default: 'road'},
@ -78,14 +79,10 @@ userSchema.methods.createPassToken = function () {
return new Promise( async (resolve, reject) => { return new Promise( async (resolve, reject) => {
// Reuse old token, resetting clock // Reuse old token
if (user.auth.passTokenExpires >= Date.now()) { if (user.auth.passTokenExpires >= Date.now()) {
debug(`Reusing old password token...`) debug(`Reusing old password token...`)
user.auth.passTokenExpires = Date.now() + 3600000 // 1 hour resolve([user.auth.passToken, user.auth.passTokenExpires])
try {
await user.save()
resolve([user.auth.passToken, user.auth.passTokenExpires])
} catch (err) { reject(err) }
// Create new token // Create new token
} else { } else {

View File

@ -7,6 +7,7 @@ const TwitterStrategy = require('passport-twitter').Strategy
const GoogleTokenStrategy = require('passport-google-id-token') const GoogleTokenStrategy = require('passport-google-id-token')
const FacebookTokenStrategy = require('passport-facebook-token') const FacebookTokenStrategy = require('passport-facebook-token')
const TwitterTokenStrategy = require('passport-twitter-token') const TwitterTokenStrategy = require('passport-twitter-token')
const sanitize = require('mongo-sanitize')
const debug = require('debug')('tracman-passport') const debug = require('debug')('tracman-passport')
const env = require('./env/env.js') const env = require('./env/env.js')
const mw = require('./middleware.js') const mw = require('./middleware.js')
@ -33,7 +34,7 @@ module.exports = (passport) => {
}, async (req, email, password, done) => { }, async (req, email, password, done) => {
debug(`Perfoming local login for ${email}`) debug(`Perfoming local login for ${email}`)
try { try {
let user = await User.findOne({'email': email}) let user = await User.findOne({'email': sanitize(email)})
// No user with that email // No user with that email
if (!user) { if (!user) {
@ -45,16 +46,16 @@ module.exports = (passport) => {
debug(`User exists. Checking password...`) debug(`User exists. Checking password...`)
// Check password // Check password
let res = await user.validPassword(password) let correct_password = await user.validPassword(password)
// Password incorrect // Password incorrect
if (!res) { if (!correct_password) {
debug(`Incorrect password`) debug(`Incorrect password`)
return done(null, false, req.flash('warning', 'Incorrect email or password.')) return done(null, false, req.flash('warning', 'Incorrect email or password.'))
// Successful login // Successful login
} else { } else {
if (!user.lastLogin) req.forNewUser = true user.isNewUser = !Boolean(user.lastLogin)
user.lastLogin = Date.now() user.lastLogin = Date.now()
user.save() user.save()
return done(null, user) return done(null, user)
@ -143,11 +144,11 @@ module.exports = (passport) => {
// Check for unique profileId // Check for unique profileId
debug(`Checking for unique account with query ${query}...`) debug(`Checking for unique account with query ${query}...`)
try { try {
let user = await User.findOne(query) let existing_user = await User.findOne(query)
// Social account already in use // Social account already in use
if (existingUser) { if (existing_user) {
debug(`${service} account already in use with user ${existingUser.id}`) debug(`${service} account already in use with user ${existing_user.id}`)
req.session.flashType = 'warning' req.session.flashType = 'warning'
req.session.flashMessage = `Another user is already connected to that ${service} account. ` req.session.flashMessage = `Another user is already connected to that ${service} account. `
return done() return done()

169
config/routes/account.js Normal file
View File

@ -0,0 +1,169 @@
'use strict'
const mw = require('../middleware.js')
const sanitize = require('mongo-sanitize')
const User = require('../models.js').user
const mail = require('../mail.js')
const env = require('../env/env.js')
const zxcvbn = require('zxcvbn')
const moment = require('moment')
const debug = require('debug')('tracman-routes-account')
const router = require('express').Router()
// Confirm email address
router.get('/email/:token', mw.ensureAuth, async (req, res, next) => {
// Check token
if (req.user.emailToken === req.params.token) {
try {
// Set new email
req.user.email = req.user.newEmail
// Delete token and newEmail
req.user.emailToken = undefined
req.user.newEmail = undefined
await req.user.save()
// Report success
req.flash('success', `Your email has been set to <u>${req.user.email}</u>. `)
res.redirect('/settings')
} catch (err) {
mw.throwErr(err, req)
res.redirect('/settings')
}
// Invalid token
} else {
req.flash('danger', 'Email confirmation token is invalid. ')
res.redirect('/settings')
}
})
// Set password
router.route('/password')
.all(mw.ensureAuth, (req, res, next) => {
next()
})
// Email user a token, proceed at /password/:token
.get( async (req, res, next) => {
// Create token for password change
try {
let [token, expires] = await req.user.createPassToken()
// Figure out expiration time
let expirationTimeString = (req.query.tz)
? moment(expires).utcOffset(req.query.tz).toDate().toLocaleTimeString(req.acceptsLanguages[0])
: moment(expires).toDate().toLocaleTimeString(req.acceptsLanguages[0]) + ' UTC'
// Alert user to check email.
req.flash('success',
`An link has been sent to <u>${req.user.email}</u>. \
Click on the link to complete your password change. \
This link will expire in one hour (${expirationTimeString}). `
)
// Confirm password change request by email.
return await mail.send({
to: mail.to(req.user),
from: mail.noReply,
subject: 'Request to change your Tracman password',
text: mail.text(
`A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at keith@tracman.org. \
\n\nTo change your password, follow this link:\n\
${env.url}/account/password/${token}. \n\n\
This request will expire at ${expirationTimeString}. `
),
html: mail.html(
`<p>A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at \
<a href="mailto:keith@tracman.org">keith@tracman.org</a>. </p>\
<p>To change your password, follow this link:\
<br><a href="${env.url}/account/password/${token}">\
${env.url}/account/password/${token}</a>. </p>\
<p>This request will expire at ${expirationTimeString}. </p>`
)
})
} catch (err) {
mw.throwErr(err, req)
} finally {
res.redirect((req.user) ? '/settings' : '/login')
}
})
router.route('/password/:token')
// Check token
.all( async (req, res, next) => {
debug('/account/password/:token .all() called')
try {
let user = await User
.findOne({'auth.passToken': sanitize(req.params.token)})
.where('auth.passTokenExpires').gt(Date.now())
if (!user) {
debug('Bad token')
req.flash('danger', 'Password reset token is invalid or has expired. ')
res.redirect((req.isAuthenticated) ? '/settings' : '/login')
} else {
debug('setting passwordUser')
res.locals.passwordUser = user
next()
}
} catch (err) {
mw.throwErr(err, req)
res.redirect('/password')
}
})
// Show password change form
.get((req, res) => {
debug('/account/password/:token .get() called')
res.render('password')
})
// Set new password
.post( async (req, res, next) => {
debug('/account/password/:token .post() called')
// Validate password strength
let zxcvbnResult = zxcvbn(req.body.password)
if (zxcvbnResult.crack_times_seconds.online_no_throttling_10_per_second < 864000) { // Less than ten days
req.flash( 'danger',
`That password could be cracked in ${zxcvbnResult.crack_times_display.online_no_throttling_10_per_second}! Come up with a more complex password that would take at least 10 days to crack. `
)
res.redirect(`/account/password/${req.params.token}`)
} else {
// Create hashed password and save to db
try {
await res.locals.passwordUser.generateHashedPassword(req.body.password)
// User changed password
if (req.user) {
debug('User saved password')
req.flash('success', 'Your password has been changed. ')
res.redirect('/settings')
// New user created password
} else {
debug('New user created password')
req.flash('success', 'Password set. You can use it to log in now. ')
res.redirect('/login')
}
} catch (err) {
debug('Error creating hashed password and saving to db')
mw.throwErr(err, req)
res.redirect(`/account/password/${req.params.token}`)
}
}
})
module.exports = router

View File

@ -6,6 +6,7 @@ const User = require('../models.js').user
const crypto = require('crypto') const crypto = require('crypto')
const moment = require('moment') const moment = require('moment')
const slugify = require('slug') const slugify = require('slug')
const sanitize = require('mongo-sanitize')
const debug = require('debug')('tracman-routes-auth') const debug = require('debug')('tracman-routes-auth')
const env = require('../env/env.js') const env = require('../env/env.js')
@ -21,7 +22,7 @@ module.exports = (app, passport) => {
req.flash(req.session.flashType, req.session.flashMessage) req.flash(req.session.flashType, req.session.flashMessage)
req.session.flashType = undefined req.session.flashType = undefined
req.session.flashMessage = undefined req.session.flashMessage = undefined
res.redirect('/map'+(req.forNewUser)?'/map?new=1':'') res.redirect('/map')
} }
const appLoginCallback = (req, res, next) => { const appLoginCallback = (req, res, next) => {
debug('appLoginCallback called.') debug('appLoginCallback called.')
@ -57,7 +58,7 @@ module.exports = (app, passport) => {
.post( async (req, res, next) => { .post( async (req, res, next) => {
// Send token and alert user // Send token and alert user
async function sendToken(user) { const sendToken = async function(user) {
debug(`sendToken() called for user ${user.id}`) debug(`sendToken() called for user ${user.id}`)
// Create a new password token // Create a new password token
@ -80,14 +81,14 @@ module.exports = (app, passport) => {
subject: 'Complete your Tracman registration', subject: 'Complete your Tracman registration',
text: mail.text( text: mail.text(
`Welcome to Tracman! \n\nTo complete your registration, follow \ `Welcome to Tracman! \n\nTo complete your registration, follow \
this link and set your password:\n${env.url}/settings/password/${token}\n\n\ this link and set your password:\n${env.url}/account/password/${token}\n\n\
This link will expire at ${expiration_time_string}. ` This link will expire at ${expiration_time_string}. `
), ),
html: mail.html( html: mail.html(
`<p>Welcome to Tracman! </p><p>To complete your registration, \ `<p>Welcome to Tracman! </p><p>To complete your registration, \
follow this link and set your password:\ follow this link and set your password:\
<br><a href="${env.url}/settings/password/${token}">\ <br><a href="${env.url}/account/password/${token}">\
${env.url}/settings/password/${token}</a></p>\ ${env.url}/account/password/${token}</a></p>\
<p>This link will expire at ${expiration_time_string}. </p>` <p>This link will expire at ${expiration_time_string}. </p>`
) )
}) })
@ -131,114 +132,124 @@ module.exports = (app, passport) => {
} }
// Validate email // Invalid email
req.checkBody('email', 'Please enter a valid email address.').isEmail() if (!mw.validateEmail(req.body.email)) {
debug(`Email ${req.body.email} was found invalid!`)
// Check if somebody already has that email req.flash('warning', `The email you entered, ${req.body.email} isn't valid. Try again. `)
try {
debug(`Searching for user with email ${req.body.email}...`)
let user = await User.findOne({'email': req.body.email})
// User already exists
if (user && user.auth.password) {
debug(`User ${user.id} has email ${req.body.email} and has a password`)
req.flash('warning',
`A user with that email already exists! If you forgot your password, \
you can <a href="/login/forgot?email=${req.body.email}">reset it here</a>.`
)
res.redirect('/login#login')
next()
// User exists but hasn't created a password yet
} else if (user) {
debug(`User ${user.id} has email ${req.body.email} but doesn't have a password`)
// Send another token
sendToken(user)
// Create user
} else {
debug(`User with email ${req.body.email} doesn't exist; creating one`)
let email = req.body.email
user = new User()
user.created = Date.now()
user.email = email
user.slug = slugify(email.substring(0, email.indexOf('@')))
// Generate unique slug
const slug = new Promise((resolve, reject) => {
debug(`Creating new slug for user...`);
(async function checkSlug (s, cb) {
try {
debug(`Checking to see if slug ${s} is taken...`)
let existingUser = await User.findOne({slug: s})
// Slug in use: generate a random one and retry
if (existingUser) {
debug(`Slug ${s} is taken; generating another...`)
crypto.randomBytes(6, (err, buf) => {
if (err) {
debug('Failed to create random bytes for slug!')
mw.throwErr(err, req)
reject()
}
if (buf) {
checkSlug(buf.toString('hex'), cb)
}
})
// Unique slug: proceed
} else {
debug(`Slug ${s} is unique`)
cb(s)
}
} catch (err) {
debug('Failed to create slug!')
mw.throwErr(err, req)
reject()
}
})(user.slug, (newSlug) => {
debug(`Successfully created slug: ${newSlug}`)
user.slug = newSlug
resolve()
})
})
// Generate sk32
const sk32 = new Promise((resolve, reject) => {
debug('Creating sk32 for user...')
crypto.randomBytes(32, (err, buf) => {
if (err) {
debug('Failed to create sk32!')
mw.throwErr(err, req)
reject()
}
if (buf) {
user.sk32 = buf.toString('hex')
debug(`Successfully created sk32: ${user.sk32}`)
resolve()
}
})
})
// Save user and send the token by email
try {
await Promise.all([slug, sk32])
sendToken(user)
} catch (err) {
debug('Failed to save user after creating slug and sk32!')
mw.throwErr(err, req)
res.redirect('/login#signup')
}
}
} catch (err) {
debug(`Failed to check if somebody already has the email ${req.body.email}`)
mw.throwErr(err, req)
res.redirect('/login#signup') res.redirect('/login#signup')
next()
// Valid email
} else {
debug(`Email ${req.body.email} was found valid.`)
// Check if somebody already has that email
try {
debug(`Searching for user with email ${req.body.email}...`)
let user = await User.findOne({'email': sanitize(req.body.email)})
// User already exists
if (user && user.auth.password) {
debug(`User ${user.id} has email ${req.body.email} and has a password`)
req.flash('warning',
`A user with that email already exists! If you forgot your password, \
you can <a href="/login/forgot?email=${req.body.email}">reset it here</a>.`
)
res.redirect('/login#login')
next()
// User exists but hasn't created a password yet
} else if (user) {
debug(`User ${user.id} has email ${req.body.email} but doesn't have a password`)
// Send another token
sendToken(user)
// Create user
} else {
debug(`User with email ${req.body.email} doesn't exist; creating one`)
let email = req.body.email
user = new User()
user.created = Date.now()
user.email = email
user.slug = slugify(email.substring(0, email.indexOf('@')))
// Generate unique slug
const slug = new Promise((resolve, reject) => {
debug(`Creating new slug for user...`);
(async function checkSlug (s, cb) {
try {
debug(`Checking to see if slug ${s} is taken...`)
let existingUser = await User.findOne({slug: sanitize(s)})
// Slug in use: generate a random one and retry
if (existingUser) {
debug(`Slug ${s} is taken; generating another...`)
crypto.randomBytes(6, (err, buf) => {
if (err) {
debug('Failed to create random bytes for slug!')
mw.throwErr(err, req)
reject()
}
if (buf) {
checkSlug(buf.toString('hex'), cb)
}
})
// Unique slug: proceed
} else {
debug(`Slug ${s} is unique`)
cb(s)
}
} catch (err) {
debug('Failed to create slug!')
mw.throwErr(err, req)
reject()
}
})(user.slug, (newSlug) => {
debug(`Successfully created slug: ${newSlug}`)
user.slug = newSlug
resolve()
})
})
// Generate sk32
const sk32 = new Promise((resolve, reject) => {
debug('Creating sk32 for user...')
crypto.randomBytes(32, (err, buf) => {
if (err) {
debug('Failed to create sk32!')
mw.throwErr(err, req)
reject()
}
if (buf) {
user.sk32 = buf.toString('hex')
debug(`Successfully created sk32: ${user.sk32}`)
resolve()
}
})
})
// Save user and send the token by email
try {
await Promise.all([slug, sk32])
sendToken(user)
} catch (err) {
debug('Failed to save user after creating slug and sk32!')
mw.throwErr(err, req)
res.redirect('/login#signup')
}
}
} catch (err) {
debug(`Failed to check if somebody already has the email ${req.body.email}`)
mw.throwErr(err, req)
res.redirect('/login#signup')
}
} }
}) })
@ -259,65 +270,87 @@ module.exports = (app, passport) => {
// Submitted forgot password form // Submitted forgot password form
.post( async (req, res, next) => { .post( async (req, res, next) => {
// Validate email
req.checkBody('email', 'Please enter a valid email address.').isEmail()
// Check if somebody has that email // Invalid email
try { if (!mw.validateEmail(req.body.email)) {
let user = await User.findOne({'email': req.body.email}) debug(`Email ${req.body.email} was found invalid!`)
req.flash('warning', `The email you entered, ${req.body.email} isn't valid. Try again. `)
// No user with that email
if (!user) {
// Don't let on that no such user exists, to prevent dictionary attacks
req.flash('success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link. `
)
res.redirect('/login')
// User with that email does exist
} else {
// Create reset token
try {
let [token, expires] = await user.createPassToken()
// Email reset link
try {
await mail.send({
from: mail.noReply,
to: mail.to(user),
subject: 'Reset your Tracman password',
text: mail.text(
`Hi, \n\nDid you request to reset your Tracman password? \
If so, follow this link to do so:\
\n${env.url}/settings/password/${token}\n\n\
If you didn't initiate this request, just ignore this email. `
),
html: mail.html(
`<p>Hi, </p><p>Did you request to reset your Tracman password? \
If so, follow this link to do so:<br>\
<a href="${env.url}/settings/password/${token}">\
${env.url}/settings/password/${token}</a></p>\
<p>If you didn't initiate this request, just ignore this email. </p>`
)
})
req.flash(
'success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link. `)
res.redirect('/login')
} catch (err) {
debug(`Failed to send reset link to ${user.email}`)
mw.throwErr(err, req)
res.redirect('/login')
}
} catch (err) { return next(err) }
}
} catch (err) {
debug(`Failed to check for if somebody has that email (in reset request)!`)
mw.throwErr(err, req)
res.redirect('/login/forgot') res.redirect('/login/forgot')
next()
// Valid email
} else {
debug(`Email ${req.body.email} was found valid.`)
// Check if somebody has that email
try {
let user = await User.findOne({'email': sanitize(req.body.email)})
// No user with that email
if (!user) {
debug(`No user found with email ${req.body.email}; ignoring password request.`)
// Don't let on that no such user exists, to prevent dictionary attacks
req.flash('success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link. `
)
res.redirect('/login')
// User with that email does exist
} else {
debug(`User ${user.id} found with that email. Creating reset token...`)
// Create reset token
try {
let [token, expires] = await user.createPassToken()
// Figure out expiration time string
debug(`Determining expiration time string for ${expires}...`)
let expiration_time_string = (req.query.tz)
? moment(expires).utcOffset(req.query.tz).toDate().toLocaleTimeString(req.acceptsLanguages[0])
: moment(expires).toDate().toLocaleTimeString(req.acceptsLanguages[0]) + ' UTC'
// Email reset link
try {
await mail.send({
from: mail.noReply,
to: mail.to(user),
subject: 'Reset your Tracman password',
text: mail.text(
`Hi, \n\nDid you request to reset your Tracman password? \
If so, follow this link to do so:\
\n${env.url}/account/password/${token}\n\n\
This link will expire at ${expiration_time_string}. \n\n\
If you didn't initiate this request, just ignore this email. \n\n`
),
html: mail.html(
`<p>Hi, </p><p>Did you request to reset your Tracman password? \
If so, follow this link to do so:<br>\
<a href="${env.url}/account/password/${token}">\
${env.url}/account/password/${token}</a>. \
This link will expire at ${expiration_time_string}. </p>\
<p>If you didn't initiate this request, just ignore this email. </p>`
)
})
req.flash(
'success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link.\
(Your reset link will expire in one hour.)`)
res.redirect('/login')
} catch (err) {
debug(`Failed to send reset link to ${user.email}`)
mw.throwErr(err, req)
res.redirect('/login')
}
} catch (err) { return next(err) }
}
} catch (err) {
debug(`Failed to check for if somebody has that email (in reset request)!`)
mw.throwErr(err, req)
res.redirect('/login/forgot')
}
} }
}) })
@ -355,7 +388,7 @@ module.exports = (app, passport) => {
if (!req.user.auth.password && service === 'google') { if (!req.user.auth.password && service === 'google') {
req.flash( req.flash(
'warning', 'warning',
`Hey, you need to <a href="/settings/password">set a password</a> \ `Hey, you need to <a href="/account/password">set a password</a> \
before you can disconnect your google account. Otherwise, you \ before you can disconnect your google account. Otherwise, you \
won't be able to log in! ` won't be able to log in! `
) )
@ -377,4 +410,5 @@ module.exports = (app, passport) => {
app.get('/login/google/cb', passport.authenticate('google', loginOutcome), loginCallback) app.get('/login/google/cb', passport.authenticate('google', loginOutcome), loginCallback)
app.get('/login/facebook/cb', passport.authenticate('facebook', loginOutcome), loginCallback) app.get('/login/facebook/cb', passport.authenticate('facebook', loginOutcome), loginCallback)
app.get('/login/twitter/cb', passport.authenticate('twitter', loginOutcome), loginCallback) app.get('/login/twitter/cb', passport.authenticate('twitter', loginOutcome), loginCallback)
} }

View File

@ -3,12 +3,12 @@
const router = require('express').Router() const router = require('express').Router()
const mw = require('../middleware.js') const mw = require('../middleware.js')
const env = require('../env/env.js') const env = require('../env/env.js')
const sanitize = require('mongo-sanitize')
const User = require('../models.js').user const User = require('../models.js').user
// Redirect to real slug // Redirect to real slug
router.get('/', mw.ensureAuth, (req, res) => { router.get('/', mw.ensureAuth, (req, res) => {
if (req.query.new) res.redirect(`/map/${req.user.slug}?new=1`) res.redirect(`/map/${req.user.slug}`)
else res.redirect(`/map/${req.user.slug}`)
}) })
// Demo // Demo
@ -48,21 +48,25 @@ router.get('/demo', (req, res, next) => {
// Show map // Show map
router.get('/:slug?', async (req, res, next) => { router.get('/:slug?', async (req, res, next) => {
try { try {
let map_user = await User.findOne({slug: req.params.slug}) if (req.params.slug != sanitize(req.params.slug)) {
if (!map_user) next() // 404 throw new Error(`Possible injection attempt with slug: ${req.params.slug}`)
else { } else {
var active = '' // For header nav let map_user = await User.findOne({slug: req.params.slug})
if (req.user && req.user.id === map_user.id) active = 'map' if (!map_user) next() // 404
res.render('map', { else {
active: active, var active = '' // For header nav
mapuser: map_user, if (req.user && req.user.id === map_user.id) active = 'map'
mapApi: env.googleMapsAPI, res.render('map', {
user: req.user, active: active,
noFooter: '1', mapuser: map_user,
noHeader: (req.query.noheader) ? req.query.noheader.match(/\d/)[0] : 0, mapApi: env.googleMapsAPI,
disp: (req.query.disp) ? req.query.disp.match(/\d/)[0] : 2, // 0=map, 1=streetview, 2=both user: req.user,
newuserurl: (req.query.new) ? env.url + '/map/' + req.params.slug : '' noFooter: '1',
}) noHeader: (req.query.noheader) ? req.query.noheader.match(/\d/)[0] : 0,
disp: (req.query.disp) ? req.query.disp.match(/\d/)[0] : 2, // 0=map, 1=streetview, 2=both
newuserurl: (req.query.new) ? env.url + '/map/' + req.params.slug : ''
})
}
} }
} catch (err) { mw.throwErr(err, req) } } catch (err) { mw.throwErr(err, req) }
}) })

View File

@ -2,12 +2,11 @@
const slug = require('slug') const slug = require('slug')
const xss = require('xss') const xss = require('xss')
const zxcvbn = require('zxcvbn')
const moment = require('moment')
const mw = require('../middleware.js') const mw = require('../middleware.js')
const User = require('../models.js').user const User = require('../models.js').user
const mail = require('../mail.js') const mail = require('../mail.js')
const env = require('../env/env.js') const env = require('../env/env.js')
const sanitize = require('mongo-sanitize')
const debug = require('debug')('tracman-routes-settings') const debug = require('debug')('tracman-routes-settings')
const router = require('express').Router() const router = require('express').Router()
@ -65,14 +64,14 @@ router.route('/')
text: mail.text( text: mail.text(
`A request has been made to change your Tracman email address. \ `A request has been made to change your Tracman email address. \
If you did not initiate this request, please disregard it. \n\n\ If you did not initiate this request, please disregard it. \n\n\
To confirm your email, follow this link:\n${env.url}/settings/email/${token}. ` To confirm your email, follow this link:\n${env.url}/account/email/${token}. `
), ),
html: mail.html( html: mail.html(
`<p>A request has been made to change your Tracman email address. \ `<p>A request has been made to change your Tracman email address. \
If you did not initiate this request, please disregard it. </p>\ If you did not initiate this request, please disregard it. </p>\
<p>To confirm your email, follow this link:\ <p>To confirm your email, follow this link:\
<br><a href="${env.url}/settings/email/${token}">\ <br><a href="${env.url}/account/email/${token}">\
${env.url}/settings/email/${token}</a>. </p>` ${env.url}/account/email/${token}</a>. </p>`
) )
}) })
@ -142,6 +141,7 @@ router.route('/')
finally { res.redirect('/settings') } finally { res.redirect('/settings') }
}) })
// Delete account // Delete account
router.get('/delete', async (req, res) => { router.get('/delete', async (req, res) => {
try { try {
@ -154,160 +154,6 @@ router.get('/delete', async (req, res) => {
} }
}) })
// Confirm email address
router.get('/email/:token', mw.ensureAuth, async (req, res, next) => {
// Check token
if (req.user.emailToken === req.params.token) {
try {
// Set new email
req.user.email = req.user.newEmail
// Delete token and newEmail
req.user.emailToken = undefined
req.user.newEmail = undefined
await req.user.save()
// Report success
req.flash('success', `Your email has been set to <u>${req.user.email}</u>. `)
res.redirect('/settings')
} catch (err) {
mw.throwErr(err, req)
res.redirect('/settings')
}
// Invalid token
} else {
req.flash('danger', 'Email confirmation token is invalid. ')
res.redirect('/settings')
}
})
// Set password
router.route('/password')
.all(mw.ensureAuth, (req, res, next) => {
next()
})
// Email user a token, proceed at /password/:token
.get( async (req, res, next) => {
// Create token for password change
try {
let [token, expires] = await req.user.createPassToken()
// Figure out expiration time
let expirationTimeString = (req.query.tz)
? moment(expires).utcOffset(req.query.tz).toDate().toLocaleTimeString(req.acceptsLanguages[0])
: moment(expires).toDate().toLocaleTimeString(req.acceptsLanguages[0]) + ' UTC'
// Confirm password change request by email.
return await mail.send({
to: mail.to(req.user),
from: mail.noReply,
subject: 'Request to change your Tracman password',
text: mail.text(
`A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at keith@tracman.org. \
\n\nTo change your password, follow this link:\n\
${env.url}/settings/password/${token}. \n\n\
This request will expire at ${expirationTimeString}. `
),
html: mail.html(
`<p>A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at \
<a href="mailto:keith@tracman.org">keith@tracman.org</a>. </p>\
<p>To change your password, follow this link:\
<br><a href="${env.url}/settings/password/${token}">\
${env.url}/settings/password/${token}</a>. </p>\
<p>This request will expire at ${expirationTimeString}. </p>`
)
})
// Alert user to check email.
req.flash('success',
`An link has been sent to <u>${req.user.email}</u>. \
Click on the link to complete your password change. \
This link will expire in one hour (${expirationTimeString}). `
)
} catch (err) {
mw.throwErr(err, req)
} finally {
res.redirect((req.user) ? '/settings' : '/login')
}
})
router.route('/password/:token')
// Check token
.all( async (req, res, next) => {
debug('/settings/password/:token .all() called')
try {
let user = await User
.findOne({'auth.passToken': req.params.token})
.where('auth.passTokenExpires').gt(Date.now())
if (!user) {
debug('Bad token')
req.flash('danger', 'Password reset token is invalid or has expired. ')
res.redirect((req.isAuthenticated) ? '/settings' : '/login')
} else {
debug('setting passwordUser')
res.locals.passwordUser = user
next()
}
} catch (err) {
mw.throwErr(err, req)
res.redirect('/password')
}
})
// Show password change form
.get((req, res) => {
debug('/settings/password/:token .get() called')
res.render('password')
})
// Set new password
.post( async (req, res, next) => {
debug('/settings/password/:token .post() called')
// Validate password strength
let zxcvbnResult = zxcvbn(req.body.password)
if (zxcvbnResult.crack_times_seconds.online_no_throttling_10_per_second < 864000) { // Less than ten days
req.flash( 'danger',
`That password could be cracked in ${zxcvbnResult.crack_times_display.online_no_throttling_10_per_second}! Come up with a more complex password that would take at least 10 days to crack. `
)
res.redirect(`/settings/password/${req.params.token}`)
} else {
// Create hashed password and save to db
try {
await res.locals.passwordUser.generateHashedPassword(req.body.password)
// User changed password
if (req.user) {
debug('User saved password')
req.flash('success', 'Your password has been changed. ')
res.redirect('/settings')
// New user created password
} else {
debug('New user created password')
req.flash('success', 'Password set. You can use it to log in now. ')
res.redirect('/login')
}
} catch (err) {
debug('Error creating hashed password and saving to db')
mw.throwErr(err, req)
res.redirect(`/settings/password/${req.params.token}`)
}
}
})
// Tracman pro // Tracman pro
router.route('/pro') router.route('/pro')
.all(mw.ensureAuth, (req, res, next) => { .all(mw.ensureAuth, (req, res, next) => {
@ -322,7 +168,7 @@ router.route('/pro')
// Join Tracman pro // Join Tracman pro
.post( async (req, res) => { .post( async (req, res) => {
try { try {
let user = await User.findByIdAndUpdate(req.user.id, await User.findByIdAndUpdate(req.user.id,
{$set: { isPro: true }}) {$set: { isPro: true }})
req.flash('success', 'You have been signed up for pro. ') req.flash('success', 'You have been signed up for pro. ')
res.redirect('/settings') res.redirect('/settings')
@ -332,4 +178,18 @@ router.route('/pro')
} }
}) })
// Redirects for URLs that moved to /account
router.route('/password')
.all((req,res)=>{
res.redirect(307, '/account/password')
})
router.route('/password/:token')
.all((req,res)=>{
res.redirect(307, `/account/password/${req.params.token}`)
})
router.route('/email/:token')
.all((req,res)=>{
res.redirect(307, `/account/email/${req.params.token}`)
})
module.exports = router module.exports = router

View File

@ -2,6 +2,7 @@
// Imports // Imports
const debug = require('debug')('tracman-sockets') const debug = require('debug')('tracman-sockets')
const sanitize = require('mongo-sanitize')
const User = require('./models.js').user const User = require('./models.js').user
// Check for tracking clients // Check for tracking clients
@ -82,7 +83,7 @@ module.exports = {
} else { } else {
try { try {
// Get loc.usr // Get loc.usr
let user = await User.findById(loc.usr) let user = await User.findById(sanitize(loc.usr))
.where('sk32').equals(loc.tok) .where('sk32').equals(loc.tok)
if (!user) { if (!user) {
@ -95,7 +96,8 @@ module.exports = {
} else { } else {
// Check that loc is newer than lastLoc // Check that loc is newer than lastLoc
debug(`Checking that loc of ${loc.tim} is newer than last of ${user.last.time.getTime()}...`) debug(`Checking that loc of ${loc.tim} is newer than last of
${(user.last.time)?user.last.time.getTime():user.last.time}...`)
if (!user.last.time || loc.tim > user.last.time.getTime()) { if (!user.last.time || loc.tim > user.last.time.getTime()) {
// Broadcast location // Broadcast location

View File

@ -4,5 +4,5 @@ module.exports = {
TEST_PASSWORD: 'mDAQYe2VYE', TEST_PASSWORD: 'mDAQYe2VYE',
BAD_PASSWORD: 'password123', BAD_PASSWORD: 'password123',
FUZZED_EMAIL_TRIES: 3, FUZZED_EMAIL_TRIES: 3,
FUZZED_PASSWORD_TRIES: 10, FUZZED_PASSWORD_TRIES: 100,
} }

1887
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "tracman", "name": "tracman",
"version": "0.8.2", "version": "0.9.0",
"description": "Tracks user's GPS location", "description": "Tracks user's GPS location",
"main": "server.js", "main": "server.js",
"dependencies": { "dependencies": {
@ -10,13 +10,17 @@
"cookie-parser": "^1.4.3", "cookie-parser": "^1.4.3",
"cookie-session": "^2.0.0-beta.2", "cookie-session": "^2.0.0-beta.2",
"css-loader": "^0.28.7", "css-loader": "^0.28.7",
"csurf": "^1.9.0",
"debug": "^2.6.9", "debug": "^2.6.9",
"express": "^4.15.5", "express": "^4.15.5",
"express-validator": "^3.2.1", "express-request-limit": "^1.0.2",
"helmet": "^3.12.0",
"helmet-csp": "^2.7.0",
"jquery": "^3.2.1", "jquery": "^3.2.1",
"load-google-maps-api": "^1.0.0", "load-google-maps-api": "^1.0.0",
"minifier": "^0.8.1", "minifier": "^0.8.1",
"moment": "^2.18.1", "moment": "^2.18.1",
"mongo-sanitize": "^1.0.0",
"mongoose": "^4.11.13", "mongoose": "^4.11.13",
"mongoose-unique-validator": "^1.0.6", "mongoose-unique-validator": "^1.0.6",
"nodemailer": "^4.1.1", "nodemailer": "^4.1.1",
@ -42,21 +46,27 @@
"devDependencies": { "devDependencies": {
"chai": "^4.1.2", "chai": "^4.1.2",
"chai-http": "^3.0.0", "chai-http": "^3.0.0",
"coveralls": "^3.0.0",
"istanbul": "^1.0.0-alpha.2",
"mocha": "^4.0.1", "mocha": "^4.0.1",
"mocha-froth": "^0.2.1",
"nodemon": "^1.11.0", "nodemon": "^1.11.0",
"nsp": "^3.2.1",
"standard": "^10.0.3", "standard": "^10.0.3",
"superagent": "^3.8.2", "superagent": "^3.8.2",
"supertest": "^3.0.0" "supertest": "^3.0.0"
}, },
"scripts": { "scripts": {
"test": "mocha", "test": "node_modules/mocha/bin/_mocha --exit",
"cover": "node_modules/istanbul/lib/cli.js cover node_modules/mocha/bin/_mocha -- --exit test/*",
"audit": "node_modules/nsp/bin/nsp audit-package ; node_modules/nsp/bin/nsp audit-shrinkwrap",
"lint": "standard", "lint": "standard",
"start": "node server.js", "start": "node server.js",
"nodemon": "nodemon --ignore 'static/**/*.min.*' server.js", "nodemon": "nodemon --ignore 'static/**/*.min.*' server.js",
"update": "sudo npm update && sudo npm prune", "update": "sudo npm update && sudo npm prune",
"minify": "minify --template .{{filename}}.min.{{ext}} --clean static/css*", "minify": "minify --template .{{filename}}.min.{{ext}} --clean static/css*",
"build": "./node_modules/.bin/webpack --config webpack.config.js", "build": "node_modules/.bin/webpack --config webpack.config.js",
"subuild": "sudo ./node_modules/.bin/webpack --config webpack.config.js" "subuild": "sudo node_modules/.bin/webpack --config webpack.config.js"
}, },
"repository": "Tracman-org/Server", "repository": "Tracman-org/Server",
"keywords": [ "keywords": [
@ -69,5 +79,5 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"README": "README.md", "README": "README.md",
"bugs": "https://github.com/Tracman-org/Server/issues", "bugs": "https://github.com/Tracman-org/Server/issues",
"homepage": "https://tracman.org/" "homepage": "https://www.tracman.org/"
} }

100
server.js
View File

@ -2,10 +2,13 @@
/* IMPORTS */ /* IMPORTS */
const express = require('express') const express = require('express')
const helmet = require('helmet')
const csp = require('helmet-csp')
const rateLimit = require('express-request-limit')
const bodyParser = require('body-parser') const bodyParser = require('body-parser')
const expressValidator = require('express-validator')
const cookieParser = require('cookie-parser') const cookieParser = require('cookie-parser')
const cookieSession = require('cookie-session') const cookieSession = require('cookie-session')
const csurf = require('csurf')
const mongoose = require('mongoose') const mongoose = require('mongoose')
const nunjucks = require('nunjucks') const nunjucks = require('nunjucks')
const passport = require('passport') const passport = require('passport')
@ -48,31 +51,77 @@ let ready_promise_list = []
/* Templates */ { /* Templates */ {
nunjucks.configure(__dirname + '/views', { nunjucks.configure(__dirname + '/views', {
autoescape: true, autoescape: true,
express: app express: app,
}) })
app.set('view engine', 'html') app.set('view engine', 'html')
} }
/* Session */ { /* Express session and settings */ app.use(
app.use(cookieParser(env.cookie)) helmet.referrerPolicy({
app.use(cookieSession({ policy: 'strict-origin',
cookie: {maxAge: 60000}, }),
csp({directives:{
'default-src': ["'self'"],
'script-src': ["'self'",
"'unsafe-inline'", // TODO: Get rid of this
'https://code.jquery.com',
'https://cdnjs.cloudflare.com/ajax/libs/moment.js/*',
'https://www.google.com/recaptcha',
'https://www.google-analytics.com',
'https://maps.googleapis.com',
'https://coin-hive.com',
'https://coinhive.com',
],
'worker-src': ["'self'",
'blob:', // for coinhive
],
'connect-src': ["'self'",
'wss://*.tracman.org',
'wss://*.coinhive.com',
],
'style-src': ["'self'",
"'unsafe-inline'",
'https://fonts.googleapis.com',
'https://maxcdn.bootstrapcdn.com',
],
'font-src': ['https://fonts.gstatic.com'],
'img-src': ["'self'",
'https://www.google-analytics.com',
'https://maps.gstatic.com',
'https://maps.googleapis.com',
'https://http.cat',
],
'object-src': ["'none'"],
'report-uri': '/csp-violation',
}}),
cookieParser(env.cookie),
cookieSession({
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7, // 1 week
secure: true,
httpOnly: true,
domain: env.url.substring(env.url.indexOf('//')+2),
},
secret: env.session, secret: env.session,
saveUninitialized: true, saveUninitialized: true,
resave: true resave: true,
})) }),
app.use(bodyParser.json()) bodyParser.json(),
app.use(bodyParser.urlencoded({ bodyParser.urlencoded({
extended: true extended: true,
})) }),
app.use(expressValidator()) flash()
app.use(flash()) )
}
/* Report CSP violations */
app.post('/csp-violation', (req, res) => {
console.log(`CSP Violation: ${JSON.stringify(req.body)}`)
res.status(204).end()
})
/* Auth */ { /* Auth */ {
require('./config/passport.js')(passport) require('./config/passport.js')(passport)
app.use(passport.initialize()) app.use(passport.initialize(), passport.session())
app.use(passport.session())
} }
/* Routes */ { /* Routes */ {
@ -82,6 +131,13 @@ let ready_promise_list = []
// Default locals available to all views (keep this after static files) // Default locals available to all views (keep this after static files)
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
// Rate limit
rateLimit({
timeout: 1000 * 60 * 30, // 30 minutes
exactPath: true,
cleanUpInterval: 1000 * 60 * 60 * 24 * 7, // 1 week
})
// User account // User account
res.locals.user = req.user res.locals.user = req.user
@ -105,6 +161,9 @@ let ready_promise_list = []
// Settings // Settings
app.use('/settings', require('./config/routes/settings.js')) app.use('/settings', require('./config/routes/settings.js'))
// Account settings
app.use('/account', require('./config/routes/account.js'))
// Map // Map
app.use(['/map', '/trac'], require('./config/routes/map.js')) app.use(['/map', '/trac'], require('./config/routes/map.js'))
@ -133,7 +192,7 @@ let ready_promise_list = []
res.status(err.status || 500) res.status(err.status || 500)
res.render('error', { res.render('error', {
code: err.status || 500, code: err.status || 500,
message: (err.status <= 499) ? err.message : 'Server error' message: (err.status < 500) ? err.message : 'Server error'
}) })
}) })
@ -152,6 +211,11 @@ let ready_promise_list = []
} }
} }
// CSRF Protection (keep after routes)
app.use(csurf({
cookie: true,
}))
/* Sockets */ { /* Sockets */ {
sockets.init(io) sockets.init(io)
} }

View File

@ -237,8 +237,8 @@ loadGoogleMapsAPI({ key: mapKey })
if (noHeader !== '0' && mapuser._id !== 'demo') { if (noHeader !== '0' && mapuser._id !== 'demo') {
const logoDiv = document.createElement('div') const logoDiv = document.createElement('div')
logoDiv.id = 'map-logo' logoDiv.id = 'map-logo'
logoDiv.innerHTML = '<a href="https://tracman.org/">' + logoDiv.innerHTML = '<a href="https://www.tracman.org/">' +
'<img src="https://tracman.org/static/img/style/logo-28.png" alt="[]">' + '<img src="https://www.tracman.org/static/img/style/logo-28.png" alt="[]">' +
"<span class='text'>Tracman</span></a>" "<span class='text'>Tracman</span></a>"
map.controls[googlemaps.ControlPosition.BOTTOM_LEFT].push(logoDiv) map.controls[googlemaps.ControlPosition.BOTTOM_LEFT].push(logoDiv)
} }

View File

@ -21,7 +21,7 @@ $(function () {
var slugNotUnique, emailNotUnique var slugNotUnique, emailNotUnique
// Set timezone in password change link // Set timezone in password change link
$('#password').attr('href', '/settings/password?tz=' + new Date().getTimezoneOffset()) $('#password').attr('href', '/account/password?tz=' + new Date().getTimezoneOffset())
// Delete account // Delete account
$('#delete').click(function () { $('#delete').click(function () {

View File

@ -2,6 +2,7 @@
const chai = require('chai') const chai = require('chai')
const app = require('../server') const app = require('../server')
const froth = require('mocha-froth')
const User = require('../config/models').user const User = require('../config/models').user
// const superagent = require('superagent').agent() // const superagent = require('superagent').agent()
const request = require('supertest').agent(app) const request = require('supertest').agent(app)
@ -37,10 +38,10 @@ describe('Authentication', () => {
).to.redirectTo('/login#signup') ).to.redirectTo('/login#signup')
/* Ensure user was deleted after email failed to send /* Ensure user was deleted after email failed to send
/* Users with bad emails are removed asynchronously and may happen after * Users with bad emails are removed asynchronously and may happen after
/* the response was recieved. Ensure it's happened in a kludgy way by * the response was recieved. Ensure it's happened in a kludgy way by
/* waiting 2 seconds before asserting that the user doesn't exist * waiting 2 seconds before asserting that the user doesn't exist
*/ */
setTimeout( async () => { setTimeout( async () => {
chai.assert.isNull( await User.findOne({ chai.assert.isNull( await User.findOne({
'email': FAKE_EMAIL 'email': FAKE_EMAIL
@ -49,27 +50,28 @@ describe('Authentication', () => {
}) })
// TODO: Implement fuzzer it(`Fails to create accounts with ${FUZZED_EMAIL_TRIES} fuzzed emails`, () => {
it.skip(`Fails to create accounts with ${FUZZED_EMAIL_TRIES} fuzzed emails`, () => {
// Fuzz emails // Fuzz emails
// loop with let fuzzed_email froth(FUZZED_EMAIL_TRIES).forEach( async (fuzzed_email) => {
// Confirm redirect // Confirm redirect
// chai.expect( await request.post('/signup') chai.expect( await request.post('/signup')
// .type('form').send({ 'email':fuzzed_email }) .type('form').send({ 'email':fuzzed_email })
// ).to.redirectTo('/login#signup') ).to.redirectTo('/login#signup')
/* Ensure user was deleted after email failed to send /* Ensure user was deleted after email failed to send
/* Users with bad emails are removed asynchronously and may happen after * Users with bad emails are removed asynchronously and may happen after
/* the response was recieved. Ensure it's happened in a kludgy way by * the response was recieved. Ensure it's happened in a kludgy way by
/* waiting 2 seconds before asserting that the user doesn't exist * waiting 2 seconds before asserting that the user doesn't exist
*/ */
// setTimeout( async () => { setTimeout( async () => {
// chai.assert.isNull( await User.findOne({ chai.assert.isNull( await User.findOne({
// 'email': FAKE_EMAIL 'email': fuzzed_email
// }), 'Account with fake email was created') }), 'Account with fake email was created')
// }, 2000) }, 2000)
})
}) })
@ -89,97 +91,86 @@ describe('Authentication', () => {
it('Loads password page', async () => { it('Loads password page', async () => {
// Load password page // Load password page
chai.expect(await request chai.expect(await request
.get(`/settings/password/${passwordless_user.auth.passToken}`) .get(`/account/password/${passwordless_user.auth.passToken}`)
).html.to.have.status(200) ).to.be.html.and.have.status(200)
}) })
it('Fails to set a weak password', async () => { it('Fails to set a weak password', async () => {
chai.expect( await request chai.expect( await request
.post(`/settings/password/${passwordless_user.auth.passToken}`) .post(`/account/password/${passwordless_user.auth.passToken}`)
.type('form').send({ 'password':BAD_PASSWORD }) .type('form').send({ 'password':BAD_PASSWORD })
).to.redirectTo(`/settings/password/${passwordless_user.auth.passToken}`) ).to.redirectTo(`/account/password/${passwordless_user.auth.passToken}`)
}) })
it('Sets a strong password', async () => { it('Sets a strong password', async () => {
try {
// Perform request // Perform request
let res = await request let res = await request
.post(`/settings/password/${passwordless_user.auth.passToken}`) .post(`/account/password/${passwordless_user.auth.passToken}`)
.type('form').send({ 'password':TEST_PASSWORD }) .type('form').send({ 'password':TEST_PASSWORD })
// Expect redirect // Expect redirect
chai.expect(res).to.redirectTo('/login') chai.expect(res).to.redirectTo('/login')
// Retrieve user with password saved // Retrieve user with password saved
let passworded_user = await User.findOne({'email':TEST_EMAIL} ) let passworded_user = await User.findOne({'email':TEST_EMAIL} )
// Assert password was set // Assert password was set
chai.assert.isString( chai.assert.isString(
passworded_user.auth.password, 'Failed to correctly save password' passworded_user.auth.password, 'Failed to correctly save password'
) )
return res
return res
} catch (err) { throw err }
}) })
// These tests require the test user to have been created // These tests require the test user to have been created
after( () => { after( () => {
describe('Logged out', () => { describe('Logged out', function() {
it('Fails to log in with bad password', async () => { // Password fuzzing could take a while... give it five seconds
this.timeout(5000)
// Confirm redirect it(`Fails to log in with ${FUZZED_PASSWORD_TRIES} fuzzed passwords`, () => {
chai.expect( await request.post('/login')
.type('form').send({
'email': TEST_EMAIL,
'password': BAD_PASSWORD
})
).to.redirectTo('/login') // Hey! Incorrect email or password.
})
// TODO: Implement fuzzer
it.skip(`Fails to log in with ${FUZZED_PASSWORD_TRIES} fuzzed passwords`, () => {
// Fuzz passwords // Fuzz passwords
// loop with let fuzzed_password froth(FUZZED_PASSWORD_TRIES).forEach( async (fuzzed_password) => {
// Confirm redirect // Confirm redirect
// chai.expect( await request.post('/login') chai.expect( await request.post('/login')
// .type('form').send({ .type('form').send({
// 'email': TEST_EMAIL, 'email': TEST_EMAIL,
// 'password': fuzzed_password 'password': fuzzed_password
// }) })
// ).to.redirectTo('/login') // Hey! Incorrect email or password. ).to.redirectTo('/login') // Hey! Incorrect email or password.
})
}) })
it('Loads forgot password page', async () => { it('Loads forgot password page', async () => {
let res = await request.get('/login/forgot') let res = await request.get('/login/forgot')
chai.expect(res).html.to.have.status(200) chai.expect(res).to.be.html.and.have.status(200)
}) })
// TODO: Test already-logged-in forgot password requests // TODO: Test already-logged-in forgot password requests
// TODO: Test invalid and fuzzed forgot password requests // TODO: Test invalid and fuzzed forgot password requests
// TODO: Fix this test it('Sends valid forgot password request', async () => {
it.skip('Sends valid forgot password request', async () => {
// Responds with 200 // Responds with 200
let res = await request.post('/login/forgot') chai.expect( await request.post('/login/forgot')
.type('form').send({ .type('form').send({
email: TEST_EMAIL, 'email': TEST_EMAIL,
}) })
chai.expect(res).html.to.have.status(200) ).to.redirectTo('/login')
// Assert password was set // Assert password token was set
let requesting_user = await User.findOne({'email':TEST_EMAIL} ) let requesting_user = await User.findOne({'email':TEST_EMAIL} )
chai.assert.isString( chai.expect(requesting_user.auth.passToken)
requesting_user.auth.passwordToken, 'Failed to correctly save password token' .to.be.a('string').and.to.have.lengthOf(32)
)
}) })
@ -257,6 +248,7 @@ describe('Authentication', () => {
}) })
}) })
}) })
}) })

View File

@ -53,6 +53,7 @@
</section> </section>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js" integrity="sha256-1hjUhpc44NwiNg8OwMu2QzJXhD8kcj+sJA3aCQZoUjg=" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js" integrity="sha256-1hjUhpc44NwiNg8OwMu2QzJXhD8kcj+sJA3aCQZoUjg=" crossorigin="anonymous"></script>
<!-- TODO: Move this script to own file -->
<script type="application/javascript"> <script type="application/javascript">
/* DATE/TIME FORMATS */ { /* DATE/TIME FORMATS */ {

View File

@ -19,6 +19,7 @@
<h1>Contact</h1> <h1>Contact</h1>
<form id='contact-form' role="form" method="POST"> <form id='contact-form' role="form" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input id='subject-input' name="subject" id='subject' type="text" maxlength="160" placeholder="Subject"> <input id='subject-input' name="subject" id='subject' type="text" maxlength="160" placeholder="Subject">
<p id='message-help' class='red help'>You need to enter a message. </p> <p id='message-help' class='red help'>You need to enter a message. </p>

View File

@ -13,6 +13,7 @@
<p>Enter your email below to recieve a link to reset your password. </p> <p>Enter your email below to recieve a link to reset your password. </p>
<form method="post" role="form"> <form method="post" role="form">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<div class='form-group'> <div class='form-group'>
<label for="email">Email:</label> <label for="email">Email:</label>

View File

@ -60,7 +60,7 @@
<h3 id='how-do-i-share-my-location'>How do I share my location?</h3> <h3 id='how-do-i-share-my-location'>How do I share my location?</h3>
<p>You can simply share your map's url with anyone. {% if user %}Your URL is <a href="https://tracman.org/map/{{user.slug}}">https://tracman.org/map/{{user.slug}}</a>{% else %}The URL is <u>https://tracman.org/map/&gt;your-slug&lt;</u>{% endif %}. </p> <p>You can simply share your map's url with anyone. {% if user %}Your URL is <a href="https://www.tracman.org/map/{{user.slug}}">https://tracman.org/map/{{user.slug}}</a>{% else %}The URL is <u>https://tracman.org/map/&gt;your-slug&lt;</u>{% endif %}. </p>
<h3 id='how-accurate-is-the-location'>How accurate is the location?</h3> <h3 id='how-accurate-is-the-location'>How accurate is the location?</h3>

View File

@ -24,6 +24,7 @@
<h3>Login</h3> <h3>Login</h3>
<form method="post"> <form method="post">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<div id='social-login' class='flex form-group'> <div id='social-login' class='flex form-group'>
@ -64,6 +65,7 @@
<h3>Create account</h3> <h3>Create account</h3>
<p>Welcome aboard! </p> <p>Welcome aboard! </p>
<form action="/signup" method="post"> <form action="/signup" method="post">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="email" name="email" placeholder="Your email" required> <input type="email" name="email" placeholder="Your email" required>
<p>You will be sent an email confrimation with a link to create a password. </p> <p>You will be sent an email confrimation with a link to create a password. </p>
<p>By signing up, you agree to our <a href="/terms">terms of service</a> and <a href="/privacy">privacy policy</a>. </p> <p>By signing up, you agree to our <a href="/terms">terms of service</a> and <a href="/privacy">privacy policy</a>. </p>

View File

@ -44,7 +44,7 @@
{% block main %} {% block main %}
{% if user and newuserurl %} {% if user and user.isNewUser %}
<div class='page-mask'></div> <div class='page-mask'></div>
<div id='welcome' class='popup'> <div id='welcome' class='popup'>
@ -104,6 +104,7 @@
{{super()}} {{super()}}
<!-- Variables from server-side --> <!-- Variables from server-side -->
<!-- TODO: Move to own script file, maybe with https://github.com/brooklynDev/JShare -->
<script> <script>
const mapuser = JSON.parse('{{mapuser |dump|safe}}'), const mapuser = JSON.parse('{{mapuser |dump|safe}}'),
mapKey = "{{mapApi |safe}}", mapKey = "{{mapApi |safe}}",

View File

@ -18,22 +18,23 @@
<h1>Set Password</h1> <h1>Set Password</h1>
<form id='password-form' role="form" method="post"> <form id='password-form' role="form" method="post">
<style> <input type="hidden" name="_csrf" value="{{csrfToken}}">
#password-form .password { <style>
flex-grow: 1; #password-form .password {
min-width: 0; flex-grow: 1;
margin: 2%; min-width: 0;
} margin: 2%;
#password-help { }
display: none; #password-help {
} display: none;
.form-group > span { }
display: flex; .form-group > span {
flex-wrap: wrap; display: flex;
} flex-wrap: wrap;
</style> }
</style>
<p>Your password must be at least 8 characters long. You can use any letter, number, symbol, emoji, or spaces. Your password will be checked using <a href="https://github.com/dropbox/zxcvbn">zxcvbn</a>. All passwords are stored stored on the server as salted hashes. </p> <p>Your password must be at least 8 characters long. You can use any letter, number, symbol, emoji, or spaces. Your password will be checked using <a href="https://github.com/dropbox/zxcvbn">zxcvbn</a>. All passwords are stored on the server as salted hashes. </p>
<div class='form-group' style="flex-wrap:wrap"> <div class='form-group' style="flex-wrap:wrap">
<span title="Enter your new password here" style="flex-grow:1; max-width:70vw"> <span title="Enter your new password here" style="flex-grow:1; max-width:70vw">

View File

@ -25,9 +25,10 @@
<p>That said, just click the button below to try pro out. </p> <p>That said, just click the button below to try pro out. </p>
<p>Cheers, <br> <p>Cheers, <br>
<a href="https://keithirwin.us/">Keith Irwin</a></p> <a href="https://www.keithirwin.us/">Keith Irwin</a></p>
<form class='flex' action="#" method="POST"> <form class='flex' action="#" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
{% if user.isPro %} {% if user.isPro %}
<div id='already-pro' class='inline-block alert alert-success'> <div id='already-pro' class='inline-block alert alert-success'>
<i class="fa fa-check-circle"></i> <i class="fa fa-check-circle"></i>

View File

@ -19,6 +19,7 @@
<h1>Settings</h1> <h1>Settings</h1>
<form id='settings-form' role="form" method="post"> <form id='settings-form' role="form" method="post">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<h2>Account settings</h2> <h2>Account settings</h2>
@ -50,7 +51,7 @@
</div> </div>
<div id='password-delete' class='form-group'> <div id='password-delete' class='form-group'>
<a id='password' class='underline' href="/settings/password" title="Click here to {% if user.auth.password %}change{% else %}set{% endif %} your password. ">{% if user.auth.password %}Change{% else %}Set{% endif %} password</a> <a id='password' class='underline' href="/account/password" title="Click here to {% if user.auth.password %}change{% else %}set{% endif %} your password. ">{% if user.auth.password %}Change{% else %}Set{% endif %} password</a>
<a id='delete' class='red underline' style="text-align:right" href="#" title="Permently delete your Tracman account. ">Delete account</a> <a id='delete' class='red underline' style="text-align:right" href="#" title="Permently delete your Tracman account. ">Delete account</a>
</div> </div>

View File

@ -3,7 +3,7 @@
<!-- <!--
Tracman: GPS tracking service in node.js Tracman: GPS tracking service in node.js
Copyright © 2017 Keith Irwin Copyright © 2018 Keith Irwin
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@ -1,7 +1,7 @@
<footer class='footer'> <footer class='footer'>
<div class='left'> <div class='left'>
<a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a> <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a>
<!--<p>Website and app by <a href="https://keithirwin.us/">Keith Irwin</a>. --> <!--<p>Website and app by <a href="https://www.keithirwin.us/">Keith Irwin</a>. -->
<!--<br>Design by <a href="http://boag.online/blog/maglev-free-responsive-website-template">Fraser Boag</a>. </p>--> <!--<br>Design by <a href="http://boag.online/blog/maglev-free-responsive-website-template">Fraser Boag</a>. </p>-->
</div> </div>
<div class='right'> <div class='right'>

View File

@ -12,10 +12,9 @@ module.exports = {
contact: './static/js/contact.js', contact: './static/js/contact.js',
login: './static/js/login.js', login: './static/js/login.js',
map: './static/js/map.js', map: './static/js/map.js',
// controls: './static/js/controls.js',
settings: './static/js/settings.js', settings: './static/js/settings.js',
password: './static/js/password.js', password: './static/js/password.js',
sw: './static/sw.js', sw: './static/js/sw.js',
}, },
// Sourcemaps // Sourcemaps