Commit c48334ab authored by Jarrod's avatar Jarrod 💬

Detach auth from core; Tweak tweak

See merge request !1
parents 29e4df2f 38676030
Pipeline #3872 passed with stage
in 24 seconds
// This file really only exists for dev purposes
// ie, you can dump this folder into your projects `api/hooks/ahoy` folder and it'll pick up this file and load her up
module.exports = require('./lib/index.js')
module.exports = {
friendlyName: 'Get a list of user accounts',
description: '',
inputs: {
},
fn: async function(inputs) {
const users = await User.find({ })
.select(['id', 'username', 'emailVerified', 'isAdmin', 'createdAt', 'lastSeen'])
.sort(['username'])
return { users }
}
}
\ No newline at end of file
/* global _, sails */
module.exports = {
friendlyName: 'Get API Documentation',
description: 'Get machine-readable API Documentation',
fn: async function (inputs, exits) {
var endpointsByMethodName = {}
var extraEndpointsOnlyForTestsByMethodName = {}
_.each(sails.config.routes, (target) => {
// If the route target is an array, then only consider
// the very last sub-target in the array.
if (_.isArray(target)) {
target = _.last(target)
}
// Skip redirects
// (Note that, by doing this, we also skip traditional shorthand
// -- that's ok though.)
if (_.isString(target)) {
return
}
// Skip routes whose target doesn't contain `action` for any
// other miscellaneous reason.
if (!target.action) {
return
}
// Just about everything else gets a Cloud SDK method.
// We determine its name using the bare action name.
var bareActionName = target.action.replace(/\//g, '-')
var methodName = target.name || _.camelCase(bareActionName)
var expandedAddress = sails.getRouteFor(target)
// Skip routes that just serve views.
// (but still generate them for use in tests, for convenience)
if (target.view || (bareActionName.match(/^view-/))) {
extraEndpointsOnlyForTestsByMethodName[methodName] = {
verb: (expandedAddress.method || 'get').toUpperCase(),
url: expandedAddress.url
}
return
}
endpointsByMethodName[methodName] = {
verb: (expandedAddress.method || 'get').toUpperCase(),
url: expandedAddress.url,
package: target.package
}
// If this is an actions2 action, then determine appropriate serial usage.
// (deduced the same way as helpers)
// > If there is no such action for some reason, then don't compile a
// > method for this one.
var requestable = sails.getActions()[target.action]
if (!requestable) {
sails.log.warn('Skipping unrecognized action: `' + target.action + '`')
return
}
var def = requestable.toJSON && requestable.toJSON()
if (def && def.fn) {
endpointsByMethodName[methodName].name = def.friendlyName
endpointsByMethodName[methodName].description = def.description
endpointsByMethodName[methodName].cache = target.cache
endpointsByMethodName[methodName].protocol = target.isSocket ? 'socket' : 'http'
if (def.args !== undefined) {
endpointsByMethodName[methodName].args = def.args
} else {
endpointsByMethodName[methodName].args = _.reduce(def.inputs, (args, inputDef, inputCodeName) => {
args.push(inputCodeName)
return args
}, [])
}
endpointsByMethodName[methodName].inputs = def.inputs
endpointsByMethodName[methodName].exits = def.exits
}
}) // ∞
return exits.success(endpointsByMethodName)
}
}
module.exports = {
friendlyName: 'Complete password reset',
description: 'Update a users password',
inputs: {
e: {
type: 'string',
required: true
},
t: {
type: 'string',
required: true
},
password: {
type: 'string',
required: true,
minLength: 5
}
},
exits: {
},
fn: async function (inputs, exits) {
const crypto = require('crypto')
const jwt = require('machinepack-jwt-ng')
const config = sails.config.custom
let user = await User.findOne({
where: { passwordResetVerificationToken: inputs.t }
});
if (!user) { return this.res.badRequest('Invalid token') }
const emailHash = crypto
.createHash('md5')
.update(user.email, 'utf8')
.digest('hex')
if (inputs.e !== emailHash) {
if (!user) { return this.res.badRequest('Invalid token') }
}
user = await User.updateOne({ id: user.id })
.set({
password: inputs.password,
passwordResetVerificationToken: null,
emailVerified: true
})
let token = await jwt.sign({ user: User.pickToken(user) }, config.jwt)
this.res.set('X-Token-Update', token)
return exits.success({ status: 'ok' })
}
}
module.exports = {
friendlyName: 'Confirm a recovery token/email pair exists',
description: 'I guess this is used on the "reset password" page to push the user away if given an invalid token/email',
inputs: {
e: {
type: 'string',
required: true
},
t: {
type: 'string',
required: true
}
},
exits: {
},
fn: async function (inputs, exits) {
const crypto = require('crypto')
let user = await User.findOne({
passwordResetVerificationToken: inputs.t
})
if (!user) { return this.res.badRequest('Invalid token') }
let emailHash = crypto
.createHash('md5')
.update(user.emailAddress, 'utf8')
.digest('hex')
if (inputs.e !== emailHash) {
if (!user) { return this.res.badRequest({ error: 'Invalid token' }); }
}
return exits.success({ status: 'ok' })
}
}
module.exports = {
friendlyName: 'Login',
description: 'Check a given username/password combination and return a JWT if a user match is found',
inputs: {
username: {
type: 'string',
required: true
},
password: {
type: 'string',
required: true
}
},
exits: {
},
fn: async function ({ username, password }) {
const jwt = require('machinepack-jwt-ng')
const bcrypt = require('bcrypt')
let error = this.req.i18n.__('auth.invalid') // 'Invalid username or password'
let user = await User.findOne({ username })
.catch((err) => {
sails.log.error('Unable to check if signup users username is taken!', err.stack)
})
if (!user) {
return this.res.badRequest(error)
}
//check password
if (!await bcrypt.compare(password, user.password)) {
return this.res.badRequest(error)
}
let token = await jwt.sign({ user: User.pickToken(user) }, sails.config.custom.jwt)
return {
user,
token
}
}
}
module.exports = {
friendlyName: 'Login',
description: 'Check a given username/password combination and return a JWT if a user match is found',
inputs: {
email: {
required: true,
isEmail: true
}
},
exits: {
},
fn: async function ({ email }, exits) {
const crypto = require('crypto')
const uuid = require('uuid/v4')
let user = await User.findOne({ email })
if (!user) {
return this.res.badRequest('No such user')
}
let emailHash = crypto
.createHash('md5')
.update(user.email, 'utf8')
.digest('hex')
// Generate a reset token & store in db
let token = uuid()
user = await User
.updateOne({ id: user.id })
.set({ passwordResetVerificationToken: token })
// Send the email
let sent = await sails.helpers.ahoy.email.send('password-recovery', user.email, {
subject: 'Password Recovery',
//...
user,
email: emailHash,
token
});
// Return some success status
if (sent) {
return { status: 'ok' }
}
return this.res.serverError('Failed')
}
}
module.exports = {
friendlyName: 'Re-send verification email',
description: '',
inputs: {
},
exits: {
},
fn: async function (inputs, exits) {
const uuid = require('uuid/v4')
const crypto = require('crypto')
let user = this.req.me
if (!user) { return this.res.unauthorized() }
let emailHash = crypto
.createHash('md5')
.update(user.emailChangeCandidate ? user.emailChangeCandidate : user.emailAddress, 'utf8')
.digest('hex')
if (!user.emailVerificationToken) {
user = await User.updateOne({ id: user.id })
.set({
emailVerificationToken: uuid()
// TODO:: emailVerificationTokenExpiresAt: new Date(now - 2-24hrs?)
})
}
// Send the email (async!)
try {
await sails.helpers.ahoy.email.send('email-verification', user.emailChangeCandidate ? user.emailChangeCandidate : user.emailAddress, {
subject: `Verify your email for ${sails.config.custom.site.name}`,
user: user,
email: emailHash,
token: user.emailVerificationToken
})
} catch (err) {
sails.log.error(`Unable to resend verification email`, err)
return res.serverError('Unable to send verification email')
}
return exits.success({ status: 'ok' })
}
}
module.exports = {
friendlyName: 'Register a user account',
description: '',
inputs: {
username: {
type: 'string',
required: true,
minLength: 1,
},
email: {
type: 'string',
required: true,
isEmail: true
},
password: {
type: 'string',
required: true,
minLength: 5,
}
},
exits: {
created: {
statusCode: 201
}
},
fn: async function (data, exits) {
const crypto = require('crypto')
const jwt = require('machinepack-jwt-ng')
const uuid = require('uuid/v4')
const config = sails.config.custom
let errors = {}
let existingUser = await User.count({ username: data.username })
if (existingUser) {
errors.username = ['That username is already taken']
}
existingUser = await User.count({ emailAddress: data.email })
if (existingUser) {
errors.email = ['That email address is already in use']
}
if (!_.isEmpty(errors)) {
return this.res.badRequest({
error: 'There was a problem with the entered values',
fields: errors
})
}
let newUser
data.emailAddress = data.email
data.emailVerificationToken = uuid()
try {
newUser = await User.create(data).fetch()
} catch (err) {
return this.res.serverError(err)
}
let emailHash = crypto
.createHash('md5')
.update(newUser.emailAddress, 'utf8')
.digest('hex')
// Send the email
await sails.helpers.ahoy.email.send('new-account', newUser.emailAddress, {
subject: `Welcome to ${config.site.name}`,
user: newUser,
email: emailHash,
token: data.emailVerificationToken
}).then(() => { /* do nothing */ })
let token = await jwt.sign({ user: User.pickToken(newUser) }, config.jwt)
exits.created({ user: newUser, token })
}
}
module.exports = {
friendlyName: 'Verify user email account',
inputs: {
e: {
type: 'string',
required: true
},
t: {
type: 'string',
required: true
}
},
exits: {
},
fn: async function (inputs, exits) {
const crypto = require('crypto')
const jwt = require('machinepack-jwt-ng')
const config = sails.config.custom
let user = await User.findOne({
emailVerificationToken: inputs.t
})
if (!user) { return this.res.badRequest('Invalid token') }
let emailHash = crypto
.createHash('md5')
.update(user.emailAddress, 'utf8')
.digest('hex')
if (inputs.e !== emailHash) {
return this.res.badRequest('Invalid token')
}
let update = {}
update.emailVerificationToken = null
update.emailVerified = true
user = await User
.updateOne({ id: user.id })
.set(update)
let token = await jwt.sign({ user: User.pickToken(user) }, config.jwt)
this.res.set('X-Token-Update', token)
return exits.success({ status: 'ok' })
}
}
......@@ -3,35 +3,13 @@
*/
const Pluggable = require('./pluggable')
const loadUserMiddleware = require('./middleware/user')
module.exports = new Pluggable({
apiBase: '',
key: 'ahoy',
dir: __dirname,
actions: {
// Auth/registration
'POST /auth/login': { action: 'auth/login', ratelimit: '???' },
'POST /auth/sign-up': { action: 'auth/sign-up', ratelimit: '???' },
'POST /auth/recovery': { action: 'auth/recovery', ratelimit: '???' },
'POST /auth/confirm-recovery': { action: 'auth/confirm-recovery', ratelimit: '???' },
'POST /auth/complete-recovery': { action: 'auth/complete-recovery', ratelimit: '???' },
'POST /auth/verify-email': { action: 'auth/verify-email', ratelimit: '???' },
'POST /auth/resend-verification': { action: 'auth/resend-verification', ratelimit: '???' },
'GET /ahoy.js': { action: 'get-ahoyjs', cache: 30 },
'GET /apidoc': {
action: 'apidoc',
cache: 30,
permission: 'admin'
},
// Admin Routes
'GET /admin/users': {
action: 'admin/list-users',
permission: 'admin'
},
'GET /ahoy.js': { action: 'get-ahoyjs', cache: 30 }
},
helpers: {
......@@ -72,8 +50,7 @@ module.exports = new Pluggable({
skipAssets: true,
fn: async function (req, res, next) {
res.set('X-Powered-By', 'Ahoy!')
// Add `req.me` to the request if available (JWT/Session cookie(not really session cookie though...))
loadUserMiddleware(req, res, next)
next()
}
}
},
......
/* global sails */
module.exports = function (req, res, next) {
const logLevel = 'debug'
sails.log[logLevel]('Checking permission for ' + req.method + ' ' + req.route.path)
let routeConfig = req.options // sails.config.routes[ req.method + ' ' + req.route.path ]
if (!routeConfig) { return next() }
let permission = routeConfig.permission
if ((routeConfig.auth || permission) && !req.me) {
sails.log[logLevel](' ❌ User is not authenticated - bailing on request')
res.set('X-Token-Update', '')
return res.unauthorized()
}
if (!permission) {
sails.log[logLevel](' ✓ No special permissions required for route')
return next()
}
sails.log[logLevel](' - Permission is required: ', permission)
// TODO: This should be handled a bit more betterly.
// So the user model has permissions[] and you could have 'blog' and something something other permission
if (permission !== 'admin') { permission = 'admin' }
const isType = `is${permission.charAt(0).toUpperCase()}${permission.substr(1)}`
if (req.me[isType]) {
// if (req.me && User.hasPermission(req.me, User.PERMISSIONS[permission])) {
sails.log[logLevel](' ✓ User has required permission ' + permission)
return next()
}
sails.log[logLevel](' ❌ Permission does not exist for this user - bailing on request')
return res.forbidden(`You don't have permission to access this resource`)
}
/* global sails, User */
const jwt = require('machinepack-jwt-ng')
module.exports = async function (req, res, next) {
const logLevel = sails.hooks.ahoy.logLevel || 'info'
sails.log[logLevel](`Running ahoy user middleware`)
let token
try {
token = await jwt.fromRequest(req.headers, {
algorithm: sails.config.custom.jwt.algorithm || 'HS256',
issuer: null,
acceptedKeys: sails.config.custom.jwt.acceptedKeys
})
} catch (err) {
// console.log('Unable to load JWT from request headers', err.stack)
}
if (token && token.data && token.data.user && token.data.user.id) {
try {
let user = await User.findOne({ id: token.data.user.id })
req.me = user
} catch (err) {
sails.log.error('Ahoy user middleware: Unable to load user from JWT token', err.stack)
}
}
next()
}
/* global _ */
/**
* User.js
*
* A user who can log in to this application.
*/
const bcrypt = require('bcrypt')
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
username: {
type: 'string',
columnType: 'citext',
required: true,
maxLength: 20,
description: 'A users username',
example: 'twiggy'
},
emailAddress: {
type: 'string',
columnType: 'citext',
columnName: 'email',
required: true,
unique: true,
isEmail: true,
maxLength: 200,
example: 'carol.reyna@microsoft.com'
},
password: {
type: 'string',
required: true,
protect: true,
description: 'Securely hashed representation of the user\'s login password.',
example: '2$28a8eabna301089103-13948134nad'
},
isAdmin: {
columnName: 'is_admin',
type: 'boolean',
description: 'Whether this user is a "super admin" with extra permissions, etc.',
defaultsTo: false
},
emailVerified: {
columnName: 'email_verified',
type: 'boolean',
description: 'Whether a user has verified ownership of their email address.',
defaultsTo: false
},
emailVerificationToken: {
columnName: 'email_verification_token',
type: 'string',
allowNull: true,
description: 'A unique token used to verify the user\'s email address'
},
emailVerificationTokenExpiresAt: {
columnName: 'email_verification_token_expires',
type: 'ref',
columnType: 'timestamptz default now()'
},
permissions: {
type: 'ref',
columnType: 'text[]',
defaultsTo: []
},
lastSeen: {
type: 'ref',
columnType: 'timestamptz',
defaultsTo: null,
description: 'A timestamp representing the moment at which this user most recently interacted with the backend while logged in (or null if they have not interacted with the backend at all yet).'
}
/*
passwordResetToken: {
type: 'string',
description: 'A unique token used to verify the user\'s identity when recovering a password. Expires after 1 use, or after a set amount of time has elapsed.'
},
passwordResetTokenExpiresAt: {
type: 'number',
description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `passwordResetToken` will expire (or 0 if the user currently has no such token).',
example: 1502844074211
},
stripeCustomerId: {
type: 'string',
protect: true,
description: 'The id of the customer entry in Stripe associated with this user (or empty string if this user is not linked to a Stripe customer -- e.g. if billing features are not enabled).',
extendedDescription:
`Just because this value is set doesn't necessarily mean that this user has a billing card.
It just means they have a customer entry in Stripe, which might or might not have a billing card.`
},
hasBillingCard: {
type: 'boolean',
description: 'Whether this user has a default billing card hooked up as their payment method.',
extendedDescription:
`More specifically, this indcates whether this user record's linked customer entry in Stripe has
a default payment source (i.e. credit card). Note that a user have a \`stripeCustomerId\`
without necessarily having a billing card.`
},