first draft community authentication handshake

This commit is contained in:
Claus-Peter Huebner 2023-10-23 22:50:24 +02:00
parent 841979a360
commit 81bf608e2b
16 changed files with 113 additions and 188 deletions

View File

@ -20,7 +20,7 @@ export async function startCommunityAuthentication(
const foreignCom = await DbCommunity.findOneByOrFail({ publicKey: foreignFedCom.publicKey })
if (foreignCom && foreignCom.communityUuid === null && foreignCom.authenticatedAt === null) {
try {
const client = AuthenticationClientFactory.getInstance(homeFedCom)
const client = AuthenticationClientFactory.getInstance(foreignFedCom)
// eslint-disable-next-line camelcase
if (client instanceof V1_0_AuthenticationClient) {
const args = new OpenConnectionArgs()

View File

@ -59,7 +59,7 @@ export async function validateCommunities(): Promise<void> {
const pubComInfo = await client.getPublicCommunityInfo()
if (pubComInfo) {
await writeForeignCommunity(dbCom, pubComInfo)
void startCommunityAuthentication(dbCom)
await startCommunityAuthentication(dbCom)
logger.debug(`Federation: write publicInfo of community: name=${pubComInfo.name}`)
} else {
logger.warn('Federation: missing result of getPublicCommunityInfo')

View File

@ -54,6 +54,7 @@
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-security": "^1.7.1",
"eslint-plugin-type-graphql": "^1.0.0",
"graphql-tag": "2.12.6",
"jest": "^27.2.4",
"nodemon": "^2.0.7",
"prettier": "^2.3.1",

View File

@ -7,7 +7,6 @@ import { openConnectionCallback } from './query/openConnectionCallback'
import { AuthenticationArgs } from '@/graphql/api/1_0/model/AuthenticationArgs'
import { authenticate } from './query/authenticate'
export class AuthenticationClient {
dbCom: DbFederatedCommunity
endpoint: string
@ -27,12 +26,13 @@ export class AuthenticationClient {
})
}
async openConnectionCallback(args: OpenConnectionCallbackArgs): Promise<boolean | undefined> {
async openConnectionCallback(args: OpenConnectionCallbackArgs): Promise<boolean> {
logger.debug('Authentication: openConnectionCallback with endpoint', this.endpoint, args)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { data } = await this.client.rawRequest(openConnectionCallback, { args })
if (!data?.openConnectionCallback) {
const { data } = await this.client.rawRequest<any>(openConnectionCallback, { args })
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (data && data.openConnectionCallback) {
logger.warn(
'Authentication: openConnectionCallback without response data from endpoint',
this.endpoint,
@ -47,24 +47,24 @@ export class AuthenticationClient {
} catch (err) {
logger.error('Authentication: error on openConnectionCallback', err)
}
return false
}
async authenticate(args: AuthenticationArgs): Promise<string | undefined> {
async authenticate(args: AuthenticationArgs): Promise<string | null> {
logger.debug('Authentication: authenticate with endpoint=', this.endpoint)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { data } = await this.client.rawRequest(authenticate, {})
const { data } = await this.client.rawRequest<any>(authenticate, { args })
logger.debug('Authentication: after authenticate: data:', data)
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!data?.authenticate) {
logger.warn(
'Authentication: authenticate without response data from endpoint',
this.endpoint,
)
return
const authUuid: string = data?.authenticate.uuid
if (authUuid) {
logger.debug('Authentication: received authenticated uuid', authUuid)
return authUuid
}
const
} catch (err) {
logger.error('Authentication: authenticate failed for endpoint', this.endpoint)
}
return null
}
}

View File

@ -2,6 +2,8 @@ import { gql } from 'graphql-request'
export const authenticate = gql`
mutation ($args: AuthenticateArgs!) {
authenticate(data: $args)
authenticate(data: $args) {
uuid
}
}
`

View File

@ -1,6 +1,6 @@
import { ArgsType, Field } from 'type-graphql'
import { Field, InputType } from 'type-graphql'
@ArgsType()
@InputType()
export class AuthenticationArgs {
@Field(() => String)
oneTimeCode: string

View File

@ -1,6 +1,6 @@
import { ArgsType, Field } from 'type-graphql'
import { Field, InputType } from 'type-graphql'
@ArgsType()
@InputType()
export class OpenConnectionArgs {
@Field(() => String)
publicKey: string

View File

@ -1,13 +1,10 @@
import { ArgsType, Field } from 'type-graphql'
import { Field, InputType } from 'type-graphql'
@ArgsType()
@InputType()
export class OpenConnectionCallbackArgs {
@Field(() => String)
oneTimeCode: string
@Field(() => String)
publicKey: string
@Field(() => String)
url: string
}

View File

@ -2,14 +2,13 @@
import { Arg, Mutation, Resolver } from 'type-graphql'
import { federationLogger as logger } from '@/server/logger'
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFedCommunity } from '@entity/FederatedCommunity'
import { LogError } from '@/server/LogError'
import { OpenConnectionArgs } from '../model/OpenConnectionArgs'
import {
startOpenConnectionCallback,
startOpenConnectionRedirect,
} from '../util/authenticateCommunity'
import { startAuthentication, startOpenConnectionCallback } from '../util/authenticateCommunity'
import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs'
import { ApiVersionType } from '@/client/enum/apiVersionType'
import { CONFIG } from '@/config'
import { AuthenticationArgs } from '../model/AuthenticationArgs'
@Resolver()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -28,7 +27,8 @@ export class AuthenticationResolver {
if (!requestedCom) {
throw new LogError(`unknown requesting community with publicKey`, args.publicKey)
}
void startOpenConnectionRedirect(args, requestedCom, ApiVersionType.V1_0)
// no await to respond immediatly and invoke callback-request asynchron
void startOpenConnectionCallback(args, requestedCom, CONFIG.FEDERATION_API)
return true
}
@ -38,14 +38,37 @@ export class AuthenticationResolver {
args: OpenConnectionCallbackArgs,
): Promise<boolean> {
logger.debug(`Authentication: openConnectionCallback() via apiVersion=1_0 ...`, args)
// first find with args.publicKey the community, which invokes openConnectionCallback
const callbackCom = await DbCommunity.findOneBy({
publicKey: Buffer.from(args.publicKey),
})
if (!callbackCom) {
throw new LogError(`unknown callback community with publicKey`, args.publicKey)
// TODO decrypt args.url with homeCom.privateKey and verify signing with callbackFedCom.publicKey
const endPoint = args.url.slice(0, args.url.lastIndexOf('/'))
const apiVersion = args.url.slice(args.url.lastIndexOf('/'), args.url.length)
const callbackFedCom = await DbFedCommunity.findOneBy({ endPoint, apiVersion })
if (!callbackFedCom) {
throw new LogError(`unknown callback community with url`, args.url)
}
void startOpenConnectionCallback(args, callbackCom)
// no await to respond immediatly and invoke authenticate-request asynchron
void startAuthentication(args.oneTimeCode, callbackFedCom)
return true
}
@Mutation(() => String)
async authenticate(
@Arg('data')
args: AuthenticationArgs,
): Promise<string | null> {
logger.debug(`Authentication: authenticate() via apiVersion=1_0 ...`, args)
const authCom = await DbCommunity.findOneByOrFail({ communityUuid: args.oneTimeCode })
logger.debug('Authentication: found authCom:', authCom)
if (authCom) {
// TODO decrypt args.uuid with authCom.publicKey
authCom.communityUuid = args.uuid
await DbCommunity.save(authCom)
logger.debug('Authentication: store authCom.uuid successfully:', authCom)
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
// TODO encrypt homeCom.uuid with homeCom.privateKey
if (homeCom.communityUuid) {
return homeCom.communityUuid
}
}
return null
}
}

View File

@ -1,19 +1,19 @@
import { OpenConnectionArgs } from '../model/OpenConnectionArgs'
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { FederatedCommunity as DbFedCommunity } from '@entity/FederatedCommunity'
import { federationLogger as logger } from '@/server/logger'
import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs'
// eslint-disable-next-line camelcase
import { randombytes_random } from 'sodium-native'
import { AuthenticationClientFactory } from '@/client/AuthenticationClientFactory'
import { ApiVersionType } from '@/client/enum/apiVersionType'
// eslint-disable-next-line camelcase
import { AuthenticationClient as V1_0_AuthenticationClient } from '@/client/1_0/AuthenticationClient'
import { AuthenticationArgs } from '../model/AuthenticationArgs'
export async function startOpenConnectionRedirect(
export async function startOpenConnectionCallback(
args: OpenConnectionArgs,
requestedCom: DbCommunity,
api: ApiVersionType,
api: string,
): Promise<void> {
logger.debug(
`Authentication: startOpenConnectionRedirect()...`,
@ -22,14 +22,13 @@ export async function startOpenConnectionRedirect(
requestedCom,
)
try {
// TODO verify signing of args.url with requestedCom.publicKey and decrypt with homeCom.privateKey
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
const homeFedCom = await DbFederatedCommunity.findOneByOrFail({
const homeFedCom = await DbFedCommunity.findOneByOrFail({
foreign: false,
apiVersion: api,
})
const oneTimeCode = randombytes_random()
// store oneTimeCode in requestedCom.community_uuid for authenticate-request-identifier
// store oneTimeCode in requestedCom.community_uuid as authenticate-request-identifier
requestedCom.communityUuid = oneTimeCode.toString()
await DbCommunity.save(requestedCom)
@ -38,8 +37,7 @@ export async function startOpenConnectionRedirect(
if (client instanceof V1_0_AuthenticationClient) {
const callbackArgs = new OpenConnectionCallbackArgs()
callbackArgs.oneTimeCode = oneTimeCode.toString()
callbackArgs.publicKey = homeCom.publicKey.toString('hex')
// TODO signing of callbackArgs.url with requestedCom.publicKey and decrypt with homeCom.privateKey
// TODO encrypt callbackArgs.url with requestedCom.publicKey and sign it with homeCom.privateKey
callbackArgs.url = homeFedCom.endPoint.endsWith('/')
? homeFedCom.endPoint
: homeFedCom.endPoint + '/' + homeFedCom.apiVersion
@ -54,21 +52,45 @@ export async function startOpenConnectionRedirect(
}
}
export async function startOpenConnectionCallback(
args: OpenConnectionCallbackArgs,
callbackCom: DbCommunity,
export async function startAuthentication(
oneTimeCode: string,
callbackFedCom: DbFedCommunity,
): Promise<void> {
logger.debug(
`Authentication: startOpenConnectionCallback()...`,
args.publicKey,
args.url,
callbackCom,
)
logger.debug(`Authentication: startAuthentication()...`, oneTimeCode, callbackFedCom)
try {
// TODO verify signing of args.url with requestedCom.publicKey and decrypt with homeCom.privateKey
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
const homeFedCom = await DbFedCommunity.findOneByOrFail({
foreign: false,
apiVersion: callbackFedCom.apiVersion,
})
// TODO encrypt homeCom.uuid with homeCom.privateKey and sign it with callbackFedCom.publicKey
const client = AuthenticationClientFactory.getInstance(homeFedCom)
// eslint-disable-next-line camelcase
if (client instanceof V1_0_AuthenticationClient) {
const authenticationArgs = new AuthenticationArgs()
authenticationArgs.oneTimeCode = oneTimeCode
// TODO encrypt callbackArgs.url with requestedCom.publicKey and sign it with homeCom.privateKey
if (homeCom.communityUuid) {
authenticationArgs.uuid = homeCom.communityUuid
}
logger.debug(`Authentication: vor authenticate()...`, authenticationArgs)
const fedComUuid = await client.authenticate(authenticationArgs)
logger.debug(`Authentication: nach authenticate()...`, fedComUuid)
if (fedComUuid !== null) {
// TODO decrypt fedComUuid with callbackFedCom.publicKey
const callbackCom = await DbCommunity.findOneByOrFail({
foreign: true,
publicKey: callbackFedCom.publicKey,
})
callbackCom.communityUuid = fedComUuid
callbackCom.authenticatedAt = new Date()
await DbCommunity.save(callbackCom)
logger.debug('Authentication: Community Authentication successful:', callbackCom)
} else {
logger.error('Authentication: Community Authentication failed:', authenticationArgs)
}
}
} catch (err) {
logger.error('Authentication: error in startOpenConnectionCallback:', err)
}

View File

@ -11,6 +11,14 @@ const schema = async (): Promise<GraphQLSchema> => {
resolvers: [getApiResolvers()],
// authChecker: isAuthorized,
scalarsMap: [{ type: Decimal, scalar: DecimalScalar }],
validate: {
validationError: { target: false },
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: false,
forbidUnknownValues: true,
stopAtFirstError: true,
},
})
}

View File

@ -1,37 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-empty-interface */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Decimal } from 'decimal.js-light'
expect.extend({
decimalEqual(received, value) {
const pass = new Decimal(value).equals(received.toString())
if (pass) {
return {
message: () => `expected ${received} to not equal ${value}`,
pass: true,
}
} else {
return {
message: () => `expected ${received} to equal ${value}`,
pass: false,
}
}
},
})
interface CustomMatchers<R = unknown> {
decimalEqual(value: number): R
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Expect extends CustomMatchers {}
interface Matchers<R> extends CustomMatchers<R> {}
interface InverseAsymmetricMatchers extends CustomMatchers {}
}
}

View File

@ -1,7 +0,0 @@
import { contributionDateFormatter } from '@test/helpers'
describe('contributionDateFormatter', () => {
it('formats the date correctly', () => {
expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024')
})
})

View File

@ -1,62 +0,0 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { entities } from '@entity/index'
import { createTestClient } from 'apollo-server-testing'
import { createServer } from '@/server/createServer'
import { logger } from './testSetup'
export const headerPushMock = jest.fn((t) => {
context.token = t.value
})
const context = {
token: '',
setHeaders: {
push: headerPushMock,
forEach: jest.fn(),
},
clientTimezoneOffset: 0,
}
export const cleanDB = async () => {
// this only works as long we do not have foreign key constraints
for (const entity of entities) {
await resetEntity(entity)
}
}
export const testEnvironment = async (testLogger = logger) => {
const server = await createServer(testLogger) // context, testLogger, testI18n)
const con = server.con
const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate
const query = testClient.query
return { mutate, query, con }
}
export const resetEntity = async (entity: any) => {
const items = await entity.find({ withDeleted: true })
if (items.length > 0) {
const ids = items.map((e: any) => e.id)
await entity.delete(ids)
}
}
export const resetToken = () => {
context.token = ''
}
// format date string as it comes from the frontend for the contribution date
export const contributionDateFormatter = (date: Date): string => {
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
}
export const setClientTimezoneOffset = (offset: number): void => {
context.clientTimezoneOffset = offset
}

View File

@ -1,22 +0,0 @@
import { federationLogger as logger } from '@/server/logger'
jest.setTimeout(1000000)
jest.mock('@/server/logger', () => {
const originalModule = jest.requireActual('@/server/logger')
return {
__esModule: true,
...originalModule,
backendLogger: {
addContext: jest.fn(),
trace: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
},
}
})
export { logger }

View File

@ -3058,7 +3058,7 @@ graphql-subscriptions@^1.0.0, graphql-subscriptions@^1.1.0:
dependencies:
iterall "^1.3.0"
graphql-tag@^2.11.0:
graphql-tag@2.12.6, graphql-tag@^2.11.0:
version "2.12.6"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"
integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==