Skip to content

Commit

Permalink
feat(auth): implements basic anonymous user auth
Browse files Browse the repository at this point in the history
refs #247
  • Loading branch information
ygrishajev committed Jul 31, 2024
1 parent fa1f252 commit 339da48
Show file tree
Hide file tree
Showing 29 changed files with 624 additions and 168 deletions.
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@akashnetwork/akashjs": "^0.10.0",
"@akashnetwork/database": "*",
"@akashnetwork/http-sdk": "*",
"@casl/ability": "^6.7.1",
"@chain-registry/assets": "^0.7.1",
"@cosmjs/amino": "^0.32.4",
"@cosmjs/crypto": "^0.32.4",
Expand All @@ -49,6 +50,7 @@
"@opentelemetry/instrumentation-pino": "^0.41.0",
"@opentelemetry/sdk-node": "^0.52.1",
"@sentry/node": "^7.55.2",
"@ucast/core": "^1.10.2",
"async-sema": "^3.1.1",
"axios": "^1.7.2",
"commander": "^12.1.0",
Expand Down
8 changes: 4 additions & 4 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { container } from "tsyringe";

import { AuthInterceptor } from "@src/auth/services/auth.interceptor";
import { HonoErrorHandlerService } from "@src/core/services/hono-error-handler/hono-error-handler.service";
import { HttpLoggerService } from "@src/core/services/http-logger/http-logger.service";
import { LoggerService } from "@src/core/services/logger/logger.service";
import { RequestStorageInterceptor } from "@src/core/services/request-storage/request-storage.interceptor";
import { CurrentUserInterceptor } from "@src/user/services/current-user/current-user.interceptor";
import { RequestContextInterceptor } from "@src/core/services/request-storage/request-context.interceptor";
import packageJson from "../package.json";
import { chainDb, syncUserSchema, userDb } from "./db/dbConnection";
import { apiRouter } from "./routers/apiRouter";
Expand Down Expand Up @@ -64,8 +64,8 @@ const scheduler = new Scheduler({
});

appHono.use(container.resolve(HttpLoggerService).intercept());
appHono.use(container.resolve(RequestStorageInterceptor).intercept());
appHono.use(container.resolve(CurrentUserInterceptor).intercept());
appHono.use(container.resolve(RequestContextInterceptor).intercept());
appHono.use(container.resolve(AuthInterceptor).intercept());
appHono.use(
"*",
sentry({
Expand Down
Empty file added apps/api/src/auth/index.ts
Empty file.
37 changes: 37 additions & 0 deletions apps/api/src/auth/services/ability/ability.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Ability } from "@casl/ability";
import template from "lodash/template";
import { singleton } from "tsyringe";

@singleton()
export class AbilityService {
private readonly createRegularUserRules = template(
JSON.stringify([
{ action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } },
{ action: "read", subject: "User", conditions: { id: "${user.id}" } }
])
);
private readonly createRegularAnonymousUserRules = template(
JSON.stringify([
{ action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } },
{ action: "read", subject: "User", conditions: { id: "${user.id}", userId: null } }
])
);

getAbilityForUser(user: { userId: string }) {
const rules = this.createRegularUserRules({ user });
return new Ability(JSON.parse(rules));
}

getAbilityForAnonymousUser(user: { id: string }) {
const rules = this.createRegularAnonymousUserRules({ user });
return new Ability(JSON.parse(rules));
}

getEmptyAbility() {
return new Ability([]);
}

getSuperUserAbility() {
return new Ability([{ action: "manage", subject: "all" }]);
}
}
48 changes: 48 additions & 0 deletions apps/api/src/auth/services/auth.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Context, Next } from "hono";
import { singleton } from "tsyringe";

import { AbilityService } from "@src/auth/services/ability/ability.service";
import { AuthService } from "@src/auth/services/auth.service";
import { ExecutionContextService } from "@src/core/services/execution-context/execution-context.service";
import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type";
import { getCurrentUserId } from "@src/middlewares/userMiddleware";
import { UserRepository } from "@src/user/repositories";

@singleton()
export class AuthInterceptor implements HonoInterceptor {
constructor(
private readonly abilityService: AbilityService,
private readonly userRepository: UserRepository,
private readonly executionContextService: ExecutionContextService,
private readonly authService: AuthService
) {}

intercept() {
return async (c: Context, next: Next) => {
const userId = getCurrentUserId(c);

if (userId) {
const currentUser = await this.userRepository.findByUserId(userId);

this.authService.currentUser = currentUser;
this.authService.ability = currentUser ? this.abilityService.getAbilityForUser(currentUser) : this.abilityService.getEmptyAbility();

return await next();
}
const anonymousUserId = c.req.header("x-anonymous-user-id");

if (anonymousUserId) {
const currentUser = await this.userRepository.findAnonymousById(anonymousUserId);

this.authService.currentUser = currentUser;
this.authService.ability = currentUser ? this.abilityService.getAbilityForAnonymousUser(currentUser) : this.abilityService.getEmptyAbility();

return await next();
}

this.authService.ability = this.abilityService.getEmptyAbility();

return await next();
};
}
}
53 changes: 53 additions & 0 deletions apps/api/src/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Ability } from "@casl/ability";
import assert from "http-assert";
import { container, Lifecycle, scoped } from "tsyringe";

import { ExecutionContextService } from "@src/core/services/execution-context/execution-context.service";
import { UserOutput } from "@src/user/repositories";

@scoped(Lifecycle.ResolutionScoped)
export class AuthService {
constructor(private readonly executionContextService: ExecutionContextService) {}

set currentUser(user: UserOutput) {
this.executionContextService.set("CURRENT_USER", user);
}

get currentUser(): UserOutput {
return this.executionContextService.get("CURRENT_USER");
}

set ability(ability: Ability) {
this.executionContextService.set("ABILITY", ability);
}

get ability(): Ability {
return this.executionContextService.get("ABILITY");
}

get isAuthenticated(): boolean {
return !!this.currentUser;
}

throwUnlessCan(action: string, subject: string) {
assert(this.ability.can(action, subject), 403);
}
}

export const Protected = (rules?: { action: string; subject: string }[]) => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {

Check warning on line 37 in apps/api/src/auth/services/auth.service.ts

View workflow job for this annotation

GitHub Actions / validate-n-build

Unexpected any. Specify a different type
const originalMethod = descriptor.value;

descriptor.value = function protectedFunction(...args: any[]) {

Check warning on line 40 in apps/api/src/auth/services/auth.service.ts

View workflow job for this annotation

GitHub Actions / validate-n-build

Unexpected any. Specify a different type
const authService = container.resolve(AuthService);

assert(authService.isAuthenticated, 401);

if (rules) {
rules.forEach(rule => authService.throwUnlessCan(rule.action, rule.subject));
}

return originalMethod.apply(this, args);
};

return descriptor;
};
18 changes: 11 additions & 7 deletions apps/api/src/billing/controllers/wallet/wallet.controller.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,43 @@
import type { EncodeObject } from "@cosmjs/proto-signing";
import pick from "lodash/pick";
import { singleton } from "tsyringe";
import { container, Lifecycle, scoped } from "tsyringe";

Check failure on line 3 in apps/api/src/billing/controllers/wallet/wallet.controller.ts

View workflow job for this annotation

GitHub Actions / validate-n-build

'container' is defined but never used

import { AuthService, Protected } from "@src/auth/services/auth.service";
import type { WalletListOutputResponse, WalletOutputResponse } from "@src/billing/http-schemas/wallet.schema";
import { UserWalletRepository } from "@src/billing/repositories";
import type { CreateWalletRequestInput, SignTxRequestInput, SignTxResponseOutput } from "@src/billing/routes";
import { GetWalletQuery } from "@src/billing/routes/get-wallet-list/get-wallet-list.router";
import { ManagedUserWalletService, WalletInitializerService } from "@src/billing/services";
import { WalletInitializerService } from "@src/billing/services";
import { RefillService } from "@src/billing/services/refill/refill.service";
import { TxSignerService } from "@src/billing/services/tx-signer/tx-signer.service";

// TODO: authorize endpoints below
@singleton()
@scoped(Lifecycle.ResolutionScoped)
export class WalletController {
constructor(
private readonly walletManager: ManagedUserWalletService,
private readonly userWalletRepository: UserWalletRepository,
private readonly walletInitializer: WalletInitializerService,
private readonly signerService: TxSignerService,
private readonly refillService: RefillService
private readonly refillService: RefillService,
private readonly authService: AuthService
) {}

@Protected([{ action: "create", subject: "UserWallet" }])
async create({ data: { userId } }: CreateWalletRequestInput): Promise<WalletOutputResponse> {
return {
data: await this.walletInitializer.initialize(userId)
};
}

@Protected([{ action: "read", subject: "UserWallet" }])
async getWallets(query: GetWalletQuery): Promise<WalletListOutputResponse> {
const wallets = await this.userWalletRepository.find(query);
const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query);

return {
data: wallets.map(wallet => pick(wallet, ["id", "userId", "address", "creditAmount"]))
};
}

@Protected([{ action: "sign", subject: "UserWallet" }])
async signTx({ data: { userId, messages } }: SignTxRequestInput): Promise<SignTxResponseOutput> {
return {
data: await this.signerService.signAndBroadcast(userId, messages as EncodeObject[])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { singleton } from "tsyringe";

import { InjectUserWalletSchema, UserWalletSchema } from "@src/billing/providers";
import { ApiPgDatabase, InjectPg } from "@src/core/providers";
import { AbilityParams, BaseRepository } from "@src/core/repositories/base.repository";
import { TxService } from "@src/core/services";

export type UserWalletInput = Partial<UserWalletSchema["$inferInsert"]>;
Expand All @@ -18,35 +19,37 @@ export interface ListOptions {
}

@singleton()
export class UserWalletRepository {
get cursor() {
return this.txManager.getPgTx() || this.pg;
export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
constructor(
@InjectPg() protected readonly pg: ApiPgDatabase,
@InjectUserWalletSchema() protected readonly schema: UserWalletSchema,
protected readonly txManager: TxService
) {
super(pg, schema, txManager, "UserWallet");
}

constructor(
@InjectPg() private readonly pg: ApiPgDatabase,
@InjectUserWalletSchema() private readonly userWallet: UserWalletSchema,
private readonly txManager: TxService
) {}
accessibleBy(...abilityParams: AbilityParams) {
return new UserWalletRepository(this.pg, this.schema, this.txManager).withAbility(...abilityParams) as this;
}

async create(input: Pick<UserWalletInput, "userId" | "address">) {
return this.toOutput(
first(
await this.cursor
.insert(this.userWallet)
.values({
userId: input.userId,
address: input.address
})
.returning()
)
);
const value = {
userId: input.userId,
address: input.address
};

this.ability?.throwUnlessCanExecute(value);

return this.toOutput(first(await this.cursor.insert(this.schema).values(value).returning()));
}

async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>, options?: { returning: true }): Promise<UserWalletOutput>;
async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>): Promise<void>;
async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>, options?: { returning: boolean }): Promise<void | UserWalletOutput> {
const cursor = this.cursor.update(this.userWallet).set(payload).where(eq(this.userWallet.id, id));
const cursor = this.cursor
.update(this.schema)
.set(payload)
.where(this.whereAccessibleBy(eq(this.schema.id, id)));

if (options?.returning) {
const items = await cursor.returning();
Expand All @@ -60,36 +63,40 @@ export class UserWalletRepository {

async find(query?: Partial<DbUserWalletOutput>) {
const fields = query && (Object.keys(query) as Array<keyof DbUserWalletOutput>);
const where = fields?.length ? and(...fields.map(field => eq(this.userWallet[field], query[field]))) : undefined;
const where = fields?.length ? and(...fields.map(field => eq(this.schema[field], query[field]))) : undefined;

return this.toOutputList(
await this.cursor.query.userWalletSchema.findMany({
where
where: this.whereAccessibleBy(where)
})
);
}

async findDrainingWallets(thresholds = { fee: 0, deployment: 0 }, options?: Pick<ListOptions, "limit">) {
const where = or(lte(this.schema.deploymentAllowance, thresholds.deployment.toString()), lte(this.schema.feeAllowance, thresholds.fee.toString()));

return this.toOutputList(
await this.cursor.query.userWalletSchema.findMany({
where: or(lte(this.userWallet.deploymentAllowance, thresholds.deployment.toString()), lte(this.userWallet.feeAllowance, thresholds.fee.toString())),
where: this.whereAccessibleBy(where),
limit: options?.limit || 10
})
);
}

async findByUserId(userId: UserWalletOutput["userId"]) {
return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: eq(this.userWallet.userId, userId) }));
return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) }));
}

private toOutputList(dbOutput: UserWalletSchema["$inferSelect"][]): UserWalletOutput[] {
return dbOutput.map(item => this.toOutput(item));
}

private toOutput(dbOutput: UserWalletSchema["$inferSelect"]): UserWalletOutput {
return {
...dbOutput,
creditAmount: parseFloat(dbOutput.deploymentAllowance) + parseFloat(dbOutput.feeAllowance)
};
private toOutput(dbOutput?: UserWalletSchema["$inferSelect"]): UserWalletOutput {
return (
dbOutput && {
...dbOutput,
creditAmount: parseFloat(dbOutput.deploymentAllowance) + parseFloat(dbOutput.feeAllowance)
}
);
}
}
8 changes: 5 additions & 3 deletions apps/api/src/billing/services/tx-signer/tx-signer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import assert from "http-assert";
import pick from "lodash/pick";
import { singleton } from "tsyringe";

import { AuthService } from "@src/auth/services/auth.service";
import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";
import { InjectTypeRegistry } from "@src/billing/providers/type-registry.provider";
import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories";
Expand All @@ -28,12 +29,13 @@ export class TxSignerService {
@InjectTypeRegistry() private readonly registry: Registry,
private readonly userWalletRepository: UserWalletRepository,
private readonly masterWalletService: MasterWalletService,
private readonly balancesService: BalancesService
private readonly balancesService: BalancesService,
private readonly authService: AuthService
) {}

async signAndBroadcast(userId: UserWalletOutput["userId"], messages: StringifiedEncodeObject[]) {
const userWallet = await this.userWalletRepository.findByUserId(userId);
assert(userWallet, 403, "User wallet not found");
const userWallet = await this.userWalletRepository.accessibleBy(this.authService.ability, "sign").findByUserId(userId);
assert(userWallet, 403);

const decodedMessages = this.decodeMessages(messages);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pick from "lodash/pick";
import { singleton } from "tsyringe";

import { AuthService } from "@src/auth/services/auth.service";
import { UserWalletInput, UserWalletRepository } from "@src/billing/repositories";
import { ManagedUserWalletService } from "@src/billing/services";
import { WithTransaction } from "@src/core/services";
Expand All @@ -9,12 +10,13 @@ import { WithTransaction } from "@src/core/services";
export class WalletInitializerService {
constructor(
private readonly walletManager: ManagedUserWalletService,
private readonly userWalletRepository: UserWalletRepository
private readonly userWalletRepository: UserWalletRepository,
private readonly authService: AuthService
) {}

@WithTransaction()
async initialize(userId: UserWalletInput["userId"]) {
const { id } = await this.userWalletRepository.create({ userId });
const { id } = await this.userWalletRepository.accessibleBy(this.authService.ability, "create").create({ userId });
const wallet = await this.walletManager.createAndAuthorizeTrialSpending({ addressIndex: id });
const userWallet = await this.userWalletRepository.updateById(
id,
Expand Down
Loading

0 comments on commit 339da48

Please sign in to comment.