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

@ -6,7 +6,7 @@
<link href="/static/css/.map.min.css" rel="stylesheet"> <link href="/static/css/.map.min.css" rel="stylesheet">
<style>/* These styles include values passed from to the template from the server. Other styles are in map.css */ <style>/* These styles include values passed from to the template from the server. Other styles are in map.css */
{% if noHeader!='0' %} main { top: 0; } {% endif %} {% if noHeader!='0' %} main { top: 0; } {% endif %}
{% if mapuser.settings.showStreetview and disp=='2' %} {% if mapuser.settings.showStreetview and disp=='2' %}
/* show both */ /* show both */
@media (orientation: landscape) { @media (orientation: landscape) {
@ -44,42 +44,42 @@
{% 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'>
<div class='topbar'> <div class='topbar'>
<h2>Welcome!</h2> <h2>Welcome!</h2>
<div class='close' onclick="$('#welcome').hide();$('.page-mask').hide();">✖️</div> <div class='close' onclick="$('#welcome').hide();$('.page-mask').hide();">✖️</div>
</div> </div>
<p>This is your map. It's publicly avaliable at <a href="{{newuserurl}}">{{newuserurl}}</a>. You can change that URL and other settings in <b><a href="/settings">settings</a></b>. Set your location by clicking <b>set</b> below. Clear it by clicking <b>clear</b>. To track your location, click <b>track</b> or download the <a href="/android">android app</a>. For more information, see the <b><a href="/help">help</a></b> page. </p> <p>This is your map. It's publicly avaliable at <a href="{{newuserurl}}">{{newuserurl}}</a>. You can change that URL and other settings in <b><a href="/settings">settings</a></b>. Set your location by clicking <b>set</b> below. Clear it by clicking <b>clear</b>. To track your location, click <b>track</b> or download the <a href="/android">android app</a>. For more information, see the <b><a href="/help">help</a></b> page. </p>
<div class='buttons'> <div class='buttons'>
<a class='btn main' onclick="$('#welcome').hide();$('.page-mask').hide();">Got it!</a> <a class='btn main' onclick="$('#welcome').hide();$('.page-mask').hide();">Got it!</a>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Map --> <!-- Map -->
{% if disp!='1' %} {% if disp!='1' %}
<div id='map'></div> <div id='map'></div>
{% endif %} {% endif %}
<!-- Streetview --> <!-- Streetview -->
{% if mapuser.settings.showStreetview and disp!='0' %} {% if mapuser.settings.showStreetview and disp!='0' %}
<div id='view'><img id='viewImg' alt="Streetview image"/></div> <div id='view'><img id='viewImg' alt="Streetview image"/></div>
{% endif %} {% endif %}
<div id='notset' class='centered alert alert-warning'> <div id='notset' class='centered alert alert-warning'>
{% if user.id == mapuser.id %} {% if user.id == mapuser.id %}
Your location is unset. You can click 'set' below to set it to your current position. Your location is unset. You can click 'set' below to set it to your current position.
{% else %} {% else %}
This user has no location set. This user has no location set.
{% endif %} {% endif %}
</div> </div>
{% if user.id == mapuser.id and mapuser._id != 'demo' %} {% if user.id == mapuser.id and mapuser._id != 'demo' %}
<link rel="stylesheet" type="text/css" href="/static/css/.controls.min.css"> <link rel="stylesheet" type="text/css" href="/static/css/.controls.min.css">
<div id='controls'> <div id='controls'>
@ -90,20 +90,21 @@
} }
</style> </style>
{% endif %} {% endif %}
<button id='set-loc' class='btn set' title="Click here to set your location">Set</button> <button id='set-loc' class='btn set' title="Click here to set your location">Set</button>
<button id='track-loc' class='btn track' title="Click here to track your location"><i class='fa fa-crosshairs'></i>Track</button> <button id='track-loc' class='btn track' title="Click here to track your location"><i class='fa fa-crosshairs'></i>Track</button>
<button id='clear-loc' class='btn clear' title="Click here to clear your location">Clear</button> <button id='clear-loc' class='btn clear' title="Click here to clear your location">Clear</button>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
{{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}}",
@ -112,11 +113,11 @@
userid = "{{user._id |safe}}", userid = "{{user._id |safe}}",
token = "{{user.sk32 |safe}}"; token = "{{user.sk32 |safe}}";
</script> </script>
<!-- Webpacked bundles --> <!-- Webpacked bundles -->
<script type="application/javascript" src="/static/js/.map.bun.js"></script> <script type="application/javascript" src="/static/js/.map.bun.js"></script>
<!--{% if user.id == mapuser.id %}--> <!--{% if user.id == mapuser.id %}-->
<!--<script type="application/javascript" src="/static/js/.controls.bun.js"></script>--> <!--<script type="application/javascript" src="/static/js/.controls.bun.js"></script>-->
<!--{% endif %}--> <!--{% endif %}-->
{% endblock %} {% endblock %}

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