Skip to content

Commit

Permalink
feat(auth): implement anonymous user authentication
Browse files Browse the repository at this point in the history
refs #247
  • Loading branch information
ygrishajev committed Aug 12, 2024
1 parent ce241e1 commit fa9de2f
Show file tree
Hide file tree
Showing 27 changed files with 276 additions and 75 deletions.
1 change: 1 addition & 0 deletions .env.sandbox.docker-compose-dev
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ AkashSandboxDatabaseCS=postgres://postgres:password@db:5432/console-akash-sandbo
UserDatabaseCS=postgres://postgres:password@db:5432/console-users
Network=sandbox
ActiveChain=akashSandbox
POSTGRES_DB_URI: postgres://postgres:password@db:5432/console-users

# Deploy Web
API_BASE_URL: http://api:3000
Expand Down
1 change: 1 addition & 0 deletions apps/api/.env.functional.test
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT=5000000
TRIAL_ALLOWANCE_DENOM=uakt
LOG_LEVEL=debug
BILLING_ENABLED=true
ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@opentelemetry/instrumentation-pino": "^0.41.0",
"@opentelemetry/sdk-node": "^0.52.1",
"@sentry/node": "^7.55.2",
"@types/jsonwebtoken": "^9.0.6",
"@ucast/core": "^1.10.2",
"async-sema": "^3.1.1",
"axios": "^1.7.2",
Expand All @@ -65,6 +66,7 @@
"http-errors": "^2.0.0",
"human-interval": "^2.0.1",
"js-sha256": "^0.9.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"markdown-to-txt": "^2.0.1",
"memory-cache": "^0.2.0",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/auth/config/env.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

const envSchema = z.object({
ANONYMOUS_USER_TOKEN_SECRET: z.string()
});

export const envConfig = envSchema.parse(process.env);
3 changes: 3 additions & 0 deletions apps/api/src/auth/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { envConfig } from "./env.config";

export const config = envConfig;
11 changes: 11 additions & 0 deletions apps/api/src/auth/providers/config.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { container, inject } from "tsyringe";

import { config } from "@src/auth/config";

export const AUTH_CONFIG = "AUTH_CONFIG";

container.register(AUTH_CONFIG, { useValue: config });

export type AuthConfig = typeof config;

export const InjectAuthConfig = () => inject(AUTH_CONFIG);
1 change: 1 addition & 0 deletions apps/api/src/auth/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "./config.provider";
37 changes: 37 additions & 0 deletions apps/api/src/auth/services/auth-token/auth-token.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import jwt from "jsonwebtoken";
import { singleton } from "tsyringe";
import { z } from "zod";

import { AuthConfig, InjectAuthConfig } from "@src/auth/providers/config.provider";

@singleton()
export class AuthTokenService {
private readonly PayloadSchema = z.object({
sub: z.string(),
type: z.literal("ANONYMOUS")
});

constructor(@InjectAuthConfig() private readonly config: AuthConfig) {}

signTokenFor(input: { userId: string }): string {
return jwt.sign({ sub: input.userId, type: "ANONYMOUS" }, this.config.ANONYMOUS_USER_TOKEN_SECRET);
}

async getValidUserId(bearer: string): Promise<string | undefined> {
const token = bearer.replace(/^Bearer\s+/i, "");
const payload = await this.decodeToken(token);

if (payload) {
jwt.verify(token, this.config.ANONYMOUS_USER_TOKEN_SECRET);

return payload.sub;
}
}

private async decodeToken(token: string): Promise<z.infer<typeof this.PayloadSchema> | undefined> {
const payload = jwt.decode(token);
const { success, data } = await this.PayloadSchema.safeParseAsync(payload);

return success ? data : undefined;
}
}
36 changes: 18 additions & 18 deletions apps/api/src/auth/services/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { singleton } from "tsyringe";

import { AbilityService } from "@src/auth/services/ability/ability.service";
import { AuthService } from "@src/auth/services/auth.service";
import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service";
import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type";
import { kvStore } from "@src/middlewares/userMiddleware";
import { UserRepository } from "@src/user/repositories";
Expand All @@ -14,28 +15,32 @@ export class AuthInterceptor implements HonoInterceptor {
constructor(
private readonly abilityService: AbilityService,
private readonly userRepository: UserRepository,
private readonly authService: AuthService
private readonly authService: AuthService,
private readonly anonymousUserAuthService: AuthTokenService
) {}

intercept() {
return async (c: Context, next: Next) => {
const userId = await this.authenticate(c);
const bearer = c.req.header("authorization");

if (userId) {
const currentUser = await this.userRepository.findByUserId(userId);
const anonymousUserId = bearer && (await this.anonymousUserAuthService.getValidUserId(bearer));

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

this.authService.currentUser = currentUser;
this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_USER", currentUser) : this.abilityService.EMPTY_ABILITY;
this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_ANONYMOUS_USER", currentUser) : this.abilityService.EMPTY_ABILITY;

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

if (anonymousUserId) {
const currentUser = await this.userRepository.findAnonymousById(anonymousUserId);
const userId = bearer && (await this.getValidUserId(bearer, c));

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

this.authService.currentUser = currentUser;
this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_ANONYMOUS_USER", currentUser) : this.abilityService.EMPTY_ABILITY;
this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_USER", currentUser) : this.abilityService.EMPTY_ABILITY;

return await next();
}
Expand All @@ -46,15 +51,10 @@ export class AuthInterceptor implements HonoInterceptor {
};
}

private async authenticate(c: Context) {
const jwtToken = c.req.header("Authorization")?.replace(/Bearer\s+/i, "");

if (!jwtToken?.length) {
return;
}

const jwks = await getJwks(env.Auth0JWKSUri || c.env.JWKS_URI, useKVStore(kvStore || c.env?.VERIFY_RSA_JWT), c.env?.VERIFY_RSA_JWT_JWKS_CACHE_KEY);
const result = await verify(jwtToken, jwks);
private async getValidUserId(bearer: string, c: Context) {
const token = bearer.replace(/^Bearer\s+/i, "");
const jwks = await getJwks(env.Auth0JWKSUri || c.env?.JWKS_URI, useKVStore(kvStore || c.env?.VERIFY_RSA_JWT), c.env?.VERIFY_RSA_JWT_JWKS_CACHE_KEY);
const result = await verify(token, jwks);

return (result.payload as { sub: string }).sub;
}
Expand Down
19 changes: 16 additions & 3 deletions apps/api/src/routers/userRouter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Hono } from "hono";
import { Context, Hono } from "hono";
import assert from "http-assert";
import { container } from "tsyringe";
import * as uuid from "uuid";

import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service";
import { getCurrentUserId, optionalUserMiddleware, requiredUserMiddleware } from "@src/middlewares/userMiddleware";
import {
addTemplateFavorite,
Expand Down Expand Up @@ -114,11 +117,10 @@ userRequiredRouter.delete("/removeAddressName/:address", async c => {

userRequiredRouter.post("/tokenInfo", async c => {
const userId = getCurrentUserId(c);
const anonymousUserId = c.req.header("x-anonymous-user-id");
const { wantedUsername, email, emailVerified, subscribedToNewsletter } = await c.req.json();

const settings = await getSettingsOrInit({
anonymousUserId,
anonymousUserId: await extractAnonymousUserId(c),
userId: userId,
wantedUsername,
email: email,
Expand All @@ -129,6 +131,17 @@ userRequiredRouter.post("/tokenInfo", async c => {
return c.json(settings);
});

async function extractAnonymousUserId(c: Context) {
const anonymousBearer = c.req.header("x-anonymous-authorization");

if (anonymousBearer) {
const anonymousUserId = await container.resolve(AuthTokenService).getValidUserId(anonymousBearer);
assert(anonymousUserId, 401, "Invalid anonymous user token");

return anonymousUserId;
}
}

userRequiredRouter.put("/updateSettings", async c => {
const userId = getCurrentUserId(c);
const { username, subscribedToNewsletter, bio, youtubeUsername, twitterUsername, githubUsername } = await c.req.json();
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/user/controllers/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "http-assert";
import { singleton } from "tsyringe";

import { AuthService, Protected } from "@src/auth/services/auth.service";
import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service";
import { UserRepository } from "@src/user/repositories";
import { GetUserParams } from "@src/user/routes/get-anonymous-user/get-anonymous-user.router";
import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema";
Expand All @@ -10,12 +11,15 @@ import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema";
export class UserController {
constructor(
private readonly userRepository: UserRepository,
private readonly authService: AuthService
private readonly authService: AuthService,
private readonly anonymousUserAuthService: AuthTokenService
) {}

async create(): Promise<AnonymousUserResponseOutput> {
const user = await this.userRepository.create();
return {
data: await this.userRepository.create()
data: user,
token: this.anonymousUserAuthService.signTokenFor({ userId: user.id })
};
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/user/schemas/user.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export const AnonymousUserResponseOutputSchema = z.object({
.object({
id: z.string().openapi({})
})
.openapi({})
.openapi({}),
token: z.string().openapi({})
});

export type AnonymousUserResponseOutput = z.infer<typeof AnonymousUserResponseOutputSchema>;
11 changes: 7 additions & 4 deletions apps/api/test/functional/anonymous-user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ describe("Users", () => {
const schema = container.resolve<UserSchema>(USER_SCHEMA);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
let user: AnonymousUserResponseOutput["data"];
let token: AnonymousUserResponseOutput["token"];

beforeEach(async () => {
const userResponse = await app.request("/v1/anonymous-users", {
method: "POST",
headers: new Headers({ "Content-Type": "application/json" })
});
user = (await userResponse.json()).data;
const body = await userResponse.json();
user = body.data;
token = body.token;
});

afterEach(async () => {
Expand All @@ -32,7 +35,7 @@ describe("Users", () => {
it("should retrieve a user", async () => {
const getUserResponse = await app.request(`/v1/anonymous-users/${user.id}`, {
method: "GET",
headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id })
headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` })
});
const retrievedUser = await getUserResponse.json();

Expand All @@ -54,10 +57,10 @@ describe("Users", () => {
method: "POST",
headers: new Headers({ "Content-Type": "application/json" })
});
const { data: differentUser } = await differentUserResponse.json();
const { token: differentUserToken } = await differentUserResponse.json();
const res = await app.request(`/v1/anonymous-users/${user.id}`, {
method: "GET",
headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": differentUser.id })
headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${differentUserToken}` })
});

expect(res.status).toBe(404);
Expand Down
5 changes: 3 additions & 2 deletions apps/api/test/functional/create-wallet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ describe("wallets", () => {
headers: new Headers({ "Content-Type": "application/json" })
});
const {
data: { id: userId }
data: { id: userId },
token
} = await userResponse.json();
const headers = new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": userId });
const headers = new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` });
const createWalletResponse = await app.request("/v1/wallets", {
method: "POST",
body: JSON.stringify({ data: { userId } }),
Expand Down
8 changes: 4 additions & 4 deletions apps/api/test/functional/sign-and-broadcast-tx.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ describe("Tx Sign", () => {

describe("POST /v1/tx", () => {
it("should create a wallet for a user", async () => {
const { user, wallet } = await walletService.createUserAndWallet();
const { user, token, wallet } = await walletService.createUserAndWallet();
const res = await app.request("/v1/tx", {
method: "POST",
body: await createMessagePayload(user.id, wallet.address),
headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id })
headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` })
});

expect(res.status).toBe(200);
Expand All @@ -53,11 +53,11 @@ describe("Tx Sign", () => {
method: "POST",
headers: new Headers({ "Content-Type": "application/json" })
});
const { data: differentUser } = await differentUserResponse.json();
const { token } = await differentUserResponse.json();
const res = await app.request("/v1/tx", {
method: "POST",
body: await createMessagePayload(user.id, wallet.address),
headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": differentUser.id })
headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` })
});

expect(res.status).toBe(404);
Expand Down
12 changes: 6 additions & 6 deletions apps/api/test/functional/wallets-refill.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ describe("Wallets Refill", () => {
it("should refill draining wallets", async () => {
const prepareRecords = Array.from({ length: 15 }).map(async () => {
const records = await walletService.createUserAndWallet();
const user = records.user;
let wallet = records.wallet;
const { user, token } = records;
let { wallet } = records;

expect(wallet.creditAmount).toBe(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT + config.TRIAL_FEES_ALLOWANCE_AMOUNT);
const limits = {
Expand All @@ -49,20 +49,20 @@ describe("Wallets Refill", () => {
},
{ returning: true }
);
wallet = await walletService.getWalletByUserId(user.id);
wallet = await walletService.getWalletByUserId(user.id, token);

expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD + config.FEE_ALLOWANCE_REFILL_THRESHOLD);
expect(wallet.isTrialing).toBe(true);

return { user, wallet };
return { user, token, wallet };
});

const records = await Promise.all(prepareRecords);
await walletController.refillWallets();

await Promise.all(
records.map(async ({ wallet, user }) => {
wallet = await walletService.getWalletByUserId(user.id);
records.map(async ({ wallet, token, user }) => {
wallet = await walletService.getWalletByUserId(user.id, token);
expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT + config.FEE_ALLOWANCE_REFILL_AMOUNT);
expect(wallet.isTrialing).toBe(false);
})
Expand Down
10 changes: 5 additions & 5 deletions apps/api/test/services/wallet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ export class WalletService {
method: "POST",
headers: new Headers({ "Content-Type": "application/json" })
});
const { data: user } = await userResponse.json();
const { data: user, token } = await userResponse.json();
const walletResponse = await this.app.request("/v1/wallets", {
method: "POST",
body: JSON.stringify({
data: { userId: user.id }
}),
headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id })
headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` })
});
const { data: wallet } = await walletResponse.json();

return { user, wallet };
return { user, token, wallet };
}

async getWalletByUserId(userId: string): Promise<{ id: number; address: string; creditAmount: number }> {
async getWalletByUserId(userId: string, token: string): Promise<{ id: number; address: string; creditAmount: number }> {
const walletResponse = await this.app.request(`/v1/wallets?userId=${userId}`, {
headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": userId })
headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` })
});
const { data } = await walletResponse.json();

Expand Down
Loading

0 comments on commit fa9de2f

Please sign in to comment.