implements web verification

This commit is contained in:
Matthieu Jolimaitre 2024-02-02 16:45:08 +01:00
parent a7799eb3b1
commit 440e8bf324
9 changed files with 303 additions and 28 deletions

View file

@ -70,6 +70,15 @@ export class EpitlsBot {
return this.bot.user?.displayName;
}
public async get_username(user_id: string) {
try {
const user = await this.bot.users.fetch(user_id);
return user.displayName;
} catch (_) {
return undefined;
}
}
private async register_commands() {
const cmd = new SlashCommandBuilder()
.setName("associate")
@ -103,6 +112,7 @@ export class EpitlsBot {
this.assoc_channel.send({ cri_login, discord_user_id, callback: resolver });
await interaction.reply(message_command_response_sending_email(email));
log(`Started verification for discord id '${discord_user_id}' with cri login '${cri_login}'.`);
const result = await promise;
if (result === true) interaction.editReply(message_command_response_success());
else interaction.editReply(message_command_response_error(result));

View file

@ -1,4 +1,6 @@
import { SmtpClient } from "https://deno.land/x/smtp@v0.7.0/mod.ts";
import { ClientOptions, SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts";
import { log_from } from "./utils.ts";
const log = (...args: unknown[]) => log_from(import.meta.url, ...args);
export type EmailerConfig = {
hostname: string;
@ -11,21 +13,61 @@ export type EmailerConfig = {
* Wraps emailing process.
*/
export class Emailer {
private client;
private config;
private sender_email;
private sender_address;
private client_options;
public constructor(config: EmailerConfig, sender_email: string) {
this.config = config;
this.sender_email = sender_email;
this.client = new SmtpClient();
public constructor(config: EmailerConfig) {
const client_options: ClientOptions = {
connection: {
hostname: config.hostname,
auth: {
username: config.username,
password: config.password,
},
port: config.port,
tls: true,
},
};
this.client_options = client_options;
this.sender_address = config.username;
}
public async send(to_email: string, subject: string, content: string) {
const from = this.sender_email;
public async send_confirmation_mail(discord_username: string, cri_email: string, link: string) {
await this.send(cri_email, CONFIRMATION_EMAIL_SUBJECT, confirmation_email_body(discord_username, link));
}
private async send(to_email: string, subject: string, content: string) {
const client = new SMTPClient(this.client_options);
const from = this.sender_address;
const to = to_email;
await this.client.connectTLS(this.config);
await this.client.send({ from, to, subject, content });
await this.client.close();
await client.send({ from, to, subject, html: content });
await client.close();
log(`Sent an email to '${to_email}'.`);
}
}
const CONFIRMATION_EMAIL_SUBJECT = "Confirmation d'association à un compte discord";
function confirmation_email_body(discord_username: string, link: string) {
return `<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> </head>
<body>
<pre>
Bonjour,
Ceci est un message automatique de confirmation pour l'association
du compte discord '${discord_username}' à cet email.
Pour terminer l'association, veuillez suivre sur le lien suivant :
<a href="${link}">${link}</a>
Si vous n'êtes pas à l'origine de cette demande, veuillez ignorer ce message.
---
Je suis un robot et cette action à é effectuée automatiquement.
Vous pouvez contacter le développeur de se service à l'email matthieu at imagevo dot fr.
</pre>
</body>
</html>
`;
}

View file

@ -1,24 +1,35 @@
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-net --allow-env --unstable-kv
import { log_from, read_secrets, root_path, SimpleResult, wait } from "./utils.ts";
import { log_from, read_conf, root_path, SimpleResult, wait } from "./utils.ts";
import { State } from "./state.ts";
import { RuleSet } from "./rules.ts";
import { EpitlsBot } from "./bot.ts";
import { CriApi } from "./cri.ts";
import { Emailer } from "./email.ts";
import { WebVerifier } from "./verifier.ts";
const log = (...args: unknown[]) => log_from(import.meta.url, ...args);
async function main() {
const secrets = await read_secrets(root_path() + "/secrets.json");
const conf = await read_conf(root_path() + "/conf.json");
const rules = await RuleSet.from_file(root_path() + "/rules.json");
log("Loaded rules for", rules.size(), "roles.");
const state = await State.from_dir(root_path() + "/local");
log("Loaded state with", await state.users_count(), "users.");
const bot = new EpitlsBot(secrets.discord_bot_token);
const bot = new EpitlsBot(conf.discord.bot_token);
await bot.start();
log(`Started bot '${bot.bot_name()}' .`);
const cri_api = new CriApi(secrets.cri_token);
const service = new Service(state, bot, cri_api, rules);
const verifier = new WebVerifier(conf.verif_http);
await verifier.start();
log(`Started web verifier at '${verifier.url()}' .`);
const cri_api = new CriApi(conf.cri.cri_token);
const mailer = new Emailer(conf.email_smtp);
const service = new Service(state, bot, cri_api, rules, mailer, verifier);
await service.serve();
}
@ -30,25 +41,38 @@ class Service {
bot;
cri_api;
rules;
emailer;
verifier;
constructor(state: State, bot: EpitlsBot, cri_api: CriApi, rules: RuleSet) {
constructor(state: State, bot: EpitlsBot, cri_api: CriApi, rules: RuleSet, emailer: Emailer, verifier: WebVerifier) {
this.state = state;
this.bot = bot;
this.cri_api = cri_api;
this.rules = rules;
this.emailer = emailer;
this.verifier = verifier;
}
/**
* Main loops.
* Launches main loops.
*/
async serve() {
await this.update_all_users_roles();
// for all received associations, trigger the association procedure.
(async () => {
for await (const { discord_user_id, cri_login, callback } of this.bot.receive_associations()) {
this.association_procedure(discord_user_id, cri_login).then(callback);
}
})();
// for all links that must be sent, trigger mail sending.
(async () => {
for await (const { cri_login, discord_id, link } of this.verifier.links_to_send()) {
const username = await this.bot.get_username(discord_id);
this.emailer.send_confirmation_mail(username ?? "<unknown>", cri_login + "@epita.fr", link);
}
})();
}
async update_all_users_roles() {
@ -61,15 +85,18 @@ class Service {
async update_user_roles(cri_login: string, discord_user_id: string) {
const groups = await this.cri_api.groups_of(cri_login);
// log("found groups", groups);
const roles = groups.map((group) => this.rules.roles_for_group(group)).flat();
// log("found setting roles", roles);
for (const { guild_id, role_id } of roles) await this.bot.assign_role(discord_user_id, guild_id, role_id);
}
async association_procedure(discord_user_id: string, cri_login: string): Promise<SimpleResult> {
try {
await wait(1000);
if (!await this.cri_api.user_exists(cri_login)) return "No such login.";
this.state.set_user(discord_user_id, cri_login);
const res = await this.verifier.verification(discord_user_id, cri_login);
if (res !== true) return res;
await this.state.set_user(discord_user_id, cri_login);
await this.update_user_roles(cri_login, discord_user_id);
} catch (error) {
console.error(error);

View file

@ -1,5 +1,6 @@
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
export type TargetRole = { guild_id: string; role_id: string };
type SerializedRule = { group_id: string; target_role: TargetRole };
/**
* A set of rules for associating CRI groups with Discord roles.
@ -17,7 +18,7 @@ export class RuleSet {
public static async from_file(path: string) {
const result = new RuleSet();
const file_content = await Deno.readTextFile(path);
const parsed = JSON.parse(file_content) as SerializedRule[];
const parsed = parse_rules(file_content);
for (const { group_id, target_role } of parsed) result.append_rule(group_id, target_role);
return result;
}
@ -47,3 +48,10 @@ export class RuleSet {
roles.push(target_role);
}
}
function parse_rules(content: string) {
return z.array(z.object({
group_id: z.string(),
target_role: z.object({ guild_id: z.string(), role_id: z.string() }),
})).parse(JSON.parse(content));
}

View file

@ -46,6 +46,8 @@ export function split_promise<T>() {
return { promise, resolver };
}
export type Resolver<T> = (item: T) => void;
/**
* Logging function factory.
*/
@ -55,17 +57,54 @@ export function log_from(url: string, ...args: unknown[]) {
console.log(`[${date}][epitls][${file}]`, ...args);
}
export type Conf = Awaited<ReturnType<typeof read_conf>>;
/**
* Reads and parse a configuration file containing the current instance' secrets.
*/
export async function read_secrets(path: string) {
export async function read_conf(path: string) {
const content = await Deno.readTextFile(path);
return z.object({
discord_bot_token: z.string(),
cri_token: z.string(),
discord: z.object({
bot_token: z.string(),
}),
cri: z.object({
cri_token: z.string(),
}),
email_smtp: z.object({
hostname: z.string(),
port: z.number(),
username: z.string(),
password: z.string(),
}),
verif_http: z.object({
port: z.number(),
url_prefix: z.string(),
}),
}).parse(JSON.parse(content));
}
export function placeholder_conf() {
const result: Conf = {
"discord": {
"bot_token": "available at 'https://discord.com/developers/applications'",
},
"cri": {
"cri_token": "'username:password' base64-encoded",
},
"email_smtp": {
"hostname": "smtp.gmail.com",
"port": 465,
"username": "bot@gmail.com",
"password": "PWD",
},
"verif_http": {
"port": 80,
"url_prefix": "localhost",
},
};
return JSON.stringify(result, null, 4);
}
export type SimpleResult = true | string;
export async function wait(ms: number) {

115
src/verifier.ts Normal file
View file

@ -0,0 +1,115 @@
import { v1 as uuid } from "https://deno.land/std@0.213.0/uuid/mod.ts";
import { channel, log_from, Resolver, SimpleResult, split_promise, wait } from "./utils.ts";
const log = (...args: unknown[]) => log_from(import.meta.url, ...args);
export type WebVerifierConf = {
port: number;
url_prefix: string;
};
export type LinkToSend = {
discord_id: string;
cri_login: string;
link: string;
};
export class WebVerifier {
conf;
awaiting;
link_channel;
public constructor(conf: WebVerifierConf) {
this.conf = conf;
this.awaiting = new Map<string, Resolver<SimpleResult>>();
this.link_channel = channel<LinkToSend>();
}
public async start() {
const { promise, resolver } = split_promise<void>();
Deno.serve({
hostname: "0.0.0.0",
port: this.conf.port,
onListen: () => resolver(),
}, (request) => this.serve(request));
return await promise;
}
public async verification(discord_id: string, cri_login: string) {
const { link, uid } = this.create_verif_link();
const { promise, resolver } = split_promise<SimpleResult>();
this.awaiting.set(uid, resolver);
log(`Created verification link '${link}' for discord id '${discord_id}' to cri login '${cri_login}'.`);
this.link_channel.send({ cri_login, discord_id, link });
this.start_peremption(uid);
return await promise;
}
public async *links_to_send() {
while (true) yield await this.link_channel.receive();
}
public url() {
return `http://${this.conf.url_prefix}`;
}
private serve(request: Request): Response {
const url = new URL(request.url);
if (!url.pathname.startsWith("/verify/")) return response_failure("Invalid path.");
const [uid] = url.pathname.slice("/verify/".length).split("/");
const resolver = this.awaiting.get(uid);
if (resolver === undefined) return response_failure("Invalid verification link.");
resolver(true);
this.awaiting.delete(uid);
log(`Verified link ${uid} successfully.`);
return response_success();
}
private create_verif_link() {
const uid = uuid.generate() as string;
const link = `http://${this.conf.url_prefix}/verify/${uid}`;
return { uid, link };
}
private async start_peremption(uid: string) {
await wait(5 * 60 * 1000); // 5 mins
const resolver = this.awaiting.get(uid);
if (resolver === undefined) return;
resolver("Verification link timeout.");
this.awaiting.delete(uid);
log(`Link '${uid}' timed out.`);
}
}
function response_failure(message: string) {
const body = `<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>Epitls Verification</title>
</head>
<body>
<pre>
Failure :
${message}
</pre>
</body>
</html>
`;
return new Response(body, { headers: { "Content-Type": "text/html" } });
}
function response_success() {
const body = `<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>Epitls Verification</title>
</head>
<body>
<pre>
Verified successfully.
</pre>
</body>
</html>
`;
return new Response(body, { headers: { "Content-Type": "text/html" } });
}