Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

T1652 - Add 2FA #41

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Patch the `application_unproxied` function like so:
response.headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
response.headers['Access-Control-Max-Age'] = 1000
# note that '*' is not valid for Access-Control-Allow-Headers
response.headers['Access-Control-Allow-Headers'] = 'origin, x-csrftoken, content-type, accept'
response.headers['Access-Control-Allow-Headers'] = 'origin, x-csrftoken, content-type, accept, authorization'
return response(environ, start_response)


Expand Down
5 changes: 4 additions & 1 deletion src/components/Logout.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { Component, xml } from "@odoo/owl";
import Loader from "./Loader";
import { clearStoreCache } from "../store";
import OdooAPI from "../models/OdooAPI";

class Logout extends Component {

setup() {
this.logout()
}

logout() {
async logout() {
// First, we attempt to revoke the token on the backend.
await OdooAPI.logout();
// Clear session storage and reload page
clearStoreCache();
window.location.reload();
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,5 +266,8 @@
"The number may vary depending on letter checks. Don't worry, we record each of your interactions with the translations": "Die Zahl kann je nach Überprüfung der Buchstaben variieren. Keine Sorge, wir erfassen jede deiner Interaktionen mit den Übersetzungen",
"Your verification letter is awaiting approval. Once approved by our staff, you will be able to start translating in this skill": "Dein Bestätigungsbrief wartet auf Genehmigung. Sobald er von unserem Team genehmigt wurde, kannst du mit der Übersetzung in dieser Sprache beginnen",
"Awaiting approval": "Wartet auf Validierung",
"Waiting for your verification letter": "Warten auf deinen Bestätigungsbrief"
"Waiting for your verification letter": "Warten auf deinen Bestätigungsbrief",
"Use 2FA": "2FA verwenden",
"6-digits 2FA code": "6-stelliger 2FA-Code",
"This account requires 2FA": "Dieses Konto erfordert 2FA"
}
5 changes: 4 additions & 1 deletion src/i18n/fr_CH.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,5 +266,8 @@
"The number may vary depending on letter checks. Don't worry, we record each of your interactions with the translations": "Le chiffre peut varier en fonction des vérifications de lettres. Ne t'inquiéte pas nous enregistrons chacune de tes interactions avec les traductions",
"Your verification letter is awaiting approval. Once approved by our staff, you will be able to start translating in this skill": "Ta lettre de vérification est en attente de validation. Une fois approuvée par notre équipe, tu pourras commencer à traduire dans cette langue",
"Awaiting approval": "En attente de validation",
"Waiting for your verification letter": "En attente de ta lettre de vérification"
"Waiting for your verification letter": "En attente de ta lettre de vérification",
"Use 2FA": "Utiliser 2FA",
"6-digits 2FA code": "Code 2FA à 6 chiffres",
"This account requires 2FA": "Ce compte nécessite l'authentication à deux facteurs"
}
174 changes: 150 additions & 24 deletions src/models/OdooAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import store, { clearStoreCache } from "../store";
import { XmlRpcClient } from '@foxglove/xmlrpc';
import { selectedLang } from "../i18n";
import notyf from "../notifications";
import fetch from "@foxglove/just-fetch";
import _ from "../i18n";
import {
RPC_FAULT_CODE_ACCESS_DENIED,
Expand All @@ -19,51 +20,171 @@ import {
STORAGE_KEY
} from "../constants";

type AuthResponse = {
user_id: number;
auth_tokens: AuthTokens;
}

type AuthTokens = {
access_token: string;
refresh_token: string;
expires_at: string;
};

type ExecuteKwOptions = {
password?: string;
refreshIfExpired?: boolean;
}

// Declare the two XML-RPC clients
const authClient = new XmlRpcClient(import.meta.env.VITE_ODOO_URL + "/xmlrpc/2/common");
// Declare the XML-RPC client
const apiClient = new XmlRpcClient(import.meta.env.VITE_ODOO_URL + "/xmlrpc/2/object");

const setClientHeader = (header: string, value: string) => {
(apiClient.headers as Record<string, string>)[header] = value;
};


async function fetchJson(uri: string, body: any, verb = 'POST'): Promise<any> {
const res = await fetch(import.meta.env.VITE_ODOO_URL + uri, {
method: verb,
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
// TODO: pass language to get localized error.
// "Accept-Language": selectedLang,
}
});

if (!res.ok) {
throw new Error(`Failed to refresh. Returned ${res.status}: "${res.statusText}"`)
}

const payload = JSON.parse(await res.text());
if (payload.error) {
throw payload.error.data;
}

return payload.result;
}

// Buffered token refresh.
const refreshListeners: ((tokens?: AuthTokens, err?: any) => void)[] = [];
function refreshAccessToken(): Promise<AuthTokens> {
const operationCompleted =
(tokens?: AuthTokens, err?: any) => {
refreshListeners.forEach(l => l(tokens, err));
refreshListeners.length = 0;
};

return new Promise(async (res, rej) => {
refreshListeners.push((tokens, err) => tokens ? res(tokens) : rej(err));

// A refresh request is pending, wait for it and do nothing.
if (refreshListeners.length !== 1) {
return;
}

let authTokens: AuthTokens | null = null;
try {
authTokens = await fetchJson('/auth/refresh', {
refresh_token: store.refreshToken,
});
}
catch (e) {
operationCompleted(undefined, e);
return;
}

store.accessToken = authTokens?.access_token;
store.accessTokenExpiresAt = authTokens?.expires_at;
store.refreshToken = authTokens?.refresh_token;

window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));

operationCompleted(authTokens!);
})
}


const OdooAPI = {

/**
* Attempts to authenticate the user given a username and a
* password
* Attempts to authenticate the user given a username, a password, and optionally
* a totp.
* @param username the username
* @param password the password
* @returns the authenticated user's information or null if failed authenticating
* @returns True if the credentials are valid or the server error.
*/
async authenticate(username: string, password: string): Promise<boolean> {
const userId = await authClient.methodCall('authenticate', [
import.meta.env.VITE_ODOO_DBNAME,
username,
password,
[],
]) as number | false;
if (userId === false) {
return false;
} else {
store.userId = userId as number;
async authenticate(username: string, password: string, totp?: string): Promise<true | any> {
try {
const { user_id, auth_tokens }: AuthResponse = await fetchJson('/auth/login', {
"login": username, // username is called login in res_partner
password,
totp,
});

store.userId = user_id;
store.username = username;
store.password = password;
// INFO: next line needed because the store callback is not called every time we update the values
store.accessToken = auth_tokens.access_token;
store.refreshToken = auth_tokens.refresh_token;
store.accessTokenExpiresAt = auth_tokens.expires_at;

window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));

return true;
}
catch (e: any) {
console.warn("Failed to authenticate: ", e);
return e;
}
},

ifNoneElse<V, T extends any = undefined>(val: V, other?: T): V | T {
if ((val as any) === "None") return other as T;
return val;
},

async execute_kw<T>(model: string, method: string, ...args: any[]): Promise<T | undefined> {
async logout(): Promise<void> {
const route = '/auth/logout';
const refresh_token = store.refreshToken;
try {
args.push({context: {lang: selectedLang}});
const resp = await fetchJson(route, { refresh_token });
console.log(`Logout succeeded: ${resp}`);
} catch (error) {
console.warn(`Request to ${route} failed. This means that the refresh_token was probably not revoked on the backend. This is only a security risk if the refresh_token was intercepted by a malicious actor. In all cases, it will automatically expire when its expiration datetime is reached. Anyway, there's not much we can do at this point except clear to tokens in local storage.`);
throw error;
}
},

async executeWithOptions_kw<T>(model: string, method: string, options: ExecuteKwOptions, ...args: any[]): Promise<T | undefined> {
if (store.accessToken && !options.password) {
const refreshMarginMs = 10 * 1000;
// refresh the accessToken if it's going to expire in the next refreshMarginMs milliseconds;
if (options.refreshIfExpired !== false && (new Date(store.accessTokenExpiresAt ?? '').getTime()) < Date.now() + refreshMarginMs) {
try {
await refreshAccessToken();
}
catch (e) {
clearStoreCache();
throw e;
}
}

setClientHeader('Authorization', 'Bearer ' + store.accessToken);
}
else if (!options.password) {
// No token or password. Request will fail.
console.warn("Tried to execute a request without credentials.")
return;
}

try {
args.push({ context: { lang: selectedLang } });
const response = await apiClient.methodCall('execute_kw', [
import.meta.env.VITE_ODOO_DBNAME,
store.userId,
store.password,
options.password ?? 'None',
model,
method,
...args,
Expand All @@ -81,13 +202,18 @@ const OdooAPI = {
// Reset cache when the error is related to a user login issue
clearStoreCache();

} else {

} else {
notyf.error(_('Oops! An error occurred. Please contact Compassion for further assistance.'));

}

return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method is missing a return statement, in case of error

Suggested change
}
}
return;

}
},

async execute_kw<T>(model: string, method: string, ...args: any[]): Promise<T | undefined> {
return await this.executeWithOptions_kw(model, method, {}, ...args);
},
}

export default OdooAPI;
2 changes: 2 additions & 0 deletions src/pages/Home/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export default class Home extends Component {
// Fetch letters to display to the user for each of his translation skill
this.refresh().then(() => {
startTutorial(this.tutorial);
}).catch(() => {
navigateTo('/login')
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/pages/Layout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default class Layout extends Component {
}

checkStore() {
if (!store.username || !store.userId || !store.password) {
if (!store.username || !store.userId || !store.accessToken) {
navigateTo("/login");
} else if (!this.currentTranslator.data) {
this.refreshCurrentTranslator();
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Letters/LetterRowActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class LetterRowActions extends Component {

static template = xml`
<div class="flex gap-1 pl-3">
<t t-if="currentTranslator.data.role === 'admin'">
<t t-if="currentTranslator.data?.role === 'admin'">
<RouterLink to="'/letters/letter-view/' + props.letter.id">
<button class="text-blue-500 hover:text-compassion transition-colors">View</button>
</RouterLink>
Expand Down
36 changes: 30 additions & 6 deletions src/pages/Login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, useRef, useState, xml } from "@odoo/owl";
import { Component, useEffect, useRef, useState, xml } from "@odoo/owl";
import Button from "../components/Button";
import OdooAPI from "../models/OdooAPI";
import notyf from "../notifications";
Expand Down Expand Up @@ -39,13 +39,18 @@ class Login extends Component {
</svg>
</button>
</span>
<input t-if="state.use2FA" t-ref="input-2fa" class="compassion-input text-sm mb-3" type="text" inputmode="numeric" pattern="[0-9]*" placeholder="6-digits 2FA code" t-model="state.totp" />
<Button color="'compassion'" class="'w-full mb-2'" size="'sm'">Login</Button>
<div class="flex justify-between mt-2">
<div class="flex justify-left items-center">
<input id="2fa-checkbox" t-model="state.use2FA" type="checkbox" value="" class="w-4 h-4 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"/>
<label for="2fa-checkbox" class="ms-2 ml-2 text-sm select-none font-medium text-compassion">Use 2FA</label>
</div>
<a href="#" class="text-sm font-medium text-compassion hover:text-slate-900 transition-colors" t-on-click="() => state.settingsModal = true">Switch language</a>
</div>
</form>
</div>
<img t-att-src="webPath('/splash.jpg')" class="object-cover w-128 shadow-inner" />
<img t-att-src="webPath('/splash.jpg')" class="object-cover w-128 shadow-inner hidden md:block" />
<Transition active="state.loading" t-slot-scope="scope">
<div class="absolute top-0 left-0 bg-white-20 w-full h-full flex items-center justify-center" t-att-class="scope.itemClass">
<div class="bg-white p-10 shadow-2xl rounded-sm">
Expand All @@ -66,8 +71,10 @@ class Login extends Component {
state = useState({
username: '',
password: '',
totp: '',
loading: false,
settingsModal: false,
use2FA: false,
showPassword: false,
});

Expand All @@ -78,12 +85,29 @@ class Login extends Component {
SettingsModal,
};

inputField2FA = useRef('input-2fa');

setup(): void {
// Autofocus 2FA field when it appears.
useEffect(el => el?.focus(),
() => [this.inputField2FA.el])
}

async login() {
this.state.loading = true;
const { username, password } = this.state;
const res = await OdooAPI.authenticate(username, password)
if (!res) {
notyf.error(_('Failed to log in, incorrect credentials'));
const { username, password, totp } = this.state;
const res = await OdooAPI.authenticate(username, password, totp)
if (res !== true) {
if (res?.name?.endsWith?.('InvalidTotp') && this.state.use2FA === false) {
this.state.use2FA = true;
notyf.error(_('This account requires 2FA'));
}
else if (res?.message?.startsWith('Too many login failures')) {
notyf.error(_('Too many login attempts. Please, retry later'));
}
else {
notyf.error(_('Failed to log in, incorrect credentials'));
}
this.state.loading = false;
} else {
// Provide user to all next components, better UI, minimize number of loaders
Expand Down
Loading