Skip to content

Commit 262b3bc

Browse files
committed
feat: implement role-based auth system with middleware and signature verification [skip ci]
1 parent b12a26d commit 262b3bc

15 files changed

Lines changed: 579 additions & 309 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from "./roles"
2+
export * from "./verifySignature"
3+
export * from "./middlewares/userAuth"
4+
export * from "./middlewares/gameAuth"
5+
export * from "./middlewares/guildAuth"
6+
export * from "./middlewares/requireAuth"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createMiddleware } from "hono/factory"
2+
import type { GameTableId, UserTableId } from "../../db/types"
3+
4+
export const requireGameOwnership = createMiddleware(async (c, next) => {
5+
const userId = c.get("userId") as UserTableId
6+
const gameId = c.req.param("id")
7+
const { gameRepo } = c.get("repos")
8+
9+
const game = await gameRepo.findById(Number(gameId) as GameTableId)
10+
11+
if (!game) {
12+
return c.json({ error: "Game not found", ok: false }, 404)
13+
}
14+
15+
if (game.admin_id !== userId) {
16+
return c.json({ error: "Only the game creator can perform this action", ok: false }, 403)
17+
}
18+
19+
await next()
20+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createMiddleware } from "hono/factory"
2+
import type { GuildTableId, UserTableId } from "../../db/types"
3+
import type { GuildRole } from "../roles"
4+
5+
export const requireGuildRole = (role: keyof typeof GuildRole) => {
6+
return createMiddleware(async (c, next) => {
7+
const userId = c.get("userId") as UserTableId
8+
const guildId = c.req.param("id")
9+
const { guildRepo } = c.get("repos")
10+
11+
const guild = await guildRepo.findById(Number(guildId) as GuildTableId)
12+
if (!guild) {
13+
return c.json({ error: "Guild not found", ok: false }, 404)
14+
}
15+
16+
if (role === "CREATOR" && guild.creator_id !== userId) {
17+
return c.json({ error: "Only the guild creator can perform this action", ok: false }, 403)
18+
}
19+
20+
if (role === "MEMBER" || role === "ADMIN") {
21+
const member = await guildRepo.findGuildMember(Number(guildId) as GuildTableId, userId)
22+
23+
if (!member) {
24+
return c.json({ error: "You are not a member of this guild", ok: false }, 403)
25+
}
26+
27+
if (role === "ADMIN" && !member.is_admin) {
28+
return c.json({ error: "Admin privileges required", ok: false }, 403)
29+
}
30+
}
31+
32+
await next()
33+
})
34+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Address } from "@happy.tech/common"
2+
import type { MiddlewareHandler } from "hono"
3+
import { createMiddleware } from "hono/factory"
4+
import type { AuthSessionTableId, UserTableId } from "../../db/types"
5+
6+
// Define the context variables we'll set in the middleware
7+
declare module "hono" {
8+
interface ContextVariableMap {
9+
userId: UserTableId
10+
primaryWallet: Address
11+
sessionId: AuthSessionTableId
12+
}
13+
}
14+
15+
/**
16+
* Middleware that verifies if a user is authenticated via session
17+
* Requires Authorization header with Bearer token containing the session ID
18+
*/
19+
export const requireAuth: MiddlewareHandler = createMiddleware(async (c, next) => {
20+
const authHeader = c.req.header("Authorization")
21+
22+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
23+
return c.json({ error: "Authentication required", ok: false }, 401)
24+
}
25+
26+
const sessionId = authHeader.substring(7) as AuthSessionTableId
27+
28+
const { authRepo } = c.get("repos")
29+
const session = await authRepo.verifySession(sessionId)
30+
31+
if (!session) {
32+
return c.json({ error: "Invalid or expired session", ok: false }, 401)
33+
}
34+
35+
// Set user context for downstream middleware and handlers
36+
c.set("userId", session.user_id)
37+
c.set("primaryWallet", session.primary_wallet)
38+
c.set("sessionId", session.id)
39+
40+
await next()
41+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createMiddleware } from "hono/factory"
2+
import type { UserTableId } from "../../db/types"
3+
4+
export const requireOwnership = (paramName: string, idType: "id" | "primary_wallet") => {
5+
return createMiddleware(async (c, next) => {
6+
const userId = c.get("userId") as UserTableId
7+
const resourceId = c.req.param(paramName)
8+
9+
if (idType === "id" && userId !== (Number(resourceId) as UserTableId)) {
10+
return c.json({ error: "You can only access your own resources", ok: false }, 403)
11+
}
12+
13+
if (idType === "primary_wallet" && c.get("primaryWallet") !== resourceId) {
14+
return c.json({ error: "You can only access your own resources", ok: false }, 403)
15+
}
16+
17+
await next()
18+
})
19+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export enum UserRole {
2+
AUTHENTICATED = "authenticated", // Any authenticated user
3+
SELF = "self", // The user themselves (for self-management)
4+
}
5+
6+
export enum GuildRole {
7+
MEMBER = "member", // Regular guild member
8+
ADMIN = "admin", // Guild admin (can manage members)
9+
CREATOR = "creator", // Guild creator (can delete guild)
10+
}
11+
12+
export enum GameRole {
13+
PLAYER = "player", // Regular player (can submit scores)
14+
CREATOR = "creator", // Game creator (can manage game)
15+
}
16+
17+
export const Permissions = {
18+
User: {
19+
READ: [UserRole.AUTHENTICATED, UserRole.SELF], // Anyone can read user profiles
20+
CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a profile
21+
UPDATE: [UserRole.SELF], // Only the user can update their profile
22+
DELETE: [UserRole.SELF], // Only the user can delete their profile
23+
MANAGE_WALLETS: [UserRole.SELF], // Only the user can manage their wallets
24+
},
25+
26+
Guild: {
27+
READ: [UserRole.AUTHENTICATED], // Anyone can read guild info
28+
CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a guild
29+
UPDATE: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can update guild
30+
DELETE: [GuildRole.CREATOR], // Only creator can delete guild
31+
ADD_MEMBER: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can add members
32+
REMOVE_MEMBER: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can remove members
33+
PROMOTE_MEMBER: [GuildRole.CREATOR], // Only creator can promote members to admin
34+
},
35+
36+
Game: {
37+
READ: [UserRole.AUTHENTICATED], // Anyone can read game info
38+
CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a game
39+
UPDATE: [GameRole.CREATOR], // Only creator can update game
40+
DELETE: [GameRole.CREATOR], // Only creator can delete game
41+
SUBMIT_SCORE: [UserRole.AUTHENTICATED], // Any authenticated user can submit scores
42+
MANAGE_SCORES: [GameRole.CREATOR], // Only creator can manage/delete scores
43+
},
44+
}
45+
46+
export type RoleType = UserRole | GuildRole | GameRole
47+
export type ResourceType = "user" | "guild" | "game" | "score"
48+
export type ActionType = "read" | "create" | "update" | "delete" | "manage"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Address, Hex } from "@happy.tech/common"
2+
import { abis } from "@happy.tech/contracts/boop/anvil"
3+
import { http, createPublicClient, hashMessage } from "viem"
4+
import { localhost } from "viem/chains"
5+
import { env } from "../env"
6+
7+
// ERC-1271 magic value constant
8+
const EIP1271_MAGIC_VALUE = "0x1626ba7e"
9+
10+
/**
11+
* Verifies a signature against a wallet address using ERC-1271 standard
12+
* Makes an RPC call to the smart contract account for signature verification
13+
*
14+
* @param walletAddress - The wallet address to verify against
15+
* @param message - The message that was signed
16+
* @param signature - The signature to verify
17+
* @returns Promise<boolean> - Whether the signature is valid
18+
*/
19+
export async function verifySignature(walletAddress: Address, message: Hex, signature: Hex): Promise<boolean> {
20+
try {
21+
const publicClient = createPublicClient({
22+
chain: localhost,
23+
transport: http(env.RPC_URL),
24+
})
25+
26+
const messageHash = hashMessage({ raw: message })
27+
const result = await publicClient.readContract({
28+
address: walletAddress,
29+
abi: abis.HappyAccountImpl,
30+
functionName: "isValidSignature",
31+
args: [messageHash, signature],
32+
})
33+
34+
return result === EIP1271_MAGIC_VALUE
35+
} catch (error) {
36+
console.error("Error verifying signature:", error)
37+
return false
38+
}
39+
}

apps/leaderboard-backend/src/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const envSchema = z.object({
44
LEADERBOARD_DB_URL: z.string().trim(),
55
PORT: z.string().trim().default("4545"),
66
DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"),
7-
SESSION_EXPIRY: z.string().trim().default("1h"),
7+
SESSION_EXPIRY: z.string().trim().default("1d"),
88
SIGN_MESSAGE_PREFIX: z.string().trim().default("HappyChain Authentication"),
99
RPC_URL: z.string().trim().default("http://localhost:8545"),
1010
})

apps/leaderboard-backend/src/middlewares/isValidSignature.ts

Lines changed: 0 additions & 27 deletions
This file was deleted.

apps/leaderboard-backend/src/repositories/AuthRepository.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import { createUUID } from "@happy.tech/common"
2-
import type { Address, Hex } from "@happy.tech/common"
2+
import type { Address } from "@happy.tech/common"
33
import { abis, deployment } from "@happy.tech/contracts/boop/anvil"
44

55
import type { Kysely } from "kysely"
6-
import { http, createPublicClient, hashMessage } from "viem"
6+
import { http, createPublicClient } from "viem"
77
import { localhost } from "viem/chains"
88

99
import type { AuthSession, AuthSessionTableId, Database, NewAuthSession, UserTableId } from "../db/types"
1010
import { env } from "../env"
1111

12-
const EIP1271_MAGIC_VALUE = "0x1626ba7e"
13-
1412
export class AuthRepository {
1513
private publicClient: ReturnType<typeof createPublicClient>
1614

@@ -34,23 +32,6 @@ export class AuthRepository {
3432
return `${nonce}${timestamp}`
3533
}
3634

37-
async verifySignature(primary_wallet: Address, message: Hex, signature: Hex): Promise<boolean> {
38-
try {
39-
const messageHash = await hashMessage({ raw: message })
40-
const result = await this.publicClient.readContract({
41-
address: primary_wallet,
42-
abi: abis.HappyAccountImpl,
43-
functionName: "isValidSignature",
44-
args: [messageHash, signature],
45-
})
46-
47-
return result === EIP1271_MAGIC_VALUE
48-
} catch (error) {
49-
console.error("Error verifying signature:", error)
50-
return false
51-
}
52-
}
53-
5435
async createSession(userId: UserTableId, walletAddress: Address): Promise<AuthSession | undefined> {
5536
try {
5637
const now = new Date()

0 commit comments

Comments
 (0)