Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Implementation of Casbin Middleware #676

Merged
merged 12 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-llamas-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/casbin': major
---

Initial release
25 changes: 25 additions & 0 deletions .github/workflows/ci-casbin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: ci-cabin
on:
push:
branches: [main]
paths:
- 'packages/cabin/**'
pull_request:
branches: ['*']
paths:
- 'packages/cabin/**'

jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/cabin
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install --frozen-lockfile
- run: yarn build
- run: yarn test
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"build:react-compat": "yarn workspace @hono/react-compat build",
"build:effect-validator": "yarn workspace @hono/effect-validator build",
"build:conform-validator": "yarn workspace @hono/conform-validator build",
"build:casbin": "yarn workspace @hono/casbin build",
"build": "run-p 'build:*'",
"lint": "eslint 'packages/**/*.{ts,tsx}'",
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
Expand Down
140 changes: 140 additions & 0 deletions packages/casbin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Casbin Middleware for Hono

This is a third-party [Casbin](https://casbin.org) middleware for [Hono](https://github.com/honojs/hono).

This middleware can be used to enforce authorization policies defined using Casbin in your Hono routes.

## Installation

```bash
npm i hono @hono/casbin casbin
```

## Configuration

Before using the middleware, you must set up your Casbin model and policy files.

For details on how to write authorization policies and other information, please refer to the [Casbin documentation](https://casbin.org/).

### Example model.conf

```conf
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
```

### Example policy.csv

```csv
p, alice, /dataset1/*, *
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
p, bob, /dataset1/*, GET
```

## Usage with Basic HTTP Authentication

You can perform authorization control after Basic authentication by combining it with `basicAuthorizer`.
(The client needs to send `Authentication: Basic {Base64Encoded(username:password)}`.)

Let's look at an example.
Use the `model` and `policy` files from the [Configuration](#configuration) section.
You can implement a scenario where `alice` and `bob` have different permissions. Alice has access to all methods on `/dataset1/test`, while Bob has access only to the `GET` method.

```ts
import { Hono } from 'hono'
import { basicAuth } from 'hono/basic-auth'
import { newEnforcer } from 'casbin'
import { casbin } from '@hono/casbin'
import { basicAuthorizer } from '@hono/casbin/helper'

const app = new Hono()
app.use('*',
basicAuth(
{
username: 'alice', // alice has full access to /dataset1/test
password: 'password',
},
{
username: 'bob', // bob cannot post to /dataset1/test
password: 'password',
}
),
casbin({
newEnforcer: newEnforcer('examples/model.conf', 'examples/policy.csv'),
authorizer: basicAuthorizer
})
)
app.get('/dataset1/test', (c) => c.text('dataset1 test')) // alice and bob can access /dataset1/test
app.post('/dataset1/test', (c) => c.text('dataset1 test')) // Only alice can access /dataset1/test
```

## Usage with JWT Authentication

By using `jwtAuthorizer`, you can perform authorization control after JWT authentication.
By default, `jwtAuthorizer` uses the `sub` in the JWT payload as the username.

```ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import { newEnforcer } from 'casbin'
import { casbin } from '@hono/casbin'
import { jwtAuthorizer } from '@hono/casbin/helper'

const app = new Hono()
app.use('*',
jwt({
secret: 'it-is-very-secret',
}),
casbin({
newEnforcer: newEnforcer('examples/model.conf', 'examples/policy.csv'),
authorizer: jwtAuthorizer
})
)
app.get('/dataset1/test', (c) => c.text('dataset1 test')) // alice and bob can access /dataset1/test
app.post('/dataset1/test', (c) => c.text('dataset1 test')) // Only alice can access /dataset1/test
```

Of course, you can use claims other than the `sub` claim.
Specify the `key` as a user-friendly name and the `value` as the JWT claim name. The `Payload` key used for evaluation in the enforcer will be the `value`.

```ts
const claimMapping = {
username: 'username',
}
// ...
casbin({
newEnforcer: newEnforcer('examples/model.conf', 'examples/policy.csv'),
authorizer: (c, e) => jwtAuthorizer(c, e, claimMapping)
})
```

## Usage with Customized Authorizer

You can also use a customized authorizer function to handle the authorization logic.

```ts
import { Hono } from 'hono'
import { newEnforcer } from 'casbin'
import { casbin } from '@hono/casbin'

const app = new Hono()
app.use('*', casbin({
newEnforcer: newEnforcer('path-to-your-model.conf', 'path-to-your-policy.csv'),
authorizer: async (c, enforcer) => {
const { user, path, method } = c
return await enforcer.enforce(user, path, method)
}
}))
```

## Author

sugar-cat https://github.com/sugar-cat7
14 changes: 14 additions & 0 deletions packages/casbin/examples/model.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
5 changes: 5 additions & 0 deletions packages/casbin/examples/policy.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
p, dataset1_admin, /dataset1/*, *
p, dataset2_admin, /dataset2/*, *

g, alice, dataset1_admin
g, bob, dataset2_admin
61 changes: 61 additions & 0 deletions packages/casbin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@hono/casbin",
"version": "0.0.0",
"description": "Casbin middleware for Hono",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./helper": {
"import": {
"types": "./dist/helper/index.d.ts",
"default": "./dist/helper/index.js"
},
"require": {
"types": "./dist/helper/index.d.cts",
"default": "./dist/helper/index.cjs"
}
}
},
"files": [
"dist"
],
"scripts": {
"test": "vitest --run",
"build": "tsup ./src/index.ts ./src/helper/index.ts --format esm,cjs --dts",
"publint": "publint",
"release": "yarn build && yarn test && yarn publint && yarn publish"
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"casbin": ">=5.30.0",
"hono": ">=4.5.11"
},
"devDependencies": {
"casbin": "^5.30.0",
"hono": "^4.5.11",
"tsup": "^8.1.0",
"typescript": "^5.5.3",
"vitest": "^2.0.1"
}
}
17 changes: 17 additions & 0 deletions packages/casbin/src/helper/basic-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Enforcer } from 'casbin'
import type { Context } from 'hono'
import { auth } from 'hono/utils/basic-auth'

const getUserName = (c: Context): string => {
const requestUser = auth(c.req.raw)
if (!requestUser) {
return ''
}
return requestUser.username
}

export const basicAuthorizer = async (c: Context, enforcer: Enforcer): Promise<boolean> => {
const { path, method } = c.req
const user = getUserName(c)
return enforcer.enforce(user, path, method)
}
2 changes: 2 additions & 0 deletions packages/casbin/src/helper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './jwt'
export * from './basic-auth'
36 changes: 36 additions & 0 deletions packages/casbin/src/helper/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { decode } from 'hono/jwt'
import type { Enforcer } from 'casbin'
import type { Context } from 'hono'
import type { JWTPayload } from 'hono/utils/jwt/types'

export const jwtAuthorizer = async (
c: Context,
enforcer: Enforcer,
claimMapping: Record<string, string> = { userID: 'sub' }
): Promise<boolean> => {
// Note: if use hono/jwt, the payload is stored in c.get('jwtPayload')
// https://github.com/honojs/hono/blob/8ba02273e829318d7f8797267f52229e531b8bd5/src/middleware/jwt/jwt.ts#L136
let payload: JWTPayload = c.get('jwtPayload')

if (!payload) {
const credentials = c.req.header('Authorization')
if (!credentials) return false

const parts = credentials.split(/\s+/)
if (parts.length !== 2 || parts[0] !== 'Bearer') return false

const token = parts[1]

try {
const decoded = decode(token)
payload = decoded.payload
} catch {
return false
}
}

const args = Object.values(claimMapping).map((key) => payload[key])

const { path, method } = c.req
return await enforcer.enforce(...args, path, method)
}
22 changes: 22 additions & 0 deletions packages/casbin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Enforcer } from 'casbin'
import { type Context, MiddlewareHandler } from 'hono'

interface CasbinOptions {
newEnforcer: Promise<Enforcer>
authorizer: (c: Context, enforcer: Enforcer) => Promise<boolean>
}

export const casbin = (opt: CasbinOptions): MiddlewareHandler => {
return async (c, next) => {
const enforcer = await opt.newEnforcer
if (!(enforcer instanceof Enforcer)) {
return c.json({ error: 'Invalid enforcer' }, 500)
}

const isAllowed = await opt.authorizer(c, enforcer)
if (!isAllowed) {
return c.json({ error: 'Forbidden' }, 403)
}
await next()
}
}
Loading