Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gmail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ Before committing, run prettier
Update the `CLIENT_ID` and the public URL in `consts.ts`, then run
> npm run build
> node dist

# Run tests
Run the following command to run unit tests
> npm run test
35 changes: 22 additions & 13 deletions gmail/init_db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,32 @@
-- createdb odoo_gmail_addin
-- psql -f init_db.sql odoo_gmail_addin

CREATE TABLE IF NOT EXISTS users_settings (
CREATE TABLE IF NOT EXISTS enc_users_settings (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
odoo_url TEXT,
odoo_token TEXT,
login_token TEXT,
login_token_expire_at TIMESTAMP WITH TIME ZONE,
translations JSON,
translations_expire_at TIMESTAMP WITH TIME ZONE

-- hash(derived key), used to retrieve the user's settings
key_hash bytea NOT NULL UNIQUE,
CONSTRAINT users_settings_key_hash_length_check CHECK (octet_length(key_hash) = 32),

enc_odoo_url bytea,
enc_odoo_token bytea,
enc_translations bytea,
enc_translations_expire_at bytea,

-- temporary value used during the Odoo authentication process
enc_login_token bytea,
enc_login_token_expire_at bytea
);

CREATE TABLE IF NOT EXISTS email_logs (
CREATE TABLE IF NOT EXISTS enc_email_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
message_id TEXT NOT NULL,
res_id INTEGER NOT NULL,
res_model TEXT NOT NULL,
message_id_hash bytea NOT NULL,
enc_res_id bytea NOT NULL,
enc_res_model bytea NOT NULL,
-- not encrypted to clean in the CRON
create_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users_settings(id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES enc_users_settings(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS enc_email_logs_user_id_idx ON enc_email_logs(user_id);
3 changes: 2 additions & 1 deletion gmail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "rm -rf dist && tsc && npm run gen-secret && mv .env dist/.env",
"start": "cd dist && node index.js",
"prettier": "prettier --write 'src/**/*.ts'",
"gen-secret": "echo APP_SECRET=$(node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\") > .env"
"gen-secret": "echo APP_SECRET=$(node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\") > .env",
"test": "node --import=tsx --test tests/*.test.ts"
},
"author": "",
"license": "",
Expand Down
22 changes: 15 additions & 7 deletions gmail/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { google } from "googleapis";
import jwt from "jsonwebtoken";
import cron from "node-cron";
import path from "path";
import { HOST } from "./consts";
import { Email } from "./models/email";
import { Partner } from "./models/partner";
import { State } from "./models/state";
Expand Down Expand Up @@ -38,7 +39,7 @@ cron.schedule("0 0 * * *", async () => {
console.log("Clean the email logging table...");
await pool.query(
`
DELETE FROM email_logs
DELETE FROM enc_email_logs
WHERE create_date < NOW() - INTERVAL '1 month'
`,
);
Expand Down Expand Up @@ -143,10 +144,6 @@ app.post(
app.post(
"/execute_action",
asyncHandler(async (req, res) => {
const user = await User.getUserFromGoogleToken(req.body);

const _t = await Translate.getTranslations(user);

const rawFormInputs = req.body.commonEventObject.formInputs || {};
const formInputs = Object.fromEntries(
Object.entries(rawFormInputs).map(([key, value]) => [
Expand All @@ -169,6 +166,9 @@ app.post(
state.email.accessToken = req.body.gmail.accessToken;
}

const user = await User.getUserFromGoogleToken(req.body);
const _t = await Translate.getTranslations(user);

const result = await getEventHandler(functionName)(state, _t, user, args, formInputs);
res.json(result.build());
}),
Expand Down Expand Up @@ -269,10 +269,18 @@ app.use(
* Route that we can call to check that the database is correctly configured.
*/
app.use("/db_check", async (req, res, next) => {
try {
new URL(HOST);
} catch (e) {
console.error(e);
res.json("Invalid host URL");
return;
}

try {
// check that the table exists
await pool.query("SELECT id FROM users_settings LIMIT 1");
await pool.query("SELECT id FROM email_logs LIMIT 1");
await pool.query("SELECT id FROM enc_users_settings LIMIT 1");
await pool.query("SELECT id FROM enc_email_logs LIMIT 1");
res.json("ok");
} catch (e) {
console.error(e);
Expand Down
25 changes: 18 additions & 7 deletions gmail/src/models/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { google } from "googleapis";
import { simpleParser } from "mailparser";
import { ErrorMessage } from "../models/error_message";
import pool from "../utils/db";
import { decryptAesGcm, encryptAesGcm, hmacSha256 } from "../utils/encrypt";
import { User } from "./user";

const gmail = google.gmail({ version: "v1" });
Expand Down Expand Up @@ -253,12 +254,19 @@ export class Email {
async setLoggingState(user: User, resModel: string, resId: number) {
console.log(`Logging email for user ${user.email}`);
this.loggingState[resModel].push(resId);

const enc = (pt) => encryptAesGcm(pt, user.encryptionKey);
await pool.query(
`
INSERT INTO email_logs (user_id, message_id, res_id, res_model)
INSERT INTO enc_email_logs (user_id, message_id_hash, enc_res_id, enc_res_model)
VALUES ($1, $2, $3, $4)
`,
[user.id, this.messageId, resId, resModel],
[
user.id,
hmacSha256(user.encryptionKey, `Email log ${this.messageId}`),
enc(resId.toString()),
enc(resModel),
],
);
}

Expand All @@ -281,22 +289,25 @@ export class Email {
user: User,
messageId: string,
): Promise<Record<string, number[]>> {
const dec = (ct) => decryptAesGcm(ct, user.encryptionKey);

const result = await pool.query(
`
SELECT res_model, res_id
FROM email_logs
WHERE user_id = $1 AND message_id = $2
SELECT enc_res_model, enc_res_id
FROM enc_email_logs
WHERE user_id = $1 AND message_id_hash = $2
`,
[user.id, messageId],
[user.id, hmacSha256(user.encryptionKey, `Email log ${messageId}`)],
);

const ret: Record<string, number[]> = {
"res.partner": [],
"crm.lead": [],
"helpdesk.ticket": [],
"project.task": [],
};
for (const row of result.rows) {
ret[row.res_model].push(row.res_id);
ret[dec(row.enc_res_model)].push(parseInt(dec(row.enc_res_id)));
}
return ret;
}
Expand Down
127 changes: 78 additions & 49 deletions gmail/src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import * as crypto from "crypto";
import { OAuth2Client } from "google-auth-library";
import { CLIENT_ID } from "../consts";
import pool from "../utils/db";
import { decryptAesGcm, deriveKeyScryptCached, encryptAesGcm, hmacSha256 } from "../utils/encrypt";

const oAuth2Client = new OAuth2Client();

export class User {
id?: number;
encryptionKey: Buffer;

email: string;
odooUrl?: string;
odooToken?: string;
Expand All @@ -21,6 +26,7 @@ export class User {

constructor(
id: number,
encryptionKey: Buffer,
email: string,
odooUrl?: string,
odooToken?: string,
Expand All @@ -30,6 +36,7 @@ export class User {
translationsExpireAt?: Date,
) {
this.id = id;
this.encryptionKey = encryptionKey;
this.email = email;
this.odooUrl = odooUrl;
this.odooToken = odooToken;
Expand All @@ -41,34 +48,37 @@ export class User {

async save() {
console.log(`Saving user ${this.email}`);

const enc = (pt) => encryptAesGcm(pt, this.encryptionKey);
await pool.query(
`
INSERT INTO users_settings (
email,
odoo_url,
odoo_token,
login_token,
login_token_expire_at,
translations,
translations_expire_at
INSERT INTO enc_users_settings (
key_hash,
enc_odoo_url,
enc_odoo_token,
enc_login_token,
enc_login_token_expire_at,
enc_translations,
enc_translations_expire_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (email) DO UPDATE
SET odoo_url = EXCLUDED.odoo_url,
odoo_token = EXCLUDED.odoo_token,
login_token = EXCLUDED.login_token,
login_token_expire_at = EXCLUDED.login_token_expire_at,
translations = EXCLUDED.translations,
translations_expire_at = EXCLUDED.translations_expire_at
ON CONFLICT (key_hash) DO UPDATE
SET
enc_odoo_url = EXCLUDED.enc_odoo_url,
enc_odoo_token = EXCLUDED.enc_odoo_token,
enc_login_token = EXCLUDED.enc_login_token,
enc_login_token_expire_at = EXCLUDED.enc_login_token_expire_at,
enc_translations = EXCLUDED.enc_translations,
enc_translations_expire_at = EXCLUDED.enc_translations_expire_at
`,
[
this.email,
this.odooUrl,
this.odooToken,
this.loginToken,
this.loginTokenExpireAt,
this.translations,
this.translationsExpireAt,
hmacSha256(this.encryptionKey, "Hash Key Salt"),
enc(this.odooUrl),
enc(this.odooToken),
enc(this.loginToken),
enc(this.loginTokenExpireAt?.toISOString()),
enc(JSON.stringify(this.translations)),
enc(this.translationsExpireAt?.toISOString()),
],
);
}
Expand All @@ -88,7 +98,6 @@ export class User {
* Check the token we receive from Google, and get the user based on the email.
*/
static async getUserFromGoogleToken(event: any): Promise<User> {
const oAuth2Client = new OAuth2Client();
const decodedToken = await oAuth2Client.verifyIdToken({
idToken: event.authorizationEventObject.userIdToken,
audience: CLIENT_ID,
Expand All @@ -97,16 +106,25 @@ export class User {
if (!payload.email || !payload.email_verified) {
throw new Error("Failed to authenticate the user");
}
return await User._getUserFromEmail(payload.email);

const encryptionKey = await deriveKeyScryptCached(
`${payload.sub}-${payload.email.toLowerCase()}`,
"Odoo-Gmail-Addin-Salt",
);
return await User._getUserFromEncryptionKey(encryptionKey, payload.email);
}

/**
* Check the token we receive from the user's browser, and get the user.
*
* The login token can only be used once, and is reset after getting the user.
*/
static async getUserFromLoginToken(email: string, loginToken: string): Promise<User> {
const user = await User._getUserFromEmail(email);
static async getUserFromLoginToken(
email: string,
encryptionKey: Buffer,
loginToken: string,
): Promise<User> {
const user = await User._getUserFromEncryptionKey(encryptionKey, email);

// constant time comparison
if (!loginToken?.length || loginToken?.length !== user.loginToken?.length) {
Expand Down Expand Up @@ -140,36 +158,47 @@ export class User {
await this.save();
}

private static async _getUserFromEmail(email: string): Promise<User> {
private static async _getUserFromEncryptionKey(
encryptionKey: Buffer,
email: string,
): Promise<User> {
const keyHash = hmacSha256(encryptionKey, "Hash Key Salt");
const result = await pool.query(
`
SELECT id,
email,
odoo_url,
odoo_token,
login_token,
login_token_expire_at,
translations,
translations_expire_at
FROM users_settings
WHERE email = $1
enc_odoo_url,
enc_odoo_token,
enc_login_token,
enc_login_token_expire_at,
enc_translations,
enc_translations_expire_at
FROM enc_users_settings
WHERE key_hash = $1
`,
[email],
[keyHash],
);
if (result.rows.length === 0) {
return new User(null, email);
// new user
return new User(null, encryptionKey, email);
}

const data = result.rows[0];
return new User(
data.id,
email,
data.odoo_url,
data.odoo_token,
data.login_token,
data.login_token_expire_at,
data.translations,
data.translations_expire_at,
);
try {
const dec = (ct) => decryptAesGcm(ct, encryptionKey);
const data = result.rows[0];
return new User(
data.id,
encryptionKey,
email,
dec(data.enc_odoo_url),
dec(data.enc_odoo_token),
dec(data.enc_login_token),
data.enc_login_token_expire_at && new Date(dec(data.enc_login_token_expire_at)),
data.enc_translations && JSON.parse(dec(data.enc_translations)),
data.enc_translations_expire_at && new Date(dec(data.enc_translations_expire_at)),
);
} catch (error) {
console.log(`Decryption failed for ${email}, ${error}`);
return new User(null, encryptionKey, email);
}
}
}
Loading