Skip to content

Commit

Permalink
Merge pull request #10260 from hicommonwealth/rotorsoft/10214-create-…
Browse files Browse the repository at this point in the history
…address

Refactors create and verify address flows
  • Loading branch information
Rotorsoft authored Dec 23, 2024
2 parents 38e4a58 + 7113a74 commit 8a1dd9e
Show file tree
Hide file tree
Showing 37 changed files with 1,120 additions and 1,111 deletions.
4 changes: 2 additions & 2 deletions libs/adapters/src/rabbitmq/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ export enum RascalRoutingKeys {
XpProjectionSignUpFlowCompleted = EventNames.SignUpFlowCompleted,
XpProjectionCommunityCreated = EventNames.CommunityCreated,
XpProjectionCommunityJoined = EventNames.CommunityJoined,
XpProjectionThreadCreated = `${EventNames.ThreadCreated}.${RoutingKeyTags.Contest}.#`,
XpProjectionThreadUpvoted = `${EventNames.ThreadUpvoted}.${RoutingKeyTags.Contest}.#`,
XpProjectionThreadCreated = `${EventNames.ThreadCreated}.#`,
XpProjectionThreadUpvoted = `${EventNames.ThreadUpvoted}.#`,
XpProjectionCommentCreated = EventNames.CommentCreated,
XpProjectionCommentUpvoted = EventNames.CommentUpvoted,
XpProjectionUserMentioned = EventNames.UserMentioned,
Expand Down
118 changes: 67 additions & 51 deletions libs/adapters/src/trpc/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ type Metadata<Input extends ZodSchema, Output extends ZodSchema> = {
readonly output: Output;
auth: unknown[];
secure?: boolean;
authStrategy?: AuthStrategies;
authStrategy?: AuthStrategies<Input>;
};

const isSecure = (md: Metadata<ZodSchema, ZodSchema>) =>
md.secure !== false || (md.auth ?? []).length > 0;
const isSecure = <Input extends ZodSchema, Output extends ZodSchema>(
md: Metadata<Input, Output>,
) => md.secure !== false || (md.auth ?? []).length > 0;

export interface Context {
req: Request;
Expand Down Expand Up @@ -151,6 +152,47 @@ export type BuildProcOptions<
forceSecure?: boolean;
};

const authenticate = async <Input extends ZodSchema>(
req: Request,
rawInput: z.infer<Input>,
authStrategy: AuthStrategies<Input> = { type: 'jwt' },
) => {
// Bypass when user is already authenticated via JWT or token
// Authentication overridden at router level e.g. external-router.ts
if (req.user && authStrategy.type !== 'custom') return;

try {
if (authStrategy.type === 'authtoken') {
switch (req.headers['authorization']) {
case config.NOTIFICATIONS.KNOCK_AUTH_TOKEN:
req.user = {
id: authStrategy.userId,
email: '[email protected]',
};
break;
case config.LOAD_TESTING.AUTH_TOKEN:
req.user = {
id: authStrategy.userId,
email: '[email protected]',
};
break;
default:
throw new Error('Not authenticated');
}
} else if (authStrategy.type === 'custom') {
req.user = await authStrategy.userResolver(rawInput, req.user as User);
} else {
await passport.authenticate(authStrategy.type, { session: false });
}
if (!req.user) throw new Error('Not authenticated');
} catch (error) {
throw new TRPCError({
message: error instanceof Error ? error.message : (error as string),
code: 'UNAUTHORIZED',
});
}
};

/**
* tRPC procedure factory with authentication, traffic stats, and analytics middleware
*/
Expand All @@ -165,8 +207,8 @@ export const buildproc = <Input extends ZodSchema, Output extends ZodSchema>({
}: BuildProcOptions<Input, Output>) => {
const secure = forceSecure ?? isSecure(md);
return trpc.procedure
.use(async ({ ctx, next }) => {
if (secure) await authenticate(ctx.req, md.authStrategy);
.use(async ({ ctx, rawInput, next }) => {
if (secure) await authenticate(ctx.req, rawInput, md.authStrategy);
return next({
ctx: {
...ctx,
Expand All @@ -181,6 +223,26 @@ export const buildproc = <Input extends ZodSchema, Output extends ZodSchema>({
const start = Date.now();
const result = await next();
const latency = Date.now() - start;

// TODO: this is a Friday night hack, let's rethink output middleware
if (
md.authStrategy?.type === 'custom' &&
md.authStrategy?.name === 'SignIn' &&
result.ok &&
result.data
) {
const data = result.data as z.infer<typeof md.output>;
await new Promise((resolve, reject) => {
ctx.req.login(data.User, (err) => {
if (err) {
// TODO: track Mixpanel login failure
reject(err);
}
resolve(true);
});
});
}

try {
const path = `${ctx.req.method.toUpperCase()} ${ctx.req.path}`;
stats().increment('cw.path.called', { path });
Expand Down Expand Up @@ -218,49 +280,3 @@ export const buildproc = <Input extends ZodSchema, Output extends ZodSchema>({
.input(md.input)
.output(md.output);
};

const authenticate = async (
req: Request,
authStrategy: AuthStrategies = { name: 'jwt' },
) => {
// User is already authenticated. Authentication overridden at router level e.g. external-router.ts
if (req.user) return;

try {
if (authStrategy.name === 'authtoken') {
switch (req.headers['authorization']) {
case config.NOTIFICATIONS.KNOCK_AUTH_TOKEN:
req.user = {
id: authStrategy.userId,
email: '[email protected]',
};
break;
case config.LOAD_TESTING.AUTH_TOKEN:
req.user = {
id: authStrategy.userId,
email: '[email protected]',
};
break;
default:
throw new Error('Not authenticated');
}
} else if (authStrategy.name === 'custom') {
authStrategy.customStrategyFn(req);
req.user = {
id: authStrategy.userId,
};
} else {
await passport.authenticate(authStrategy.name, { session: false });
}

if (!req.user) throw new Error('Not authenticated');
if (authStrategy.userId && (req.user as User).id !== authStrategy.userId) {
throw new Error('Not authenticated');
}
} catch (error) {
throw new TRPCError({
message: error instanceof Error ? error.message : (error as string),
code: 'UNAUTHORIZED',
});
}
};
14 changes: 7 additions & 7 deletions libs/core/src/framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,15 @@ export const ExternalServiceUserIds = {
K6: -2,
} as const;

export type AuthStrategies =
export type AuthStrategies<Input extends ZodSchema> =
| {
name: 'jwt' | 'authtoken';
type: 'jwt' | 'authtoken';
userId?: (typeof ExternalServiceUserIds)[keyof typeof ExternalServiceUserIds];
}
| {
name: 'custom';
userId?: (typeof ExternalServiceUserIds)[keyof typeof ExternalServiceUserIds];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
customStrategyFn: (req: any) => void;
type: 'custom';
name: string;
userResolver: (payload: z.infer<Input>, user?: User) => Promise<User>;
};

/**
Expand All @@ -43,6 +42,7 @@ export type User = {
id?: number;
emailVerified?: boolean;
isAdmin?: boolean;
auth?: Record<string, unknown>; // custom auth payload
};

/**
Expand Down Expand Up @@ -167,7 +167,7 @@ export type Metadata<
readonly auth: Handler<Input, Output, _Context>[];
readonly body: Handler<Input, Output, _Context>;
readonly secure?: boolean;
readonly authStrategy?: AuthStrategies;
readonly authStrategy?: AuthStrategies<Input>;
};

/**
Expand Down
4 changes: 4 additions & 0 deletions libs/model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"@alchemy/aa-alchemy": "^3.17.0",
"@alchemy/aa-core": "^3.16.0",
"@anatine/zod-mock": "^3.13.3",
"@canvas-js/interfaces": "^0.12.1",
"@canvas-js/signatures": "^0.12.1",
"@cosmjs/cosmwasm-stargate": "^0.31.3",
"@cosmjs/encoding": "0.32.3",
"@cosmjs/stargate": "^0.31.3",
Expand All @@ -38,10 +40,12 @@
"@hicommonwealth/shared": "workspace:*",
"@hicommonwealth/evm-protocols": "workspace:*",
"@neynar/nodejs-sdk": "^1.55.0",
"@sendgrid/mail": "^6.5.0",
"@solana/spl-token": "^0.4.6",
"@solana/web3.js": "^1.91.6",
"async-mutex": "^0.5.0",
"axios": "^1.3.4",
"bech32": "^2.0.0",
"bn.js": "^4.12.0",
"ethers": "5.7.2",
"jsonwebtoken": "^9.0.0",
Expand Down
4 changes: 2 additions & 2 deletions libs/model/src/emails/GetDigestEmailData.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function GetDigestEmailDataQuery(): Query<typeof GetDigestEmailData> {
...GetDigestEmailData,
auth: [],
secure: true,
authStrategy: { name: 'authtoken', userId: ExternalServiceUserIds.Knock },
authStrategy: { type: 'authtoken', userId: ExternalServiceUserIds.Knock },
body: async () => {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(new Date().getDate() - 7);
Expand Down Expand Up @@ -42,7 +42,7 @@ export function GetDigestEmailDataQuery(): Query<typeof GetDigestEmailData> {

if (!threads.length) return {};

const result: z.infer<typeof GetDigestEmailData['output']> = {};
const result: z.infer<(typeof GetDigestEmailData)['output']> = {};
for (const thread of threads) {
if (!result[thread.community_id]) {
result[thread.community_id] = [thread];
Expand Down
16 changes: 8 additions & 8 deletions libs/model/src/emails/GetRecapEmailData.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { config, models } from '..';
const log = logger(import.meta);

type AdditionalMetaData<Key extends keyof typeof EnrichedNotificationNames> = {
event_name: typeof EnrichedNotificationNames[Key];
event_name: (typeof EnrichedNotificationNames)[Key];
inserted_at: string;
};

Expand Down Expand Up @@ -123,11 +123,11 @@ async function getMessages(userId: string): Promise<{

async function enrichDiscussionNotifications(
discussion: DiscussionNotifications,
): Promise<z.infer<typeof GetRecapEmailData['output']>['discussion']> {
): Promise<z.infer<(typeof GetRecapEmailData)['output']>['discussion']> {
if (!discussion.length) return [];

const enrichedDiscussion: z.infer<
typeof GetRecapEmailData['output']
(typeof GetRecapEmailData)['output']
>['discussion'] = [];

const unfilteredIds: number[] = [];
Expand Down Expand Up @@ -186,17 +186,17 @@ async function enrichGovAndProtocolNotif({
governance: GovernanceNotifications;
protocol: ProtocolNotifications;
}): Promise<{
governance: z.infer<typeof GetRecapEmailData['output']>['governance'];
protocol: z.infer<typeof GetRecapEmailData['output']>['protocol'];
governance: z.infer<(typeof GetRecapEmailData)['output']>['governance'];
protocol: z.infer<(typeof GetRecapEmailData)['output']>['protocol'];
}> {
if (!governance.length && !protocol.length)
return { governance: [], protocol: [] };

const enrichedGovernance: z.infer<
typeof GetRecapEmailData['output']
(typeof GetRecapEmailData)['output']
>['governance'] = [];
const enrichedProtocol: z.infer<
typeof GetRecapEmailData['output']
(typeof GetRecapEmailData)['output']
>['protocol'] = [];

const unfilteredCommunityIds: string[] = [];
Expand Down Expand Up @@ -258,7 +258,7 @@ export function GetRecapEmailDataQuery(): Query<typeof GetRecapEmailData> {
...GetRecapEmailData,
auth: [],
secure: true,
authStrategy: { name: 'authtoken', userId: ExternalServiceUserIds.Knock },
authStrategy: { type: 'authtoken', userId: ExternalServiceUserIds.Knock },
body: async ({ payload }) => {
const notifications = await getMessages(payload.user_id);
const enrichedGovernanceAndProtocol = await enrichGovAndProtocolNotif({
Expand Down
2 changes: 1 addition & 1 deletion libs/model/src/load-testing/CreateJWTs.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function CreateJWTs(): Command<typeof schemas.CreateJWTs> {
...schemas.CreateJWTs,
auth: [],
secure: true,
authStrategy: { name: 'authtoken', userId: ExternalServiceUserIds.K6 },
authStrategy: { type: 'authtoken', userId: ExternalServiceUserIds.K6 },
body: async ({ payload }) => {
const userIds = await models.sequelize.query<{ id: number }>(
`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { ServerError, logger } from '@hicommonwealth/core';
import type { DB } from '@hicommonwealth/model';
import { Op } from 'sequelize';
import { models } from '../../database';

const log = logger(import.meta);

export default async function assertAddressOwnership(
models: DB,
address: string,
) {
export async function assertAddressOwnership(address: string) {
const addressUsers = await models.Address.findAll({
where: {
address,
Expand Down
4 changes: 4 additions & 0 deletions libs/model/src/services/session/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './assertAddressOwnership';
export * from './transferOwnership';
export * from './verifyAddress';
export * from './verifySessionSignature';
63 changes: 63 additions & 0 deletions libs/model/src/services/session/transferOwnership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as schemas from '@hicommonwealth/schemas';
import { Op, Transaction } from 'sequelize';
import { z } from 'zod';
import { models } from '../../database';

/**
* Transfers ownership of address to user in addr from other users with same address
*/
export async function transferOwnership(
addr: z.infer<typeof schemas.Address>,
transaction: Transaction,
) {
const found = await models.Address.findOne({
where: {
address: addr.address,
user_id: { [Op.ne]: addr.user_id },
// verified: { [Op.ne]: null },
},
include: {
model: models.User,
required: true,
attributes: ['id', 'email'],
},
transaction,
});
if (found) {
const [updated] = await models.Address.update(
{ user_id: addr.user_id },
{
where: { address: addr.address, user_id: found?.user_id },
transaction,
},
);
if (updated > 0) return found?.User;
}
}

// TODO: subscribe to AddressOwnershipTransferred event
// if (updated > 0 && unverifed) {
// try {
// // send email to the old user (should only ever be one)
// if (!unverifed.User?.email) throw new InvalidState(Errors.NoEmail);

// const msg = {
// to: unverifed.User.email,
// from: `Commonwealth <no-reply@${PRODUCTION_DOMAIN}>`,
// templateId: DynamicTemplate.VerifyAddress,
// dynamic_template_data: {
// address: addr.address,
// chain: community.name,
// },
// };
// await sgMail.send(msg);
// log.info(
// `Sent address move email: ${addr.address} transferred to a new account`,
// );
// } catch (e) {
// log.error(
// `Could not send address move email for: ${addr.address}`,
// e as Error,
// );
// }
// }
Loading

0 comments on commit 8a1dd9e

Please sign in to comment.