add logout error handling

This commit is contained in:
Nemo Godebski-Pedersen 2025-04-09 15:47:43 +01:00
parent ba0fe64f02
commit e95a172235
6 changed files with 46 additions and 223 deletions

2
components.d.ts vendored
View File

@ -39,6 +39,8 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
GlossarySearchNav: typeof import('./src/components/GlossarySearchNav.vue')['default']

View File

@ -1,212 +0,0 @@
/*
* Open Bank Project - API Explorer II
* Copyright (C) 2023-2024, TESOBE GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Email: contact@tesobe.com
* TESOBE GmbH
* Osloerstrasse 16/17
* Berlin 13359, Germany
*
* This product includes software developed at
* TESOBE (http://www.tesobe.com/)
*
*/
import { Controller, Session, Req, Res, Post } from 'routing-controllers'
import { Request, Response } from 'express'
import OBPClientService from '../services/OBPClientService'
import { Service } from 'typedi'
import * as fs from 'fs'
import * as jwt from 'jsonwebtoken'
@Service()
@Controller('/opey-old')
/**
* Controller class for handling Opey related operations.
* This used to hold the /chat endpoint, but that endpoint has become obsolete since using websockets.
* Now it serves to get tokens to authenticate the user at websocket handshake.
* This is called from the frontend when ChatWidget.vue is mounted. (It is done at the backend to keep the private key secret)
*/
export class OpeyController {
constructor(
private obpClientService: OBPClientService,
) {}
@Post('/consent')
/**
* Retrieves a consent from OBP for the current user
*/
async getConsent(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
try {
console.log("Getting consent from OBP")
// Check if consent is already in session
if (session['obpConsent']) {
console.log("Consent found in session, returning cached consent ID")
const obpConsent = session['obpConsent']
// NOTE: Arguably we should not return the consent to the frontend as it could be hijacked,
// we can keep everything in the backend and only return the JWT token
return response.status(200).json({consent_id: obpConsent.consent_id});
}
const oauthConfig = session['clientConfig']
const version = this.obpClientService.getOBPVersion()
// Obbiously this should not be hard-coded, especially the consumer_id, but for now it is
const consentRequestBody = {
"everything": false,
"views": [],
"entitlements": [],
"consumer_id": "33e0a1bd-9f1d-4128-911b-8936110f802f"
}
// Get current user, only proceed if user is logged in
const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
const currentResponseKeys = Object.keys(currentUser)
if (!currentResponseKeys.includes('user_id')) {
return response.status(400).json({ message: 'User not logged in, Authentication required' });
}
// url needs to be changed once we get the 'bankless' consent endpoint
// this creates a consent for the current logged in user, and starts SCA flow i.e. sends SMS or email OTP to user
const consent = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/my/consents/IMPLICIT`, consentRequestBody, oauthConfig)
console.log("Consent: ", consent)
// store consent in session, return consent 200 OK
session['obpConsent'] = consent
return response.status(200).json({consent_id: consent.consent_id});
} catch (error) {
console.error("Error in consent endpoint: ", error);
return response.status(500).json({ error: 'Internal Server Error '});
}
}
@Post('/consent/answer-challenge')
/**
* Endpoint to answer the consent challenge with code i.e. SMS or email OTP for SCA
* If successful, returns a Consent-JWT for use by Opey to access endpoints/ roles that the consenting user has
* This completes (i.e. is the final step in) the consent flow
*/
async answerConsentChallenge(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
try {
const oauthConfig = session['clientConfig']
const version = this.obpClientService.getOBPVersion()
const obpConsent = session['obpConsent']
if (!obpConsent) {
return response.status(400).json({ message: 'Consent not found in session' });
} else if (obpConsent.status === 'ACCEPTED') {
return response.status(400).json({ message: 'Consent already accepted' });
}
const answerBody = request.body
const consentJWT = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/consents/${obpConsent.consent_id}/challenge`, answerBody, oauthConfig)
console.log("Consent JWT: ", consentJWT)
// store consent JWT in session, return consent JWT 200 OK
session['obpConsentJWT'] = consentJWT
return response.status(200).json(true);
} catch (error) {
console.error("Error in consent/answer-challenge endpoint: ", error);
return response.status(500).json({ error: 'Internal Server Error' });
}
}
@Post('/token')
/**
* Retrieves a JWT token for the current user.
* This only works if the user is logged in. (i.e. the user has a valid session)
* Request for the token is made to POST /api/opey/token
*
* @param session - The session object.
* @param request - The request object.
* @param response - The response object.
* @returns The response containing the JWT token or an error message.
*
*/
async getToken(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
try {
// Get current user
const oauthConfig = session['clientConfig']
const version = this.obpClientService.getOBPVersion()
const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
const currentResponseKeys = Object.keys(currentUser)
// If current user is logged in, issue JWT signed with private key
if (currentResponseKeys.includes('user_id')) {
// sign
const jwtToken = this.generateJWT(currentUser.user_id, currentUser.username, session)
return response.status(200).json({ token: jwtToken });
} else {
return response.status(400).json({ message: 'User not logged in, Authentication required' });
}
} catch (error) {
console.error("Error in token endpoint: ", error);
return response.status(500).json({ error: 'Internal Server Error' });
}
}
/**
* Generates a JSON Web Token (JWT) for the given Open Bank Project (OBP) user.
* @param obpUserId - The ID of the OBP user.
* @param obpUsername - The username of the OBP user.
* @param session - The session object.
* @returns The generated JWT.
*/
generateJWT(obpUserId: string, obpUsername: string, session: typeof Session): string {
// Retrieve secret key
let privateKey: string;
if (session['opeyToken']) {
console.log("Returning cached token");
return session['opeyToken'];
}
// Read private key from file
// Private key must be in the server/cert directory, this is pretty janky at the moment and should be improved
// Opey must also have a copy of the public key to verify the JWT
try {
privateKey = fs.readFileSync('./server/cert/private_key.pem', {encoding: 'utf-8'});
} catch (error) {
console.error("Error reading private key: ", error);
return '';
}
// Allows some user data to be passed in the JWT (this could be the obp consent in the future)
const payload = {
user_id: obpUserId,
username: obpUsername,
exp: Math.floor(Date.now() / 1000) + (60 * 60),
};
console.log("Generating new token for Opey");
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
session['opeyToken'] = token;
return token
}
}

View File

@ -46,6 +46,10 @@ export class OpeyController {
@Res() response: Response,
): Promise<Response> {
if (!session) {
console.error("Session not found")
return response.status(401).json({ error: 'Session Time Out' })
}
// Check if the consent is in the session, and can be added to the headers
const opeyConfig = session['opeyConfig']
if (!opeyConfig) {

View File

@ -158,6 +158,13 @@ export default {
background-color: #3e4e70;
}
.assistant .error {
color: red;
font-weight: bold;
align-self: flex-start;
font-size: smaller;
}
.content {
margin-top: -10px;
margin-bottom: -10px;

View File

@ -67,18 +67,21 @@ export default {
<div class="tool-message-container" v-bind:class="expanded? 'expanded':''">
<div class="tool-message-header">
<div class="status" v-bind:class="status">
<div v-if="status === 'pending'">
<el-icon class="is-loading" color="#20cbeb"><RefreshRight /></el-icon>
<div class="tool-name">Tool Call: {{ name }}</div>
<div class="right-aligned">
<div class="status" v-bind:class="status">
<div v-if="status === 'pending'">
<el-icon class="is-loading" color="#20cbeb"><RefreshRight /></el-icon>
</div>
<div v-else-if="status === 'success'">
<el-icon color="#00ff18"><Check /></el-icon>
</div>
</div>
<div v-else-if="status === 'success'">
<el-icon color="#00ff18"><Check /></el-icon>
<div class="expand-icon" @click="toggleExpanded">
<el-icon><ArrowDown v-if="!expanded" /><ArrowUp v-else /></el-icon>
</div>
</div>
<div class="tool-name">{{ name }}</div>
<div class="expand-icon" @click="toggleExpanded">
<el-icon><ArrowDown v-if="!expanded" /><ArrowUp v-else /></el-icon>
</div>
</div>
@ -179,16 +182,26 @@ export default {
overflow: auto;
}
.status {
margin-left: auto;
margin-right: 20px;
}
.expand-icon {
margin-left: auto;
cursor: pointer;
}
.right-aligned {
display: flex;
flex-direction: row;
align-items: center;
}
.tool-message-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
}

View File

@ -202,6 +202,10 @@ export const useChat = defineStore('chat', {
}
if (response.status !== 200) {
switch (response.status) {
case 401:
throw new Error('Unauthorized. Please log in again.');
}
throw new Error(`Error sending Opey message: ${response.statusText}`);
}
@ -209,7 +213,12 @@ export const useChat = defineStore('chat', {
} catch (error) {
console.error('Error sending Opey message:', error);
const errorMessage = "Hmmm, Looks like smething went wrong. Please try again later.";
let errorMessage = "Hmmm, Looks like smething went wrong. Please try again later.";
switch (error) {
case 'Unauthorized. Please log in again.':
errorMessage = 'You are not logged in. Please log in to continue.';
}
// Apply error state to the assistant message
await this.applyErrorToMessage(this.currentAssistantMessage.id, errorMessage);