From 6e13f5d8ab558c6e0379b2583d74a1c1fb4f6624 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Mon, 13 Oct 2025 12:53:04 +0200 Subject: [PATCH] finish refactor authentication with states --- .../src/federation/authenticateCommunities.ts | 2 +- .../logic/interpretEncryptedTransferArgs.ts | 16 ++--- core/src/util/utilities.ts | 5 ++ .../src/enum/CommunityHandshakeStateType.ts | 1 + database/src/queries/communityHandshakes.ts | 12 +++- .../1_0/resolver/AuthenticationResolver.ts | 69 ++++++++++++------- .../api/1_0/util/authenticateCommunity.ts | 51 ++++++++++---- shared/src/jwt/JWT.ts | 10 --- 8 files changed, 110 insertions(+), 56 deletions(-) diff --git a/backend/src/federation/authenticateCommunities.ts b/backend/src/federation/authenticateCommunities.ts index d71314efa..78fea419f 100644 --- a/backend/src/federation/authenticateCommunities.ts +++ b/backend/src/federation/authenticateCommunities.ts @@ -100,7 +100,7 @@ export async function startCommunityAuthentication( methodLogger.error(errorMsg) state.status = CommunityHandshakeStateType.FAILED state.lastError = errorMsg - await state.save() } + await state.save() } } diff --git a/core/src/graphql/logic/interpretEncryptedTransferArgs.ts b/core/src/graphql/logic/interpretEncryptedTransferArgs.ts index 301f6da16..31bf47a56 100644 --- a/core/src/graphql/logic/interpretEncryptedTransferArgs.ts +++ b/core/src/graphql/logic/interpretEncryptedTransferArgs.ts @@ -6,37 +6,35 @@ import { CommunityLoggingView, getHomeCommunity } from 'database' import { verifyAndDecrypt } from 'shared' import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const' -const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.logic.interpretEncryptedTransferArgs`) +const createLogger = (functionName: string) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.logic.interpretEncryptedTransferArgs.${functionName}`) export const interpretEncryptedTransferArgs = async (args: EncryptedTransferArgs): Promise => { - const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.logic.interpretEncryptedTransferArgs-method`) + const methodLogger = createLogger('interpretEncryptedTransferArgs') methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.debug('interpretEncryptedTransferArgs()... args:', args) // first find with args.publicKey the community 'requestingCom', which starts the request + // TODO: maybe use community from caller instead of loading it separately const requestingCom = await DbCommunity.findOneBy({ publicKey: Buffer.from(args.publicKey, 'hex') }) if (!requestingCom) { - const errmsg = `unknown requesting community with publicKey ${Buffer.from(args.publicKey, 'hex')}` + const errmsg = `unknown requesting community with publicKey ${args.publicKey}` methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') throw new Error(errmsg) } if (!requestingCom.publicJwtKey) { - const errmsg = `missing publicJwtKey of requesting community with publicKey ${Buffer.from(args.publicKey, 'hex')}` + const errmsg = `missing publicJwtKey of requesting community with publicKey ${args.publicKey}` methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') throw new Error(errmsg) } methodLogger.debug(`found requestingCom:`, new CommunityLoggingView(requestingCom)) // verify the signing of args.jwt with homeCom.privateJwtKey and decrypt args.jwt with requestingCom.publicJwtKey + // TODO: maybe use community from caller instead of loading it separately const homeCom = await getHomeCommunity() const jwtPayload = await verifyAndDecrypt(args.handshakeID, args.jwt, homeCom!.privateJwtKey!, requestingCom.publicJwtKey) as JwtPayloadType if (!jwtPayload) { - const errmsg = `invalid payload of community with publicKey ${Buffer.from(args.publicKey, 'hex')}` + const errmsg = `invalid payload of community with publicKey ${args.publicKey}` methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') throw new Error(errmsg) } methodLogger.debug('jwtPayload', jwtPayload) - methodLogger.removeContext('handshakeID') return jwtPayload } diff --git a/core/src/util/utilities.ts b/core/src/util/utilities.ts index 0e8a8da85..be32736fc 100644 --- a/core/src/util/utilities.ts +++ b/core/src/util/utilities.ts @@ -36,6 +36,11 @@ export const delay = promisify(setTimeout) export const ensureUrlEndsWithSlash = (url: string): string => { return url.endsWith('/') ? url : url.concat('/') } +export function splitUrlInEndPointAndApiVersion(url: string): { endPoint: string, apiVersion: string } { + const endPoint = url.slice(0, url.lastIndexOf('/') + 1) + const apiVersion = url.slice(url.lastIndexOf('/') + 1, url.length) + return { endPoint, apiVersion } +} /** * Calculates the date representing the first day of the month, a specified number of months prior to a given date. * diff --git a/database/src/enum/CommunityHandshakeStateType.ts b/database/src/enum/CommunityHandshakeStateType.ts index 5378974aa..8b811da61 100644 --- a/database/src/enum/CommunityHandshakeStateType.ts +++ b/database/src/enum/CommunityHandshakeStateType.ts @@ -1,6 +1,7 @@ export enum CommunityHandshakeStateType { START_COMMUNITY_AUTHENTICATION = 'START_COMMUNITY_AUTHENTICATION', START_OPEN_CONNECTION_CALLBACK = 'START_OPEN_CONNECTION_CALLBACK', + START_AUTHENTICATION = 'START_AUTHENTICATION', OPEN_CONNECTION_CALLBACK = 'OPEN_CONNECTION_CALLBACK', SUCCESS = 'SUCCESS', diff --git a/database/src/queries/communityHandshakes.ts b/database/src/queries/communityHandshakes.ts index 3902fb26b..f8fa2ba89 100644 --- a/database/src/queries/communityHandshakes.ts +++ b/database/src/queries/communityHandshakes.ts @@ -22,4 +22,14 @@ export function findPendingCommunityHandshake( }, relations: withRelations ? { federatedCommunity: { community: true } } : undefined, }) -} \ No newline at end of file +} + +export function findPendingCommunityHandshakeOrFailByOneTimeCode( + oneTimeCode: number +): Promise { + return CommunityHandshakeState.findOneOrFail({ + where: { oneTimeCode }, + relations: { federatedCommunity: { community: true } }, + }) +} + \ No newline at end of file diff --git a/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts b/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts index 74f75239e..40b5e2c18 100644 --- a/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts +++ b/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts @@ -1,12 +1,17 @@ import { CONFIG } from '@/config' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' -import { EncryptedTransferArgs, interpretEncryptedTransferArgs } from 'core' +import { CommunityHandshakeStateLogic, EncryptedTransferArgs, interpretEncryptedTransferArgs, splitUrlInEndPointAndApiVersion } from 'core' import { CommunityLoggingView, + CommunityHandshakeStateLoggingView, + CommunityHandshakeState as DbCommunityHandshakeState, + CommunityHandshakeStateType, Community as DbCommunity, FederatedCommunity as DbFedCommunity, FederatedCommunityLoggingView, getHomeCommunity, + findPendingCommunityHandshake, + findPendingCommunityHandshakeOrFailByOneTimeCode, } from 'database' import { getLogger } from 'log4js' import { @@ -93,9 +98,7 @@ export class AuthenticationResolver { // no infos to the caller return true } - - const endPoint = openConnectionCallbackJwtPayload.url.slice(0, openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1) - const apiVersion = openConnectionCallbackJwtPayload.url.slice(openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1, openConnectionCallbackJwtPayload.url.length) + const { endPoint, apiVersion } = splitUrlInEndPointAndApiVersion(openConnectionCallbackJwtPayload.url) methodLogger.debug(`search fedComB per:`, endPoint, apiVersion) const fedComB = await DbFedCommunity.findOneBy({ endPoint, apiVersion }) if (!fedComB) { @@ -126,45 +129,47 @@ export class AuthenticationResolver { const methodLogger = createLogger('authenticate') methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.debug(`authenticate() via apiVersion=1_0 ...`, args) + let state: DbCommunityHandshakeState | null = null + let stateSaveResolver: Promise | undefined = undefined try { const authArgs = await interpretEncryptedTransferArgs(args) as AuthenticationJwtPayloadType methodLogger.debug(`interpreted authentication payload...authArgs:`, authArgs) if (!authArgs) { - const errmsg = `invalid authentication payload of requesting community with publicKey` + args.publicKey - methodLogger.error(errmsg) - // no infos to the caller - return null + throw new Error(`invalid authentication payload of requesting community with publicKey ${args.publicKey}`) } if (!uint32Schema.safeParse(Number(authArgs.oneTimeCode)).success) { - const errmsg = `invalid oneTimeCode: ${authArgs.oneTimeCode} for community with publicKey ${authArgs.publicKey}, expect uint32` - methodLogger.error(errmsg) - // no infos to the caller - return null + throw new Error( + `invalid oneTimeCode: ${authArgs.oneTimeCode} for community with publicKey ${authArgs.publicKey}, expect uint32` + ) } + state = await findPendingCommunityHandshakeOrFailByOneTimeCode(Number(authArgs.oneTimeCode)) + const stateLogic = new CommunityHandshakeStateLogic(state) + if (await stateLogic.isTimeoutUpdate() || state.status !== CommunityHandshakeStateType.START_OPEN_CONNECTION_CALLBACK) { + throw new Error('No valid pending community handshake found') + } + state.status = CommunityHandshakeStateType.SUCCESS + stateSaveResolver = state.save() + methodLogger.debug(`search community per oneTimeCode:`, authArgs.oneTimeCode) - const authCom = await DbCommunity.findOneByOrFail({ communityUuid: authArgs.oneTimeCode }) + const authCom = state.federatedCommunity.community if (authCom) { methodLogger.debug('found authCom:', new CommunityLoggingView(authCom)) methodLogger.debug('authCom.publicKey', authCom.publicKey.toString('hex')) methodLogger.debug('args.publicKey', args.publicKey) if (authCom.publicKey.compare(Buffer.from(args.publicKey, 'hex')) !== 0) { - const errmsg = `corrupt authentication call detected, oneTimeCode: ${authArgs.oneTimeCode} doesn't belong to caller: ${args.publicKey}` - methodLogger.error(errmsg) - // no infos to the caller - return null + throw new Error( + `corrupt authentication call detected, oneTimeCode: ${authArgs.oneTimeCode} doesn't belong to caller: ${args.publicKey}` + ) } const communityUuid = uuidv4Schema.safeParse(authArgs.uuid) if (!communityUuid.success) { - const errmsg = `invalid uuid: ${authArgs.uuid} for community with publicKey ${authCom.publicKey}` - methodLogger.error(errmsg) - // no infos to the caller - return null + throw new Error(`invalid uuid: ${authArgs.uuid} for community with publicKey ${authCom.publicKey}`) } authCom.communityUuid = communityUuid.data authCom.authenticatedAt = new Date() - await DbCommunity.save(authCom) + await authCom.save() methodLogger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom)) const homeComB = await getHomeCommunity() if (homeComB?.communityUuid) { @@ -175,8 +180,26 @@ export class AuthenticationResolver { } return null } catch (err) { - methodLogger.error('invalid jwt token:', err) + let errorString = '' + if (err instanceof Error) { + errorString = err.message + } else { + errorString = String(err) + } + if (state) { + methodLogger.info(`state: ${new CommunityHandshakeStateLoggingView(state)}`) + state.status = CommunityHandshakeStateType.FAILED + state.lastError = errorString + stateSaveResolver = state.save() + } + methodLogger.error(`failed: ${errorString}`) + // no infos to the caller return null + } finally { + if (stateSaveResolver) { + await stateSaveResolver + } } + } } diff --git a/federation/src/graphql/api/1_0/util/authenticateCommunity.ts b/federation/src/graphql/api/1_0/util/authenticateCommunity.ts index 3ffd4b3d5..d901a84c3 100644 --- a/federation/src/graphql/api/1_0/util/authenticateCommunity.ts +++ b/federation/src/graphql/api/1_0/util/authenticateCommunity.ts @@ -132,12 +132,14 @@ export async function startAuthentication( oneTimeCode: string, fedComB: DbFedCommunity, ): Promise { - const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.startAuthentication`) + const methodLogger = createLogger('startAuthentication') methodLogger.addContext('handshakeID', handshakeID) methodLogger.debug(`startAuthentication()...`, { oneTimeCode, fedComB: new FederatedCommunityLoggingView(fedComB), }) + let state: DbCommunityHandshakeState | null = null + let stateSaveResolver: Promise | undefined = undefined try { const homeComA = await getHomeCommunity() const comB = await DbCommunity.findOneByOrFail({ @@ -147,6 +149,17 @@ export async function startAuthentication( if (!comB.publicJwtKey) { throw new Error('Public JWT key still not exist for foreign community') } + state = await findPendingCommunityHandshake(fedComB.publicKey, fedComB.apiVersion, false) + if (!state) { + throw new Error('No pending community handshake found') + } + const stateLogic = new CommunityHandshakeStateLogic(state) + if (await stateLogic.isTimeoutUpdate() || state.status !== CommunityHandshakeStateType.START_COMMUNITY_AUTHENTICATION) { + methodLogger.debug('invalid state', new CommunityHandshakeStateLoggingView(state)) + throw new Error('No valid pending community handshake found') + } + state.status = CommunityHandshakeStateType.START_AUTHENTICATION + stateSaveResolver = state.save() const client = AuthenticationClientFactory.getInstance(fedComB) @@ -161,6 +174,7 @@ export async function startAuthentication( methodLogger.debug(`invoke authenticate() with:`, args) const responseJwt = await client.authenticate(args) methodLogger.debug(`response of authenticate():`, responseJwt) + if (responseJwt !== null) { const payload = await verifyAndDecrypt(handshakeID, responseJwt, homeComA!.privateJwtKey!, comB.publicJwtKey!) as AuthenticationResponseJwtPayloadType methodLogger.debug( @@ -169,27 +183,40 @@ export async function startAuthentication( new FederatedCommunityLoggingView(fedComB), ) if (payload.tokentype !== AuthenticationResponseJwtPayloadType.AUTHENTICATION_RESPONSE_TYPE) { - const errmsg = `Invalid tokentype in authenticate-response of community with publicKey` + comB.publicKey - methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') - throw new Error(errmsg) + throw new Error(`Invalid tokentype in authenticate-response of community with publicKey ${comB.publicKey}`) } if (!payload.uuid || !validateUUID(payload.uuid) || versionUUID(payload.uuid) !== 4) { - const errmsg = `Invalid uuid in authenticate-response of community with publicKey` + comB.publicKey - methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') - throw new Error(errmsg) + throw new Error(`Invalid uuid in authenticate-response of community with publicKey ${comB.publicKey}`) } comB.communityUuid = payload.uuid comB.authenticatedAt = new Date() - await DbCommunity.save(comB) + await DbCommunity.save(comB) + state.status = CommunityHandshakeStateType.SUCCESS + stateSaveResolver = state.save() methodLogger.debug('Community Authentication successful:', new CommunityLoggingView(comB)) } else { + state.status = CommunityHandshakeStateType.FAILED + state.lastError = 'Community Authentication failed, empty response' + stateSaveResolver = state.save() methodLogger.error('Community Authentication failed:', authenticationArgs) } } } catch (err) { - methodLogger.error('error in startAuthentication:', err) + let errorString: string = '' + if (err instanceof Error) { + errorString = err.message + } else { + errorString = String(err) + } + if (state) { + state.status = CommunityHandshakeStateType.FAILED + state.lastError = errorString + stateSaveResolver = state.save() + } + methodLogger.error('error in startAuthentication:', errorString) + } finally { + if (stateSaveResolver) { + await stateSaveResolver + } } - methodLogger.removeContext('handshakeID') } diff --git a/shared/src/jwt/JWT.ts b/shared/src/jwt/JWT.ts index 1af50f5bd..7c8fd799d 100644 --- a/shared/src/jwt/JWT.ts +++ b/shared/src/jwt/JWT.ts @@ -43,11 +43,9 @@ export const verify = async (handshakeID: string, token: string, publicKey: stri }) payload.handshakeID = handshakeID methodLogger.debug('verify after jwtVerify... payload=', payload) - methodLogger.removeContext('handshakeID') return payload as JwtPayloadType } catch (err) { methodLogger.error('verify after jwtVerify... error=', err) - methodLogger.removeContext('handshakeID') return null } } @@ -74,11 +72,9 @@ export const encode = async (payload: JwtPayloadType, privatekey: string): Promi .setExpirationTime(payload.expiration) .sign(secret) methodLogger.debug('encode... token=', token) - methodLogger.removeContext('handshakeID') return token } catch (e) { methodLogger.error('Failed to sign JWT:', e) - methodLogger.removeContext('handshakeID') throw e } } @@ -111,11 +107,9 @@ export const encrypt = async (payload: JwtPayloadType, publicKey: string): Promi .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) .encrypt(recipientKey) methodLogger.debug('encrypt... jwe=', jwe) - methodLogger.removeContext('handshakeID') return jwe.toString() } catch (e) { methodLogger.error('Failed to encrypt JWT:', e) - methodLogger.removeContext('handshakeID') throw e } } @@ -131,11 +125,9 @@ export const decrypt = async(handshakeID: string, jwe: string, privateKey: strin await compactDecrypt(jwe, decryptKey) methodLogger.debug('decrypt... plaintext=', plaintext) methodLogger.debug('decrypt... protectedHeader=', protectedHeader) - methodLogger.removeContext('handshakeID') return new TextDecoder().decode(plaintext) } catch (e) { methodLogger.error('Failed to decrypt JWT:', e) - methodLogger.removeContext('handshakeID') throw e } } @@ -147,7 +139,6 @@ export const encryptAndSign = async (payload: JwtPayloadType, privateKey: string methodLogger.debug('encryptAndSign... jwe=', jwe) const jws = await encode(new EncryptedJWEJwtPayloadType(payload.handshakeID, jwe), privateKey) methodLogger.debug('encryptAndSign... jws=', jws) - methodLogger.removeContext('handshakeID') return jws } @@ -171,6 +162,5 @@ export const verifyAndDecrypt = async (handshakeID: string, token: string, priva methodLogger.debug('verifyAndDecrypt... jwe=', jwe) const payload = await decrypt(handshakeID, jwe as string, privateKey) methodLogger.debug('verifyAndDecrypt... payload=', payload) - methodLogger.removeContext('handshakeID') return JSON.parse(payload) as JwtPayloadType }