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:
Stefan Zermatten
2022-02-10 19:02:18 +02:00
parent 3948d20f46
commit 359f18988c
27 changed files with 852 additions and 11 deletions

View File

@@ -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';

View File

@@ -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( '#/', '' ),
}),
};

View 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">
&#160;
</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">
&#160;
</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">
&#160;
</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">
&#160;
</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">
&#160;
</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">
&#160;
</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">
&#160;
</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>
`
}

View 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
}
});
};

View File

@@ -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

View 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'
});

View File

@@ -0,0 +1 @@
import './creature.js';

View File

@@ -0,0 +1,2 @@
import './restLogin.js';
import './apiPublications/index.js';

View File

@@ -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;

View 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,
},
});
});