From d369ddbeafdf9b8f9649bd67819e9e6150486446 Mon Sep 17 00:00:00 2001 From: Thomas E Lackey Date: Wed, 13 Sep 2023 21:09:06 +0000 Subject: [PATCH] Add e-mail verification. (#1) Reviewed-on: https://git.vdb.to/cerc-io/keycloak-reg-api/pulls/1 Co-authored-by: Thomas E Lackey Co-committed-by: Thomas E Lackey --- .eslintrc | 3 +- package.json | 8 +++- src/config.ts | 13 ++++- src/middleware/cors.ts | 6 +-- src/middleware/register.ts | 21 ++++++--- src/middleware/verify.ts | 36 ++++++++++++++ src/template/verify.html | 27 +++++++++++ src/template/verify.txt | 1 + src/userreg.ts | 16 +++++-- src/util/smtp.ts | 44 +++++++++++++++++ src/util/verify.ts | 97 ++++++++++++++++++++++++++++++++++++++ yarn.lock | 29 ++++++++++++ 12 files changed, 283 insertions(+), 18 deletions(-) create mode 100644 src/middleware/verify.ts create mode 100755 src/template/verify.html create mode 100755 src/template/verify.txt create mode 100644 src/util/smtp.ts create mode 100644 src/util/verify.ts diff --git a/.eslintrc b/.eslintrc index 0d2feb1..14a5b4c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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" } } diff --git a/package.json b/package.json index 50397f6..791b996 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/config.ts b/src/config.ts index 04c2f22..0ce75a3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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' }; diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index e141498..2822512 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -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(); }; diff --git a/src/middleware/register.ts b/src/middleware/register.ts index bd09abd..15272f3 100644 --- a/src/middleware/register.ts +++ b/src/middleware/register.ts @@ -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); diff --git a/src/middleware/verify.ts b/src/middleware/verify.ts new file mode 100644 index 0000000..6cb6a90 --- /dev/null +++ b/src/middleware/verify.ts @@ -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); + } +}; diff --git a/src/template/verify.html b/src/template/verify.html new file mode 100755 index 0000000..a968fc5 --- /dev/null +++ b/src/template/verify.html @@ -0,0 +1,27 @@ + + + + + + + Document + + + +
+
+

Thank for registering. To verify and enable your account, please click this link: + %%verifyLink%% +

+
+
+ + + diff --git a/src/template/verify.txt b/src/template/verify.txt new file mode 100755 index 0000000..15d8ed7 --- /dev/null +++ b/src/template/verify.txt @@ -0,0 +1 @@ +Thank for registering. To verify and enable your account, please follow this link: %%verifyLink%% diff --git a/src/userreg.ts b/src/userreg.ts index b2def70..e590184 100644 --- a/src/userreg.ts +++ b/src/userreg.ts @@ -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'); diff --git a/src/util/smtp.ts b/src/util/smtp.ts new file mode 100644 index 0000000..a01b3bf --- /dev/null +++ b/src/util/smtp.ts @@ -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, + }); +}; diff --git a/src/util/verify.ts b/src/util/verify.ts new file mode 100644 index 0000000..ea3e7cf --- /dev/null +++ b/src/util/verify.ts @@ -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; +}; diff --git a/yarn.lock b/yarn.lock index 2f542ac..3e87cdf 100644 --- a/yarn.lock +++ b/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"