Add e-mail verification.

This commit is contained in:
Thomas E Lackey 2023-09-13 14:29:05 -05:00
parent 32b2ed2df2
commit 0b10b11de1
12 changed files with 282 additions and 17 deletions

View File

@ -20,6 +20,7 @@
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
"simple-import-sort/exports": "error",
"@typescript-eslint/no-non-null-assertion": "off"
}
}

View File

@ -6,11 +6,15 @@
"dependencies": {
"@keycloak/keycloak-admin-client": "^22.0.1",
"debug": "^4.3.4",
"http-request-handler": "^2.0.1"
"http-request-handler": "^2.0.1",
"nodemailer": "^6.9.5",
"randomstring": "^1.3.0"
},
"devDependencies": {
"@types/debug": "^4.1.7",
"@types/node": "^18.7.6",
"@types/nodemailer": "^6.4.10",
"@types/randomstring": "^1.1.8",
"@types/request": "^2.48.8",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",

View File

@ -1,11 +1,20 @@
export const Config = {
LISTEN_PORT: parseInt(process.env.CERC_KCUSERREG_LISTEN_PORT || '9292'),
LISTEN_ADDR: parseInt(process.env.CERC_KCUSERREG_LISTEN_ADDR || '0.0.0.0'),
API_URL: process.env.CERC_KCUSERREG_API_URL || 'http://localhost:57198/auth',
API_URL: process.env.CERC_KCUSERREG_API_URL || 'http://localhost:8080/auth',
REG_USER: process.env.CERC_KCUSERREG_REG_USER || 'admin',
REG_PW: process.env.CERC_KCUSERREG_REG_PW || 'admin',
REG_CLIENT_ID: process.env.CERC_KCUSERREG_REG_CLIENT_ID || 'admin-cli',
SMTP_FROM: process.env.CERC_KCUSERREG_SMTP_FROM || 'verify@mail.laconic.com',
SMTP_HOST: process.env.CERC_KCUSERREG_SMTP_HOST || 'localhost',
SMTP_PORT: parseInt(process.env.CERC_KCUSERREG_SMTP_PORT || '25'),
SMTP_USER: process.env.CERC_KCUSERREG_SMTP_USER || '',
SMTP_PW: process.env.CERC_KCUSERREG_SMTP_PW || '',
TARGET_REALM: process.env.CERC_KCUSERREG_TARGET_REALM || 'cerc',
TARGET_GROUPS: process.env.CERC_KCUSERREG_TARGET_GROUPS?.split(',') || ['eth'],
CREATE_ENABLED: "true" === (process.env.CERC_KCUSERREG_CREATE_ENABLED || 'true')
CREATE_ENABLED: 'true' === (process.env.CERC_KCUSERREG_CREATE_ENABLED || 'false'),
VERIFY_EMAIL: 'true' === (process.env.CERC_KCUSERREG_VERIFY_EMAIL || 'true'),
VERIFY_EMAIL_SUBJECT: process.env.CERC_KCUSERREG_VERIFY_EMAIL_SUBJECT || 'Account Verification',
VERIFY_EMAIL_URL: process.env.CERC_KCUSERREG_VERIFY_EMAIL_URL || 'http://localhost:3000',
VERIFY_EMAIL_TKN_ATTR: 'x-verify-tkn'
};

View File

@ -5,7 +5,7 @@ import {Logger} from '../util/logger.js';
const log = new Logger('cerc:keycloak-userreg:mw:cors');
export const CorsMW: MiddlewareFunction = (request, response, data, resolve, reject) => {
response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Access-Control-Allow-Headers', '*')
resolve()
response.setHeader('Access-Control-Allow-Origin', '*');
response.setHeader('Access-Control-Allow-Headers', '*');
resolve();
};

View File

@ -3,6 +3,7 @@ import {MiddlewareFunction} from 'http-request-handler';
import {Config} from '../config.js';
import {getKeyCloakClient} from '../util/keycloak.js';
import {Logger} from '../util/logger.js';
import {requireVerification} from '../util/verify.js';
const log = new Logger('cerc:keycloak-userreg:mw:register');
@ -19,7 +20,7 @@ export const RegisterMW : MiddlewareFunction = async (request, response, data, r
email: data.json.email,
enabled: Config.CREATE_ENABLED,
groups: Config.TARGET_GROUPS,
realm: Config.TARGET_REALM
realm: Config.TARGET_REALM,
};
try {
@ -28,13 +29,21 @@ export const RegisterMW : MiddlewareFunction = async (request, response, data, r
log.debug('Creating user:', userRequest);
const result = await client.users.create(userRequest);
log.debug('Created user:', result.id);
if (Config.VERIFY_EMAIL) {
await requireVerification(result.id);
}
const user = await client.users.findOne({ realm: userRequest.realm, id: result.id});
log.debug(user);
response.send({
'username': user?.username,
'email': user?.email,
'api-key': (user?.attributes as any)['api-key']
});
const responseBody: any = {
'username': user!.username,
'email': user!.email,
};
if (!Config.VERIFY_EMAIL) {
responseBody['api-key'] = (user!.attributes as any)['api-key'];
}
response.send(responseBody);
resolve();
} catch (e) {
log.error('Error creating user', userRequest, e);

36
src/middleware/verify.ts Normal file
View File

@ -0,0 +1,36 @@
import {MiddlewareFunction} from 'http-request-handler';
import {Config} from '../config.js';
import {getKeyCloakClient} from '../util/keycloak.js';
import {Logger} from '../util/logger.js';
import {requireVerification, verify} from '../util/verify.js';
const log = new Logger('cerc:keycloak-userreg:mw:register');
export const VerifyMW : MiddlewareFunction = async (request, response, data, resolve, reject) =>{
if (!data?.json?.token) {
log.error(data);
log.error('Invalid request', request.body);
reject(400);
return;
}
try {
const client = await getKeyCloakClient();
const userId = await verify(data.json.token);
if (!userId) {
throw new Error(`Unable to verify ${data.json.token}`);
}
const user = await client.users.findOne({ realm: Config.TARGET_REALM, id: userId});
response.send({
'username': user?.username,
'email': user?.email,
'api-key': (user?.attributes as any)['api-key']
});
resolve();
} catch (e) {
log.error('Error verifying token', data.json.token, e);
reject(500);
}
};

27
src/template/verify.html Executable file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body: {
color: black;
}
a {
color: black;
}
</style>
</head>
<body>
<div style="width: 100%">
<div style="padding:10px;">
<p style="font-weight: 100; font-size: 16px ; line-height: 37px; font-family: Verdana; color: #1C1E3A; font-family: Verdana;">Thank for registering. To verify and enable your account, please click this link:
<a href="%%verifyLink%%">%%verifyLink%%</a>
</p>
</div>
</div>
</div>
</body>
</html>

1
src/template/verify.txt Executable file
View File

@ -0,0 +1 @@
Thank for registering. To verify and enable your account, please follow this link: %%verifyLink%%

View File

@ -2,21 +2,29 @@ import * as http from 'http';
import {HttpMethod, HTTPRequestHandler} from 'http-request-handler';
import {Config} from './config.js';
import {CorsMW} from "./middleware/cors.js";
import {CorsMW} from './middleware/cors.js';
import {JsonParserMW} from './middleware/json_parser.js';
import {RegisterMW} from './middleware/register.js';
import {VerifyMW} from './middleware/verify.js';
import {Logger} from './util/logger.js';
const log = new Logger('cerc:keycloak-userreg');
const handler = new HTTPRequestHandler();
handler.on(HttpMethod.HTTP_OPTIONS, '/register', [CorsMW], async (request, response) => {
response.ok()
response.ok();
});
handler.on(HttpMethod.HTTP_POST, '/register', [CorsMW, JsonParserMW, RegisterMW], async (request, response) => {
// If nothing has answered by now, return an error.
response.error(500);
response.error(500); // If nothing has answered by now, return an error.
});
handler.on(HttpMethod.HTTP_OPTIONS, '/verify', [CorsMW], async (request, response) => {
response.ok();
});
handler.on(HttpMethod.HTTP_POST, '/verify', [CorsMW, JsonParserMW, VerifyMW], async (request, response) => {
response.error(500); // If nothing has answered by now, return an error.
});
http.createServer(handler.getListener()).listen(Config.LISTEN_PORT, '0.0.0.0');

44
src/util/smtp.ts Normal file
View File

@ -0,0 +1,44 @@
import fs from 'fs';
import nodemailer from 'nodemailer';
import path, {dirname} from 'path';
import { fileURLToPath } from 'url';
import {Config} from '../config.js';
import {Logger} from './logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const log = new Logger('cerc:keycloak-userreg:util:smtp');
const smtp = nodemailer.createTransport({
host: Config.SMTP_HOST,
port: Config.SMTP_PORT,
secure: 465 == Config.SMTP_PORT,
requireTLS: 465 != Config.SMTP_PORT,
auth: {
user: Config.SMTP_USER,
pass: Config.SMTP_PW,
},
});
export const sendMessage = async (to: string, subject: string, template: string, vars: any) => {
let text= fs.readFileSync(path.resolve(__dirname,`../template/${template}.txt`), 'utf8');
for (const k in vars) {
text = text.split(`%%${k}%%`).join(vars[k]);
}
let html = fs.readFileSync(path.resolve(__dirname,`../template/${template}.html`), 'utf8');
for (const k in vars) {
html = html.split(`%%${k}%%`).join(vars[k]);
}
log.debug(`Sending e-mail "${subject}" to "${to}" ...`);
await smtp.sendMail({
from: Config.SMTP_FROM,
to: to,
subject: subject,
text: text,
html: html,
});
};

97
src/util/verify.ts Normal file
View File

@ -0,0 +1,97 @@
import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
import randomstring from 'randomstring';
import {Config} from '../config.js';
import {getKeyCloakClient} from '../util/keycloak.js';
import {Logger} from './logger.js';
import {sendMessage} from './smtp.js';
const log = new Logger('cerc:keycloak-userreg:util:verify');
const getUser = async (userOrId: string | UserRepresentation) => {
const keycloak = await getKeyCloakClient();
let user: UserRepresentation | undefined;
if (typeof userOrId === 'string') {
user = await keycloak.users.findOne({ id: userOrId, realm: Config.TARGET_REALM });
} else {
user = userOrId;
}
return user;
};
export const requireVerification = async (userOrId: string | UserRepresentation) => {
const keycloak = await getKeyCloakClient();
const user = await getUser(userOrId);
if (!user) {
log.warn(`No matching user: ${userOrId}`);
return null;
}
const newToken = randomstring.generate(30);
const changes = {
enabled: false,
emailVerified: false,
attributes: {
...user.attributes,
[Config.VERIFY_EMAIL_TKN_ATTR]: [newToken]
}
};
await keycloak.users.update({ id: user.id!, realm: Config.TARGET_REALM }, changes);
await sendVerificationEmail(user.id!);
return newToken;
};
export const sendVerificationEmail = async (userOrId: string | UserRepresentation) => {
const user = await getUser(userOrId);
if (!user) {
log.warn(`No matching user: ${userOrId}`);
return false;
}
const tokens: string[] = user.attributes ? user.attributes[Config.VERIFY_EMAIL_TKN_ATTR] : [];
if (!tokens || tokens.length != 1) {
log.warn(`User does not have an active verification token: ${user.id}`);
return false;
}
const verifyLink = Config.VERIFY_EMAIL_URL.replace('%%token%%', tokens[0]);
await sendMessage(user.email!, Config.VERIFY_EMAIL_SUBJECT, 'verify', { verifyLink });
return true;
};
export const verify = async (token: string) => {
const keycloak = await getKeyCloakClient();
const matches = await keycloak.users.find({
realm: Config.TARGET_REALM,
q: `${Config.VERIFY_EMAIL_TKN_ATTR}:${token}`
});
if (!matches || matches.length != 1) {
log.warn(`No user found for token: ${token}`);
return null;
}
const user = await getUser(matches[0].id!);
if (!user) {
log.warn(`No user found for id: ${matches[0].id}`);
return null;
}
const attributes = {
...user.attributes
};
delete attributes[Config.VERIFY_EMAIL_TKN_ATTR];
const changes = {
enabled: true,
emailVerified: true,
attributes
};
await keycloak.users.update({ id: user.id!, realm: Config.TARGET_REALM }, changes);
return user.id;
};

View File

@ -195,6 +195,18 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.17.5.tgz#c58b12bca8c2a437b38c15270615627e96dd0bc5"
integrity sha512-xNbS75FxH6P4UXTPUJp/zNPq6/xsfdJKussCWNOnz4aULWIRwMgP1LgaB5RiBnMX1DPCYenuqGZfnIAx5mbFLA==
"@types/nodemailer@^6.4.10":
version "6.4.10"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.10.tgz#746c952d5f22e48efded93c0a5607f59c6369b4c"
integrity sha512-oPW/IdhkU3FyZc1dzeqmS+MBjrjZNiiINnrEOrWALzccJlP5xTlbkNr2YnTnnyj9Eqm5ofjRoASEbrCYpA7BrA==
dependencies:
"@types/node" "*"
"@types/randomstring@^1.1.8":
version "1.1.8"
resolved "https://registry.yarnpkg.com/@types/randomstring/-/randomstring-1.1.8.tgz#799ce94adbe162964e655df954bf3dc85576747d"
integrity sha512-NPOJcW+TTjT9Qiog0UjSoG3Sj24c7EfzZO39BU9E61D7fQtwNmBNblyQhSsK9+5s9Fm0o31rvX+ZyZkpE/c7jA==
"@types/request@^2.48.8":
version "2.48.8"
resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.8.tgz#0b90fde3b655ab50976cb8c5ac00faca22f5a82c"
@ -1016,6 +1028,11 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
nodemailer@^6.9.5:
version "6.9.5"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.5.tgz#eaeae949c62ec84ef1e9128df89fc146a1017aca"
integrity sha512-/dmdWo62XjumuLc5+AYQZeiRj+PRR8y8qKtFCOyuOl1k/hckZd8durUUHs/ucKx6/8kN+wFxqKJlQ/LK/qR5FA==
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@ -1221,6 +1238,18 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
randombytes@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.3.tgz#674c99760901c3c4112771a31e521dc349cc09ec"
integrity sha512-lDVjxQQFoCG1jcrP06LNo2lbWp4QTShEXnhActFBwYuHprllQV6VUpwreApsYqCgD+N1mHoqJ/BI/4eV4R2GYg==
randomstring@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/randomstring/-/randomstring-1.3.0.tgz#1bf9d730066899e70aee3285573f84708278683d"
integrity sha512-gY7aQ4i1BgwZ8I1Op4YseITAyiDiajeZOPQUbIq9TPGPhUm5FX59izIaOpmKbME1nmnEiABf28d9K2VSii6BBg==
dependencies:
randombytes "2.0.3"
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"