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..ef1fd41 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",
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"