Add e-mail verification. (#1)
Reviewed-on: #1 Co-authored-by: Thomas E Lackey <telackey@bozemanpass.com> Co-committed-by: Thomas E Lackey <telackey@bozemanpass.com>
This commit is contained in:
parent
32b2ed2df2
commit
d369ddbeaf
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
@ -23,7 +27,7 @@
|
||||
"start": "DEBUG='cerc:keycloak-userreg:*' node dist/userreg.js",
|
||||
"dev": "DEBUG='cerc:keycloak-userreg:*' ts-node src/userreg.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsc",
|
||||
"build": "rm -rf dist && tsc && cp -rpf src/template dist",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"lint:fix": "eslint . --ext .ts --fix"
|
||||
},
|
||||
|
@ -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'
|
||||
};
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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
36
src/middleware/verify.ts
Normal 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
27
src/template/verify.html
Executable 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
1
src/template/verify.txt
Executable file
@ -0,0 +1 @@
|
||||
Thank for registering. To verify and enable your account, please follow this link: %%verifyLink%%
|
@ -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
44
src/util/smtp.ts
Normal 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
97
src/util/verify.ts
Normal 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;
|
||||
};
|
29
yarn.lock
29
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user