Skip to content

Commit

Permalink
Initial version of verify account email on account creation.
Browse files Browse the repository at this point in the history
Add configuration parameters for optionally enabling account email validation step.
    Default 'false'.
    Configuration parameters for Nodemailer SMTP out-bound permissions.
Add 'validated' property to account.
    Limit creation of access tokens to validated accounts.
Add 'verifyEmail' Request type.
Add /api/v1/account/verify/email to accept GET request for validation.
Add /static/verificationEmail.html as template for sent email.
    Replacement parameters in template for metaverse name
  • Loading branch information
Misterblue committed Apr 16, 2021
1 parent 16b965b commit 418811f
Show file tree
Hide file tree
Showing 10 changed files with 235 additions and 9 deletions.
18 changes: 15 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -51,6 +51,7 @@
"mongodb": "^3.6.3",
"morgan": "~1.9.1",
"multer": "^1.4.2",
"nodemailer": "^6.5.0",
"unique-names-generator": "^4.3.1",
"uuid": "^8.3.2",
"winston": "^3.3.3"
Expand All @@ -65,6 +66,7 @@
"@types/morgan": "^1.9.2",
"@types/multer": "^1.4.5",
"@types/node": "^14.14.22",
"@types/nodemailer": "^6.4.1",
"@types/uuid": "^8.3.0",
"npm-run-all": "^4.1.5",
"tslint": "^6.1.3",
Expand Down
42 changes: 38 additions & 4 deletions src/Entities/Accounts.ts
Expand Up @@ -33,9 +33,10 @@ import { ValidateResponse } from '@Route-Tools/EntityFieldDefn';
import { getEntityField, setEntityField, getEntityUpdateForField } from '@Route-Tools/GetterSetter';

import { createObject, getObject, getObjects, updateObjectFields, deleteOne, noCaseCollation, countObjects } from '@Tools/Db';
import { GenUUID, genRandomString, IsNullOrEmpty, IsNotNullOrEmpty } from '@Tools/Misc';
import { GenUUID, genRandomString, IsNullOrEmpty, IsNotNullOrEmpty, SendVerificationEmail } from '@Tools/Misc';
import { VKeyedCollection, SArray } from '@Tools/vTypes';
import { Logger } from '@Tools/Logging';
import { Requests } from './Requests';

export let accountCollection = 'accounts';

Expand Down Expand Up @@ -171,15 +172,48 @@ export const Accounts = {
newAcct.friends = []
newAcct.connections = []
newAcct.whenCreated = new Date();
if (Config['metaverse-server']['enable-account-email-verification']) {
newAcct.accountEmailVerified = false;
};

// Remember the password
Accounts.storePassword(newAcct, pPassword);

return newAcct;
},
// When an account is created, do what is necessary to enable the account.
// This might include verifying the email address, etc.
enableAccount(pAccount: AccountEntity): void {
if (Config['metaverse-server']['enable-account-email-verification']) {
pAccount.accountEmailVerified = false;
const verifyCode = GenUUID();
// Create a pending request and wait for the user to check back
const request = Requests.createEmailVerificationRequest(pAccount.id, verifyCode);
void Requests.add(request);
void SendVerificationEmail(pAccount, verifyCode);
}
else {
// If not doing email verification, just turn on the account
Accounts.doEnableAccount(pAccount);
};
},
// The 'enableAccount' function can start a process of account enablement.
// This function is called to actually turn on the account
doEnableAccount(pAccount: AccountEntity): void {
if (Config['metaverse-server']['enable-account-email-verification']) {
pAccount.accountEmailVerified = true;
const updates: VKeyedCollection = {
'accountEmailVerified': true
};
void Accounts.updateEntityFields(pAccount, updates);
}
else {
// If accounts are not verified by email, this value does not exist on the
// account and thus enablement is assumed to be 'true'.
delete pAccount.accountEmailVerified;
const updates: VKeyedCollection = {
'accountEmailVerified': null
};
void Accounts.updateEntityFields(pAccount, updates);
};
},
// TODO: add scope (admin) and filter criteria filtering
// It's push down to this routine so we could possibly use DB magic for the queries
async *enumerateAsync(pPager: CriteriaFilter,
Expand Down
4 changes: 4 additions & 0 deletions src/Entities/RequestEntity.ts
Expand Up @@ -35,5 +35,9 @@ export class RequestEntity {
public targetNodeId: string;
public requesterAccepted: boolean;
public targetAccepted: boolean;

// requestType == VERIFYEMAIL
// 'requestingAccountId' is the account being verified
public verificationCode: string; // the code we're waiting for
};

12 changes: 11 additions & 1 deletion src/Entities/Requests.ts
Expand Up @@ -94,20 +94,30 @@ export const Requests = {
return aRequest;
},
// A 'handshake' request is special request between session NodeIds
// Note, this does not add the request to the database. Caller needs a "Request.add(...)".
createHandshakeRequest(pRequesterNodeId: string, pTargetNodeId: string): RequestEntity {
const newRequest = Requests.create();
newRequest.requestType = RequestType.HANDSHAKE;
newRequest.requesterNodeId = pRequesterNodeId;
newRequest.requesterAccepted = false;
newRequest.targetNodeId = pTargetNodeId;
newRequest.targetAccepted = false;

// A connection request lasts only for so long
const expirationMinutes = Config["metaverse-server"]["handshake-request-expiration-minutes"];
newRequest.expirationTime = new Date(Date.now() + 1000 * 60 * expirationMinutes);

return newRequest;
},
// Note, this does not add the request to the database. Caller needs a "Request.add(...)".
createEmailVerificationRequest(pAccountId: string, pVerificationCode: string): RequestEntity {
const newRequest = Requests.create();
newRequest.requestType = RequestType.VERIFYEMAIL;
newRequest.requestingAccountId = pAccountId;
newRequest.verificationCode = pVerificationCode;
const expirationMinutes = Config["metaverse-server"]['email-verification-timeout-minutes'];
newRequest.expirationTime = new Date(Date.now() + 1000 * 60 * expirationMinutes);
return newRequest;
},
add(pRequestEntity: RequestEntity) : Promise<RequestEntity> {
return createObject(requestCollection, pRequestEntity);
},
Expand Down
44 changes: 44 additions & 0 deletions src/Tools/Misc.ts
Expand Up @@ -16,11 +16,18 @@
import http from 'http';
import https from 'https';
import os from 'os';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';

import fsPromises from 'fs/promises';

import { createTransport } from 'nodemailer';

import { Logger } from '@Tools/Logging';
import { VKeyedCollection } from '@Tools/vTypes';
import { Config } from '@Base/config';
import { AccountEntity } from '@Entities/AccountEntity';

// Clamp the passed value between a high and low
export function Clamp(pVal: number, pLow: number, pHigh: number): number {
Expand Down Expand Up @@ -144,3 +151,40 @@ export async function httpsRequest(pUrl: string): Promise<string> {
});
});
};

export async function SendVerificationEmail(pAccount: AccountEntity, pVerifyCode: string): Promise<void> {
try {
const verificationURL = Config.metaverse['metaverse-server-url']
+ `/api/v1/account/verify/email?a=${pAccount.id}&v=${pVerifyCode}`;
const metaverseName = Config.metaverse['metaverse-name'];
const shortMetaverseName = Config.metaverse['metaverse-nick-name'];

const verificationFile = path.join(__dirname, '../..', Config['metaverse-server']['email-verification-email-body']);
Logger.debug(`SendVerificationEmail: using verificationFile from ${verificationFile}`);
let emailBody = await fsPromises.readFile(verificationFile, 'utf-8');
emailBody = emailBody.replace('VERIFICATION_URL', verificationURL)
.replace('METAVERSE_NAME', metaverseName)
.replace('SHORT_METAVERSE_NAME', shortMetaverseName);

Logger.debug(`SendVerificationEmail: SMTPhost=${Config['nodemailer-transport-config'].host}`);
const transporter = createTransport(Config['nodemailer-transport-config']);
if (transporter) {
Logger.debug(`SendVerificationEmail: sending email verification for new account ${pAccount.id}/${pAccount.username}`);
const msg = {
from: Config['metaverse-server']['email-verification-from'],
to: pAccount.email,
subject: `${shortMetaverseName} account verification`,
html: emailBody
};
transporter.sendMail(msg);
transporter.close();
}
else {
Logger.error(`SendVerificationEmail: failed to recreate transporter`);
};
}
catch (e) {
Logger.error(`SendVerificationEmail: exception sending verification email. Acct=${pAccount.id}/${pAccount.username}. e=${e}`);
}
return;
}
19 changes: 19 additions & 0 deletions src/config.ts
Expand Up @@ -65,8 +65,27 @@ export let Config = {
'fix-domain-network-address': true,
// Whether allowing temp domain name creation
'allow-temp-domain-creation': false,

// Email verification on account creation
'enable-account-email-verification': false,
'email-verification-timeout-minutes': 60, // minutes to wait for email verification
// default is in 'static' dir. If you put in 'config' dir, use 'config/verificationEmail.html'.
'email-verification-email-body': 'dist/static/verificationEmail.html', // file to send
'email-verification-from': '', // who the email is From
},
// SMTP mail parameters for out-bound email
// This is the structure that is passed to NodeMailer's SMTP transport.
// Check out the documentation at https://nodemailer.com/smtp/
// For SMTP outbound, setup your email account on your service and
// update SMTP-HOSTNAME, SMTP-USER, and SMTP-PASSWORD with your info.
'nodemailer-transport-config': {
'host': 'SMTP-HOSTNAME',
'port': 465, // 587 if secure=false
'secure': true,
'auth': {
'user': 'SMTP-USER',
'pass': 'SMTP-PASSWORD'
}
},
'monitoring': {
'enable': true, // enable value monitoring
Expand Down
76 changes: 76 additions & 0 deletions src/routes/api/v1/account/verify/email.ts
@@ -0,0 +1,76 @@
// Copyright 2020 Vircadia Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

'use strict'

import { Router, RequestHandler, Request, Response, NextFunction } from 'express';
import { setupMetaverseAPI, finishMetaverseAPI } from '@Route-Tools/middleware';
import { Requests, RequestType } from '@Entities/Requests';

import { Accounts } from '@Entities/Accounts';

import { logger, Logger } from '@Tools/Logging';
import { IsNotNullOrEmpty } from '@Tools/Misc';

// metaverseServerApp.use(express.urlencoded({ extended: false }));

// The verify account request takes two query parameters:
// /api/v1/account/verify?a=ACCOUNT_ID&v=VERIFICATION_CODE
const procGetVerifyAccount: RequestHandler = async (req: Request, resp: Response, next: NextFunction) => {
logger.debug(`procGetVerifyAccount: `);
if (typeof(req.query.a) === 'string' && IsNotNullOrEmpty(req.query.a)
&& typeof(req.query.v) === 'string' && IsNotNullOrEmpty(req.query.v)) {
const accountId = req.query.a;
const verifyCode = req.query.v;
logger.debug(`procGetVerifyAccount: acctId=${accountId}, code=${verifyCode}`);
const request = await Requests.getWithRequesterOrTarget(accountId, RequestType.VERIFYEMAIL)
if (request) {
if (request.verificationCode === verifyCode) {
// A matching verification request
const aAccount = await Accounts.getAccountWithId(accountId);
if (aAccount) {
Accounts.doEnableAccount(aAccount);
Logger.info(`procGetVerifyAccount: account ${accountId} verified and enabled`);
}
else {
// Odd that we can't find the account. The pending verification request is no more.
req.vRestResp.respondFailure('Not verified. Missing account');
Logger.error(`procGetVerifyAccount: account ${accountId} could not be found for verification`);
};
// The pending verification request has been used
await Requests.remove(request);
}
else {
req.vRestResp.respondFailure('Not verified');
Logger.error(`procGetVerifyAccount: verification code did not match for ${accountId}`);
};
}
else {
req.vRestResp.respondFailure('Not verified. No pending verification request');
Logger.error(`procGetVerifyAccount: attempt to verify account ${accountId} but no pending verification request`);
};
}
else {
req.vRestResp.respondFailure('Verification parameters not present');
};
next();
};

export const name = '/api/v1/account/verify/email';

export const router = Router();

router.get( '/api/v1/account/verify/email', [ setupMetaverseAPI, // req.vRestResp, req.vAuthToken
procGetVerifyAccount,
finishMetaverseAPI ] );
9 changes: 8 additions & 1 deletion src/routes/api/v1/users.ts
Expand Up @@ -90,14 +90,21 @@ const procPostUsers: RequestHandler = async (req: Request, resp: Response, next:
const newAcct = await Accounts.createAccount(userName, userPassword, userEmail);
if (newAcct) {
try {
// A special kludge to create the initial admin account
// If we're creating an account that has the name of the admin account
// then add the 'admin' role to it.
const adminAccountName = Config["metaverse-server"]["base-admin-account"] ?? 'wilma';
// If we're creating the admin account, assign it admin privilages
if (newAcct.username === adminAccountName) {
if (IsNullOrEmpty(newAcct.roles)) newAcct.roles = [];
SArray.add(newAcct.roles, Roles.ADMIN);
Logger.info(`procPostUsers: setting new account ${adminAccountName} as admin`);
}
// Remember the address of the creator
newAcct.IPAddrOfCreator = req.vSenderKey;
// Enable the account (email verification, etc)
Accounts.enableAccount(newAcct);
// Put it into the database
// Note that this puts everything that's in 'newAcct' into the DB
await Accounts.addAccount(newAcct);
}
catch (err) {
Expand Down
18 changes: 18 additions & 0 deletions src/static/verificationEmail.html
@@ -0,0 +1,18 @@
<div>
<p>
You have created an account in the METAVERSE_NAME metaverse.
</p>

<p>
Please verify the account email by following this link:
<div style="text-align: center">
<a href="VERIFICATION_URL"><b>VERIFY EMAIL</b></a>
</div>
</p>
<p>
See you in the virtual world!
</p>
<p>
-- SHORT_METAVERSE_NAME admin
</p>
</div>

0 comments on commit 418811f

Please sign in to comment.