Account functionality extended, API authentication implemented
- Can now add a second email address to your account and delete one of your email addresses - Reset password now works - Resetting the password of an account without a password set will set one - Email templates overhauled - Login tokens limited to close previously devastating ($800 database bill) security hole - Login with REST API now works - Once logged in, authentication of API calls with token works - Creatures can now be fetched using the API
This commit is contained in:
@@ -45,3 +45,5 @@ akryum:vue-router2
|
||||
percolate:migrations
|
||||
meteortesting:mocha
|
||||
ostrio:files
|
||||
simple:rest-bearer-token-parser
|
||||
simple:rest-json-error-handler
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}});
|
||||
}
|
||||
|
||||
@@ -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?');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(',') || [];
|
||||
|
||||
|
||||
34
app/imports/api/users/methods/addEmail.js
Normal file
34
app/imports/api/users/methods/addEmail.js
Normal file
@@ -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;
|
||||
@@ -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();
|
||||
37
app/imports/api/users/methods/removeEmail.js
Normal file
37
app/imports/api/users/methods/removeEmail.js
Normal file
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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( '#/', '' ),
|
||||
}),
|
||||
};
|
||||
|
||||
167
app/imports/server/config/emailTemplate.js
Normal file
167
app/imports/server/config/emailTemplate.js
Normal file
@@ -0,0 +1,167 @@
|
||||
export default function emailTemplate({heading, text, buttonText, url}){
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<!-- Compiled with Bootstrap Email version: 1.1.2 --><meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<style type="text/css">
|
||||
body,table,td{font-family:Helvetica,Arial,sans-serif !important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body a,#MessageViewBody a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-]) td{border-spacing:0px;border-collapse:collapse}@media screen and (max-width: 600px){.w-full,.w-full>tbody>tr>td{width:100% !important}*[class*=s-lg-]>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-2>tbody>tr>td{font-size:8px !important;line-height:8px !important;height:8px !important}.s-5>tbody>tr>td{font-size:20px !important;line-height:20px !important;height:20px !important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px !important;height:40px !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
|
||||
<table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc">
|
||||
<table class="container" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="line-height: 24px; font-size: 16px; margin: 0; padding: 0 16px;">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="600">
|
||||
<![endif]-->
|
||||
<table align="center" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 600px; margin: 0 auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
|
||||
<table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" align="left" width="100%" height="40">
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="card" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left" bgcolor="#ffffff">
|
||||
<table class="card-body" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 20px;" align="left">
|
||||
<img src="https://dicecloud.com/favicon-96x96.png" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;">
|
||||
<h1 class="h3" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 28px; line-height: 33.6px; margin: 0;" align="left">DiceCloud</h1>
|
||||
<table class="s-2 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 8px; font-size: 8px; width: 100%; height: 8px; margin: 0;" align="left" width="100%" height="8">
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h5 class="text-teal-700" style="color: #13795b; padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 20px; line-height: 24px; margin: 0;" align="left">${heading}</h5>
|
||||
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="space-y-3">
|
||||
<p class="text-gray-700" style="line-height: 24px; font-size: 16px; color: #4a5568; width: 100%; margin: 0;" align="left">
|
||||
${text}
|
||||
</p>
|
||||
</div>
|
||||
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="btn btn-primary" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 24px; font-size: 16px; border-radius: 6px; margin: 0;" align="center" bgcolor="#0d6efd">
|
||||
<a href="${url}" target="_blank" style="color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #0d6efd; padding: 8px 12px; border: 1px solid #0d6efd;">${buttonText}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" align="left" width="100%" height="40">
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
21
app/imports/server/config/limitLoginTokens.js
Normal file
21
app/imports/server/config/limitLoginTokens.js
Normal file
@@ -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
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
|
||||
46
app/imports/server/rest/apiPublications/creature.js
Normal file
46
app/imports/server/rest/apiPublications/creature.js
Normal file
@@ -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'
|
||||
});
|
||||
1
app/imports/server/rest/apiPublications/index.js
Normal file
1
app/imports/server/rest/apiPublications/index.js
Normal file
@@ -0,0 +1 @@
|
||||
import './creature.js';
|
||||
2
app/imports/server/rest/index.js
Normal file
2
app/imports/server/rest/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import './restLogin.js';
|
||||
import './apiPublications/index.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;
|
||||
85
app/imports/server/rest/restLogin.js
Normal file
85
app/imports/server/rest/restLogin.js
Normal file
@@ -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": "<your username>", "password": "<your password>"}
|
||||
* Alternative Body: {"email": "<your email>", "password": "<your password>"}
|
||||
* Successful response:
|
||||
* {
|
||||
* "id": "<your userId>",
|
||||
* "token": "<your user token, save this>",
|
||||
* "tokenExpires": "<date string of token expiry date>"
|
||||
* }
|
||||
*
|
||||
* 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 <token>" }
|
||||
* }, 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,
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -62,10 +62,60 @@
|
||||
v-for="email in emails"
|
||||
:key="email.address"
|
||||
>
|
||||
<v-list-item-action v-if="emails.length > 1">
|
||||
<v-btn
|
||||
icon
|
||||
small
|
||||
@click="removeEmail(email.address)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
<v-list-item-title>
|
||||
{{ email.address }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-expand-transition>
|
||||
<v-alert
|
||||
v-if="removeEmailError"
|
||||
type="error"
|
||||
>
|
||||
{{ removeEmailError }}
|
||||
</v-alert>
|
||||
</v-expand-transition>
|
||||
<v-slide-x-transition
|
||||
hide-on-leave
|
||||
>
|
||||
<v-text-field
|
||||
v-if="showEmailInput"
|
||||
v-model="inputEmail"
|
||||
label="Add Email Address"
|
||||
:error-messages="addEmailError"
|
||||
outlined
|
||||
>
|
||||
<v-btn
|
||||
slot="prepend"
|
||||
icon
|
||||
@click="clearEmailInput"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
slot="append"
|
||||
icon
|
||||
@click="addEmail"
|
||||
>
|
||||
<v-icon>mdi-send</v-icon>
|
||||
</v-btn>
|
||||
</v-text-field>
|
||||
<v-btn
|
||||
v-else-if="!emails || emails.length < 2"
|
||||
icon
|
||||
@click="showEmailInput = true"
|
||||
>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-slide-x-transition>
|
||||
<v-subheader>
|
||||
Patreon
|
||||
</v-subheader>
|
||||
@@ -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('/');
|
||||
|
||||
30
app/imports/ui/pages/EmailVerificationError.vue
Normal file
30
app/imports/ui/pages/EmailVerificationError.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template lang="html">
|
||||
<div>
|
||||
<v-layout
|
||||
column
|
||||
align-center
|
||||
justify-center
|
||||
>
|
||||
<h2 style="margin: 48px 28px 16px">
|
||||
Email Verification Error
|
||||
</h2>
|
||||
<h3>
|
||||
{{ error.reason || error.message || error }}
|
||||
</h3>
|
||||
</v-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
export default {
|
||||
props: {
|
||||
error: {
|
||||
type: [Object, String],
|
||||
default: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
</style>
|
||||
24
app/imports/ui/pages/EmailVerificationSuccess.vue
Normal file
24
app/imports/ui/pages/EmailVerificationSuccess.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template lang="html">
|
||||
<div>
|
||||
<v-layout
|
||||
column
|
||||
align-center
|
||||
justify-center
|
||||
>
|
||||
<h2 style="margin: 48px 28px 16px">
|
||||
Email Verified
|
||||
</h2>
|
||||
<h3>
|
||||
Your email address has been verified
|
||||
</h3>
|
||||
</v-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
</style>
|
||||
139
app/imports/ui/pages/ResetPassword.vue
Normal file
139
app/imports/ui/pages/ResetPassword.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-form
|
||||
ref="form"
|
||||
class="mt-4"
|
||||
>
|
||||
<v-layout
|
||||
column
|
||||
align-center
|
||||
>
|
||||
<v-img
|
||||
src="crown-dice-logo-cropped-transparent.png"
|
||||
width="120px"
|
||||
class="ma-3"
|
||||
/>
|
||||
|
||||
<template v-if="token">
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
type="password"
|
||||
label="New Password"
|
||||
:rules="passwordRules"
|
||||
class="ma-2"
|
||||
outlined
|
||||
required
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="password2"
|
||||
type="password"
|
||||
label="Password Again"
|
||||
:rules="password2Rules"
|
||||
class="ma-2"
|
||||
outlined
|
||||
required
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</template>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="email"
|
||||
type="text"
|
||||
label="Email"
|
||||
:rules="emailRules"
|
||||
class="ma-2"
|
||||
outlined
|
||||
required
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<v-expand-transition>
|
||||
<v-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
>
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
</v-expand-transition>
|
||||
<v-expand-transition>
|
||||
<v-alert
|
||||
v-if="info"
|
||||
type="info"
|
||||
>
|
||||
{{ info }}
|
||||
</v-alert>
|
||||
</v-expand-transition>
|
||||
<v-layout>
|
||||
<v-btn
|
||||
:disabled="!valid"
|
||||
color="accent"
|
||||
@click="submit"
|
||||
>
|
||||
Reset Password
|
||||
</v-btn>
|
||||
</v-layout>
|
||||
</v-layout>
|
||||
</v-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
valid: true,
|
||||
submitLoading: false,
|
||||
email: '',
|
||||
emailRules: [
|
||||
v => !!v || 'E-mail is required',
|
||||
v => /.+@.+/.test(v) || 'E-mail must be valid',
|
||||
],
|
||||
password: '',
|
||||
passwordRules: [
|
||||
v => !!v || 'Password is required',
|
||||
],
|
||||
password2: '',
|
||||
password2Rules: [
|
||||
v => !!v || 'Password is required',
|
||||
v => v == this.password || 'Passwords don\'t match',
|
||||
],
|
||||
error: '',
|
||||
info: '',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
token(){
|
||||
return this.$route.params.token;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
if (this.$refs.form.validate()) {
|
||||
if (this.token){
|
||||
this.submitLoading = true;
|
||||
Accounts.resetPassword(this.token, this.password, error => {
|
||||
this.submitLoading = false;
|
||||
this.error = error && error.message;
|
||||
this.info = '';
|
||||
if (!error){
|
||||
this.$router.push('/characterList');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.submitLoading = true;
|
||||
Accounts.forgotPassword({email: this.email}, error => {
|
||||
this.submitLoading = false;
|
||||
this.error = error && error.message;
|
||||
this.info = '';
|
||||
if (!error){
|
||||
this.info = `Password reset link sent to ${this.email}`;
|
||||
this.email = '';
|
||||
this.valid = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -33,7 +33,10 @@
|
||||
required
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<v-btn text>
|
||||
<v-btn
|
||||
text
|
||||
to="/reset-password"
|
||||
>
|
||||
Reset Password
|
||||
</v-btn>
|
||||
<div class="error--text">
|
||||
@@ -50,7 +53,7 @@
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="accent"
|
||||
:to="{ name: 'register', query: { redirect: this.$route.query.redirect} }"
|
||||
:to="{ name: 'register', query: { redirect: $route.query.redirect} }"
|
||||
class="ma-2"
|
||||
>
|
||||
Register
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<script lang="js">
|
||||
import TIERS, { getUserTier } from '/imports/api/users/patreon/tiers.js';
|
||||
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
|
||||
import linkWithPatreon from '/imports/api/users/linkWithPatreon.js'
|
||||
import linkWithPatreon from '/imports/api/users/methods/linkWithPatreon.js'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import '/imports/api/simpleSchemaConfig.js';
|
||||
import '/imports/server/config/accountsEmailConfig.js';
|
||||
import '/imports/server/config/SimpleRestConfig.js';
|
||||
import '/imports/server/config/limitLoginTokens.js';
|
||||
import '/imports/server/rest/index.js';
|
||||
import '/imports/server/config/accountsEmailConfig.js';
|
||||
import '/imports/server/config/simpleSchemaDebug.js';
|
||||
import '/imports/server/config/SyncedCronConfig.js';
|
||||
import '/imports/server/publications/index.js';
|
||||
|
||||
Reference in New Issue
Block a user