diff --git a/app/.meteor/packages b/app/.meteor/packages index 1df88a4f..8a53403c 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -45,3 +45,5 @@ akryum:vue-router2 percolate:migrations meteortesting:mocha ostrio:files +simple:rest-bearer-token-parser +simple:rest-json-error-handler diff --git a/app/.meteor/versions b/app/.meteor/versions index 00189406..e9557bec 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -108,6 +108,8 @@ sha@1.0.9 shell-server@0.5.0 simple:json-routes@2.3.1 simple:rest@1.2.1 +simple:rest-bearer-token-parser@1.1.1 +simple:rest-json-error-handler@1.1.1 simple:rest-method-mixin@1.1.0 socket-stream-client@0.4.0 spacebars-compiler@1.3.0 diff --git a/app/imports/api/engine/computation/writeComputation/writeScope.js b/app/imports/api/engine/computation/writeComputation/writeScope.js index 3cb0027b..3661df4a 100644 --- a/app/imports/api/engine/computation/writeComputation/writeScope.js +++ b/app/imports/api/engine/computation/writeComputation/writeScope.js @@ -1,5 +1,10 @@ import Creatures from '/imports/api/creature/creatures/Creatures.js'; export default function writeScope(creatureId, scope){ + // Remove large properties that aren't likely to be accessed + for (const key in scope){ + delete scope[key].parent; + delete scope[key].ancestors; + } Creatures.update(creatureId, {$set: {variables: scope}}); } diff --git a/app/imports/api/sharing/sharingPermissions.js b/app/imports/api/sharing/sharingPermissions.js index 16831209..6f48ed4b 100644 --- a/app/imports/api/sharing/sharingPermissions.js +++ b/app/imports/api/sharing/sharingPermissions.js @@ -4,7 +4,7 @@ import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js'; function assertIdValid(userId){ if (!userId || typeof userId !== 'string'){ throw new Meteor.Error('Permission denied', - 'No user ID given for edit permission check'); + 'No user ID. Are you logged in?'); } } diff --git a/app/imports/api/users/Users.js b/app/imports/api/users/Users.js index 268b8d99..be3fd3a8 100644 --- a/app/imports/api/users/Users.js +++ b/app/imports/api/users/Users.js @@ -1,7 +1,10 @@ import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import '/imports/api/users/deleteMyAccount.js'; +import '/imports/api/users/methods/deleteMyAccount.js'; +import '/imports/api/users/methods/addEmail.js'; +import '/imports/api/users/methods/removeEmail.js'; + import { some } from 'lodash'; const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || []; diff --git a/app/imports/api/users/methods/addEmail.js b/app/imports/api/users/methods/addEmail.js new file mode 100644 index 00000000..af8e81f6 --- /dev/null +++ b/app/imports/api/users/methods/addEmail.js @@ -0,0 +1,34 @@ +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; + +const addEmail = new ValidatedMethod({ + name: 'users.addEmail', + validate: new SimpleSchema({ + email: { + type: String, + regEx: SimpleSchema.RegEx.Email, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 1, + timeInterval: 5000, + }, + run({email}){ + const userId = Meteor.userId(); + const user = Meteor.users.findOne(userId); + if (!user) throw new Meteor.Error('No user', + 'You must be logged in to add an email address'); + if (user.emails && user.emails.length >= 2){ + throw new Meteor.Error('Emails full', + 'You may only have up to 2 email addresses per account'); + } + if (Meteor.isServer){ + Accounts.addEmail(userId, email); + Accounts.sendVerificationEmail(userId, email); + } + } +}); + +export default addEmail; diff --git a/app/imports/api/users/deleteMyAccount.js b/app/imports/api/users/methods/deleteMyAccount.js similarity index 96% rename from app/imports/api/users/deleteMyAccount.js rename to app/imports/api/users/methods/deleteMyAccount.js index d887b8c0..f6947bfc 100644 --- a/app/imports/api/users/deleteMyAccount.js +++ b/app/imports/api/users/methods/deleteMyAccount.js @@ -15,7 +15,7 @@ Meteor.users.deleteMyAccount = new ValidatedMethod({ run(){ let userId = Meteor.userId(); if (!userId) throw new Meteor.Error('No user', - 'You must be logged into to delete your account'); + 'You must be logged in to delete your account'); // Delete all creatures let creatures = Creatures.find({owner: userId}, {fields: {_id: 1}}).fetch(); diff --git a/app/imports/api/users/linkWithPatreon.js b/app/imports/api/users/methods/linkWithPatreon.js similarity index 100% rename from app/imports/api/users/linkWithPatreon.js rename to app/imports/api/users/methods/linkWithPatreon.js diff --git a/app/imports/api/users/methods/removeEmail.js b/app/imports/api/users/methods/removeEmail.js new file mode 100644 index 00000000..86ffb822 --- /dev/null +++ b/app/imports/api/users/methods/removeEmail.js @@ -0,0 +1,37 @@ +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; + +const removeEmail = new ValidatedMethod({ + name: 'users.removeEmail', + validate: new SimpleSchema({ + email: { + type: String, + regEx: SimpleSchema.RegEx.Email, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 1, + timeInterval: 5000, + }, + run({email}){ + const userId = Meteor.userId(); + const user = Meteor.users.findOne(userId); + if (!user) throw new Meteor.Error('No user', + 'You must be logged in to remove an email address'); + if (!user.emails){ + throw new Meteor.Error('No email to remove', + 'No email addresses are associated with this account'); + } + if (user.emails.length == 1){ + throw new Meteor.Error('Can\'t remove last email', + 'You may not remove the last email address from your account'); + } + if (Meteor.isServer){ + Accounts.removeEmail(userId, email); + } + } +}); + +export default removeEmail; diff --git a/app/imports/server/config/SimpleRestConfig.js b/app/imports/server/config/SimpleRestConfig.js index c633c6a2..72f63b40 100644 --- a/app/imports/server/config/SimpleRestConfig.js +++ b/app/imports/server/config/SimpleRestConfig.js @@ -1,8 +1,7 @@ -import { JsonRoutes } from 'meteor/simple:json-routes'; +import { JsonRoutes, RestMiddleware } from 'meteor/simple:json-routes'; import { SimpleRest } from 'meteor/simple:rest'; Meteor.startup(() => { - // // Enable cross origin requests for all endpoints JsonRoutes.setResponseHeaders({ 'Cache-Control': 'no-store', @@ -17,3 +16,18 @@ SimpleRest.configure({ // No default collection methods get end points collections: [], }); + +// All errors are handled as JSON +JsonRoutes.ErrorMiddleware.use(RestMiddleware.handleErrorAsJson); + +// Hack to stop simple:rest adding routes automatically unless their URL +// has been explicitly set to 'api/...' +const oldAdd = JsonRoutes.add; +JsonRoutes.add = function(method, path, handler){ + if (path.substring(0,4) !== 'api/'){ + return; + } + oldAdd(method, path, handler); +} + +import '/imports/server/rest/restLogin.js'; diff --git a/app/imports/server/config/accountsEmailConfig.js b/app/imports/server/config/accountsEmailConfig.js index b1526d82..e6853515 100644 --- a/app/imports/server/config/accountsEmailConfig.js +++ b/app/imports/server/config/accountsEmailConfig.js @@ -1,4 +1,35 @@ import { Accounts } from 'meteor/accounts-base' +import emailTemplate from './emailTemplate.js'; Accounts.emailTemplates.from = 'no-reply@dicecloud.com'; Accounts.emailTemplates.siteName = 'DiceCloud'; + +Accounts.emailTemplates.enrollAccount = { + subject: () => 'DiceCloud Invite', + html: (user, url) => emailTemplate({ + heading: 'DiceCloud Invite', + text: 'You have been invited to DiceCloud, click the button below to begin.', + buttonText: 'Get Started', + url: url.replace( '#/', '' ), + }), +}; + +Accounts.emailTemplates.resetPassword = { + subject: () => 'DiceCloud Password Reset', + html: (user, url) => emailTemplate({ + heading: 'Password Reset', + text: 'If you did not request this password reset, please ignore this email.', + buttonText: 'Reset Password', + url: url.replace( '#/', '' ), + }), +}; + +Accounts.emailTemplates.verifyEmail = { + subject: () => 'DiceCloud Email Verification', + html: (user, url) => emailTemplate({ + heading: 'DiceCloud Email Verification', + text: 'Click below to verify your email address', + buttonText: 'Verify Email', + url: url.replace( '#/', '' ), + }), +}; diff --git a/app/imports/server/config/emailTemplate.js b/app/imports/server/config/emailTemplate.js new file mode 100644 index 00000000..80f6d6a4 --- /dev/null +++ b/app/imports/server/config/emailTemplate.js @@ -0,0 +1,167 @@ +export default function emailTemplate({heading, text, buttonText, url}){ + return ` + + + + + + + + + + + + + + + + + + + + +` +} diff --git a/app/imports/server/config/limitLoginTokens.js b/app/imports/server/config/limitLoginTokens.js new file mode 100644 index 00000000..594cb14c --- /dev/null +++ b/app/imports/server/config/limitLoginTokens.js @@ -0,0 +1,21 @@ +const MAX_LOGIN_TOKENS = 20; + +Accounts._insertHashedLoginToken = function(userId, hashedToken, query) { + query = query ? { ...query } : {}; + query._id = userId; + const user = Accounts.users.findOne(query); + let loginTokenLength = user?.services?.resume?.loginTokens?.length; + while (loginTokenLength >= MAX_LOGIN_TOKENS){ + loginTokenLength -=1; + Accounts.users.update(query, { + $pop: { + 'services.resume.loginTokens': -1 + } + }); + } + Accounts.users.update(query, { + $addToSet: { + 'services.resume.loginTokens': hashedToken + } + }); +}; diff --git a/app/imports/server/publications/singleCharacter.js b/app/imports/server/publications/singleCharacter.js index 69fff4f7..611f1d23 100644 --- a/app/imports/server/publications/singleCharacter.js +++ b/app/imports/server/publications/singleCharacter.js @@ -14,7 +14,11 @@ let schema = new SimpleSchema({ }); Meteor.publish('singleCharacter', function(creatureId){ - schema.validate({ creatureId }); + try { + schema.validate({ creatureId }); + } catch (e){ + this.error(e); + } this.autorun(function (computation){ let userId = this.userId; let creatureCursor diff --git a/app/imports/server/rest/apiPublications/creature.js b/app/imports/server/rest/apiPublications/creature.js new file mode 100644 index 00000000..f805c92b --- /dev/null +++ b/app/imports/server/rest/apiPublications/creature.js @@ -0,0 +1,46 @@ +import SimpleSchema from 'simpl-schema'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import { assertViewPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; +import computeCreature from '/imports/api/engine/computeCreature.js'; +import VERSION from '/imports/constants/VERSION.js'; + +Meteor.publish('api-creature', function(creatureId){ + try { + new SimpleSchema({ + creatureId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validate({ creatureId }); + } catch (e){ + this.error(e); + return; + } + const userId = this.userId; + const creatureCursor = Creatures.find({ + _id: creatureId, + }); + const creature = creatureCursor.fetch()[0]; + try { + assertViewPermission(creature, userId) + } catch(e){ + this.error(e); + return; + } + if (creature.computeVersion !== VERSION){ + try { + computeCreature(creatureId) + } catch(e){ + console.error(e) + } + } + return [ + creatureCursor, + CreatureProperties.find({ + 'ancestors.id': creatureId, + }), + ]; +}, { + url: 'api/creature/:0' +}); diff --git a/app/imports/server/rest/apiPublications/index.js b/app/imports/server/rest/apiPublications/index.js new file mode 100644 index 00000000..765d7988 --- /dev/null +++ b/app/imports/server/rest/apiPublications/index.js @@ -0,0 +1 @@ +import './creature.js'; diff --git a/app/imports/server/rest/index.js b/app/imports/server/rest/index.js new file mode 100644 index 00000000..b552e02a --- /dev/null +++ b/app/imports/server/rest/index.js @@ -0,0 +1,2 @@ +import './restLogin.js'; +import './apiPublications/index.js'; diff --git a/app/imports/server/rest/middleware/authenticateUserByToken.js b/app/imports/server/rest/middleware/authenticateUserByToken.js new file mode 100644 index 00000000..576f5430 --- /dev/null +++ b/app/imports/server/rest/middleware/authenticateUserByToken.js @@ -0,0 +1,57 @@ +var Fiber = Npm.require('fibers'); +import { RestMiddleware } from 'meteor/simple:json-routes'; + +/** + * SimpleRest middleware for validating a Meteor.user's login token + * + * This middleware must be processed after the request.token has been set to a + * valid login token for a Meteor.user account (from a separate layer of + * middleware). If authentication is successful, the request.userId will be set + * to the ID of the authenticated user. An invalid token will result in a error. + * + * @middleware + */ +const authenticateMeteorUserByToken = + function (req, res, next) { + Fiber(function () { + let userId; + try { + userId = getUserIdFromAuthToken(req.authToken); + } catch (e){ + RestMiddleware.handleErrorAsJson(e, req, res, next); + return; + } + if (userId) { + req.userId = userId; + } + + next(); + }).run(); + }; + +/** + * Retrieves the ID of the Meteor.user that the given auth token belongs to + * + * @param token An unhashed auth token + * @returns {String} The ID of the authenticated Meteor.user, or null if token + * is invalid + */ +function getUserIdFromAuthToken(token) { + if (!token) { + return null; + } + + var user = Meteor.users.findOne({ + 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(token), + }); + if (user) { + return user._id; + } else { + const error = new Meteor.Error('Permission denied', + 'Invalid authentication token'); + error.statusCode = 403; + throw error; + } +} + +export default authenticateMeteorUserByToken; diff --git a/app/imports/server/rest/restLogin.js b/app/imports/server/rest/restLogin.js new file mode 100644 index 00000000..1099bc37 --- /dev/null +++ b/app/imports/server/rest/restLogin.js @@ -0,0 +1,85 @@ +import { JsonRoutes } from 'meteor/simple:json-routes'; +import authenticateMeteorUserByToken from './middleware/authenticateUserByToken.js'; +/** + * Login with username/email and password: + * POST /api/login + * Body: {"username": "", "password": ""} + * Alternative Body: {"email": "", "password": ""} + * Successful response: + * { + * "id": "", + * "token": "", + * "tokenExpires": "" + * } + * + * Warning: Your token may expire before the given date. + * Since each user has a limited pool of login tokens. If you get a permission + * error, you may need to login again to refresh your token + * + * Once you have your token, you can use it as a standard bearer token header + * in other API endpoints: + * HTTP.post("/methods/return-five-auth", { + * headers: { Authorization: "Bearer " } + * }, callback); +**/ + +JsonRoutes.Middleware.use(JsonRoutes.Middleware.parseBearerToken); +JsonRoutes.Middleware.use(authenticateMeteorUserByToken); + +JsonRoutes.add('options', 'api/login', function (req, res) { + JsonRoutes.sendResult(res); +}); + +JsonRoutes.add('post', 'api/login', function (req, res) { + var options = req.body; + + var user; + if (options.email) { + check(options, { + email: String, + password: String, + }); + user = Accounts.findUserByEmail(options.email); + } else { + check(options, { + username: String, + password: String, + }); + user = Accounts.findUserByUsername(options.username); + } + + if (!user) { + throw new Meteor.Error('not-found', + 'User with that username or email address not found.'); + } + + var result = Accounts._checkPassword(user, options.password); + check(result, { + userId: String, + error: Match.Optional(Meteor.Error), + }); + + if (result.error) { + throw result.error; + } + + var stampedLoginToken = Accounts._generateStampedLoginToken(); + check(stampedLoginToken, { + token: String, + when: Date, + }); + + Accounts._insertLoginToken(result.userId, stampedLoginToken); + + var tokenExpiration = Accounts._tokenExpiration(stampedLoginToken.when); + check(tokenExpiration, Date); + + JsonRoutes.sendResult(res, { + data: { + id: result.userId, + token: stampedLoginToken.token, + tokenExpires: tokenExpiration, + }, + }); + +}); diff --git a/app/imports/ui/pages/Account.vue b/app/imports/ui/pages/Account.vue index 122ae952..bbd25a06 100644 --- a/app/imports/ui/pages/Account.vue +++ b/app/imports/ui/pages/Account.vue @@ -62,10 +62,60 @@ v-for="email in emails" :key="email.address" > + + + mdi-delete + + {{ email.address }} + + + {{ removeEmailError }} + + + + + + mdi-close + + + mdi-send + + + + mdi-plus + + Patreon @@ -167,8 +217,10 @@ import router from '/imports/ui/router.js'; import getEntitledCents from '/imports/api/users/patreon/getEntitledCents.js'; import Invites from '/imports/api/users/Invites.js'; - import linkWithPatreon from '/imports/api/users/linkWithPatreon.js' + import linkWithPatreon from '/imports/api/users/methods/linkWithPatreon.js' import { getUserTier } from '/imports/api/users/patreon/tiers.js'; + import addEmail from '/imports/api/users/methods/addEmail.js'; + import removeEmail from '/imports/api/users/methods/removeEmail.js'; export default { meteor: { @@ -216,6 +268,14 @@ linkPatreonError: '', updatePatreonError: '', updatePatreonLoading: false, + // Add email + showEmailInput: false, + addEmailLoading: false, + inputEmail: '', + addEmailError: undefined, + // Remove email + removeEmailLoading: undefined, + removeEmailError: undefined, }}, computed: { entitledCents(){ @@ -233,6 +293,33 @@ elementId: 'username', }); }, + clearEmailInput(){ + this.showEmailInput = false; + this.addEmailError = undefined; + this.inputEmail = ''; + }, + addEmail(){ + this.addEmailLoading = true; + addEmail.call({email: this.inputEmail}, error => { + this.addEmailError = error && error.message; + this.addEmailLoading = false; + if (!error){ + this.showEmailInput = false; + this.inputEmail = ''; + } + }); + }, + removeEmail(address){ + this.removeEmailLoading = address; + removeEmail.call({email: address}, error => { + this.removeEmailError = error && error.message; + this.removeEmailLoading = undefined; + if (!error){ + this.showEmailInput = false; + this.inputEmail = ''; + } + }); + }, signOut(){ Meteor.logout(); router.push('/'); diff --git a/app/imports/ui/pages/EmailVerificationError.vue b/app/imports/ui/pages/EmailVerificationError.vue new file mode 100644 index 00000000..e01d0090 --- /dev/null +++ b/app/imports/ui/pages/EmailVerificationError.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/app/imports/ui/pages/EmailVerificationSuccess.vue b/app/imports/ui/pages/EmailVerificationSuccess.vue new file mode 100644 index 00000000..759e998f --- /dev/null +++ b/app/imports/ui/pages/EmailVerificationSuccess.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/app/imports/ui/pages/ResetPassword.vue b/app/imports/ui/pages/ResetPassword.vue new file mode 100644 index 00000000..f901e3a5 --- /dev/null +++ b/app/imports/ui/pages/ResetPassword.vue @@ -0,0 +1,139 @@ + + + diff --git a/app/imports/ui/pages/SignIn.vue b/app/imports/ui/pages/SignIn.vue index 5bb545cd..87f83356 100644 --- a/app/imports/ui/pages/SignIn.vue +++ b/app/imports/ui/pages/SignIn.vue @@ -33,7 +33,10 @@ required @keyup.enter="submit" /> - + Reset Password
@@ -50,7 +53,7 @@ Register diff --git a/app/imports/ui/router.js b/app/imports/ui/router.js index 347f3eb7..f8fe9442 100644 --- a/app/imports/ui/router.js +++ b/app/imports/ui/router.js @@ -19,6 +19,9 @@ const Feedback = () => import('/imports/ui/pages/Feedback.vue' ); const Account = () => import('/imports/ui/pages/Account.vue' ); const InviteSuccess = () => import('/imports/ui/pages/InviteSuccess.vue' ); const InviteError = () => import('/imports/ui/pages/InviteError.vue' ); +const EmailVerificationSuccess = () => import('/imports/ui/pages/EmailVerificationSuccess.vue' ); +const EmailVerificationError = () => import('/imports/ui/pages/EmailVerificationError.vue' ); +const ResetPassword = () => import('/imports/ui/pages/ResetPassword.vue' ); const NotImplemented = () => import('/imports/ui/pages/NotImplemented.vue'); const PatreonLevelTooLow = () => import('/imports/ui/pages/PatreonLevelTooLow.vue'); const Tabletops = () => import('/imports/ui/pages/Tabletops.vue'); @@ -94,6 +97,17 @@ function claimInvite(to, from, next){ }); } +function verifyEmail(to, from, next){ + const token = to.params.token; + Accounts.verifyEmail(token, error => { + if (error){ + next({name: 'emailVerificationError', params: {error}}); + } else { + next('/email-verification-success') + } + }); +} + RouterFactory.configure(router => { router.addRoutes([{ path: '/', @@ -213,6 +227,9 @@ RouterFactory.configure(router => { },{ path: '/invite/:inviteToken', beforeEnter: claimInvite, + },{ + path: '/verify-email/:token', + beforeEnter: verifyEmail, },{ name: 'inviteError', path: '/invite-error', @@ -233,6 +250,34 @@ RouterFactory.configure(router => { meta: { title: 'Invite Success', }, + },{ + name: 'emailVerificationError', + path: '/email-verification-error', + components: { + default: EmailVerificationError, + }, + props: { + default: true, + }, + meta: { + title: 'Email Verification Error', + }, + },{ + path: '/email-verification-success', + components: { + default: EmailVerificationSuccess, + }, + meta: { + title: 'Email Verification Success', + }, + },{ + path: '/reset-password/:token?', + components: { + default: ResetPassword, + }, + meta: { + title: 'Reset Password', + }, },{ path: '/patreon-level-too-low', components: { diff --git a/app/imports/ui/user/TierTooLowDialog.vue b/app/imports/ui/user/TierTooLowDialog.vue index d1831c81..bd6eeb9a 100644 --- a/app/imports/ui/user/TierTooLowDialog.vue +++ b/app/imports/ui/user/TierTooLowDialog.vue @@ -48,7 +48,7 @@