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:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user