# Keycloak SSO 认证和授权
Keycloak是一种面向现代应用程序和服务的开源的IAM(身份识别与访问管理)解决方案。
这里将介绍如何在 Nest 中接入 Keycloak.
# 不好用的官方适配器
Keycloak 官方提供了一个 Node 的适配器 keycloak-nodejs-connect
这个适配器在我们的试用过程中,感觉设计非常不合理,这个主要是定位问题。
Keycloak 的设计中,Node 是只做渲染层的,而且登陆功能也必须做在渲染层面, 所以 keycloak-nodejs-connect 就是为前端服务适配的,完全达不到 Java 适配器的水准。
当一个 Node后端服务要对接 Keycloak 的时候会被官方的适配器搞死,可怜的 Node 再次被忽视了。
# Kalengo 定制
所以我们最终决定对 keycloak-nodejs-connect 做二次开发,以 Node 后端标准来设计, 我们需要考虑以下情况:
- Node 服务是无状态的,所以不会有 session,所以 token 不会保存在 cookie 中,而是前端负责保存
- 实现最基础的基于角色的访问控制(RBAC)
- Native APP 自定义登陆页面,后端提供登陆接口
根据上述需求,我们重新设计了适配器,放在 @kalengo/keycloak 中, 该适配器参考了 nest-keycloak-connect,
接下来我们会讲解如何接入我们的适配器,完整的项目例子见 nest-keycloak
# 配置 Keycloak Client
在开始之前,你需要对 Keycloak 的 Client 有个基础的认识,Client 代表接入方。
请自行部署 Keycloak,可以使用 Docker 非常方便。
在 Keycloak 后台新建一个 Client,有几点设置要注意,在 Client Settings 中:
- Client Protocal 设置为 openid-connect
- Access Type 设置为 confidential (后续支持自定义资源)
- Direct Access Grants Enabled 设置为 On (支持接口登陆)
Advanced Settings 下面
- Access Token Lifespan 设置为 3 hours (默认时间有点短)
Authentication Flow Overrides 下面
- Browser Flow 设置为 browser
- Direct Grant Flow 设置为 direct grant
完成上述配置后,点击 Client 菜单右侧 Installation,Format Option 选择 JSON, 这段 JSON 就是我们应用需要的配置信息。
# 初始化 Keycloak
把上述 JSON 放入配置文件中
config/dev.js
module.exports = {
port: process.env.PORT || 3000,
schedule: true,
keycloak: {
realm: 'nodejs-example',
'auth-server-url': 'http://keycloak.sso.dev.smart2.cn/auth/',
'ssl-required': 'external',
resource: 'nodejs-apiserver',
'verify-token-audience': false,
credentials: {
secret: 'a6cdcf7c-6e87-4e6d-9e45-4bd6c7b9a0d6'
},
'confidential-port': 0,
'policy-enforcer': {}
}
}
注意:要把 verify-token-audience
改为 false (我也不知道为什么)
# 全局引入
初始化 Keycloak, 在 APP Module 中全局引入我们写好的包
src/app.module.ts
import { Module } from '@nestjs/common'
import { APP_GUARD } from '@nestjs/core'
import { UsersModule } from './users/users.module'
import { KeycloakConnectModule, AuthGuard, RolesGuard } from '@kalengo/keycloak'
@Module({
imports: [KeycloakConnectModule.forRoot({}), UsersModule],
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard
},
{
provide: APP_GUARD,
useClass: RolesGuard
}
]
})
export class ApplicationModule {}
KeycloakConnectModule 包装了官方的适配器 keycloak-nodejs-connect
AuthGuard 负责校验用户的登陆状态,如果用户的 http 请求没有携带 token 或者 token 超时, 该中间件会拦截请求并返回 401 Unauthorized , 这个时候前端应该跳转到自定义的 login 页面
默认是拦截了所有接口的(login),如果你需要给其他接口加上白名单,请期待下一个版本实现。
# 按需引入
全局引入 AuthGuard 会给 E2E 测试带来麻烦,因为 Nest 并不支持覆盖上述写法的全局引入,这样测试就会被授权问题卡住。
为了解决测试问题,可以考虑在 Controller 层按需引入 AuthGuard
@UseGuards(RolesGuard)
@UseGuards(AuthGuard)
export class UsersController {
}
注意:因为 RolesGuard 依赖 AuthGuard 的 token,所以 AuthGuard 必须写在 RolesGuard 下面,顺序不能乱
# 授权
角色控制则通过 RolesGuard 来实现,我们将会在 Controller 来声明哪些接口可以被哪些角色访问
src/users/users.controller.ts
@Controller('users')
export class UsersController {
logger = new Logger(UsersController.name)
constructor(
private readonly usersService: UsersService,
@Inject(KEYCLOAK_INSTANCE)
private readonly keycloak: Keycloak
) {}
@Get()
@Roles('realm:admin')
async findAll(): Promise<number[]> {
return this.usersService.findAll()
}
@Get('/info')
@Roles('realm:user', 'realm:none')
async protect(@Req() req: Request): Promise<string> {
const userInfo = (req as any).user
console.log('userInfo', userInfo)
return 'protect info'
}
}
上述配置的意思是:
- get /users/info 接口允许 user 或 none 这两个角色访问(realm 级别的角色)
- get /users/ 接口只允许 admin 这个角色访问
注意角色必须带上 'realm:' 这个固定前缀, 表示是 realm 级别的角色, 如果是 Client 级别的角色应该是 'client-id:roe' 这种格式
如果用户没有对应权限,请求接口时会得到 406 Not Acceptable 错误
RolesGuard 底层依赖于官方适配器的 keycloak.protect ,更多信息建议阅读:
两份文档
# 获取用户信息
如果需要获取当前登陆用户的信息,可以这样做:
src/users/users.controller.ts
@Get('/info')
async info (@Req() req: Request): Promise<string> {
let userInfo = (req as any).user
console.log('userInfo', userInfo)
return 'protect info'
}
在 Controller 中通过 request 对象获取即可, AuthGuard 已经把 token(jwt) 信息解密出来并放到 request.user 中
# 自定义登陆接口
因为很多项目是前后端分离加上有些前端是 native 的原因,我们需要一个自定义的登陆接口。
@Get('/login')
async login(): Promise<string | { token: string }> {
const username = 'user'
const password = 'password'
try {
const grant = await this.keycloak.grantManager.obtainDirectly(
username,
password
)
// console.info('grant', grant)
const token: string = _.get(grant, 'access_token.token')
this.logger.verbose('access_token', token)
return { token }
} catch (e) {
this.logger.verbose('login fail', e)
return 'login fail'
}
}
这里为了方便测试写死了 username, password,各位实际用的时候改成 post 接口由前端传参即可
前端只要先请求这个接口即可完成登陆,返回值中包含了 token,前端需要保存这个 token, 后续请求后端接口的时候都带上这个 token 即可。
具体做法:
headers: { Authorization:
Bearer ${token}
}
在请求头里设置一个 headers,Key 是 Authorization,值是 Bearer ${token}
# 自动授权(TODO)
Keycloak 管理后台支持定义各种资源和权限,如果要实现管理后台修改权限后,应用自动实现授权,我们还有很多工作要做。
# 使用 Keycloak 自带的登陆页面(TODO)
Keycloak 支持用户初始化后首次登陆修改密码,验证邮箱等操作, 这样自定义登陆接口就不够用了,最好是使用 Keycloak 自带的登陆页面。
# 测试
这里测试有两种方式,连接 Keycloak 和 不 Mock Keycloak,分别介绍一下。
连接 Keycloak 测试
test/main.ts
export async function bootstrapWithAuth() {
// init nestjs
const testModule = await Test.createTestingModule({
imports: [ApplicationModule]
}).compile()
const app = testModule.createNestApplication()
appSettings(app)
await app.init()
const request = supertest(app.getHttpServer())
return { app, request, testModule }
}
不对 AuthGuard 做 mock 处理,在测试文件中引入
test/users/users-auth.e2e-spec.ts
import { bootstrapWithAuth } from '../main'
import * as supertest from 'supertest'
describe('users-auth.e2e-spec.ts', () => {
let request: supertest.SuperTest<supertest.Test>
beforeAll(async function () {
const res = await bootstrapWithAuth()
request = res.request
})
it('not login get 401 ', () => {
return request.get('/users/hello').expect(401)
})
})
这样测试的时候就会请求 Keycloak 做权限验证。
Mock Keycloak 测试
test/main.ts
export async function bootstrapWithAuth() {
// init nestjs
const testModule = await Test.createTestingModule({
imports: [ApplicationModule]
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile()
const app = testModule.createNestApplication()
appSettings(app)
await app.init()
const request = supertest(app.getHttpServer())
return { app, request, testModule }
}
AuthGuard 和 RolesGuard 都执行了 overrideGuard,默认打开所有权限。
再次强调,overrideGuard 不支持全局引入的 AuthGuard,你需要把 AuthGuard 在 Controller 层引入 overrideGuard 才有效,具体做法见上文
test/users/users-mock-auth.e2e-spec.ts
import { request } from '../test-helper'
describe('users-mock-auth.e2e-spec.ts', () => {
it('not login is ok ', () => {
return request.get('/users/hello').expect(200)
})
it('get user is ok', () => {
return request.get('/users/info').expect(200)
})
it('get all user is ok', () => {
return request.get('/users/').expect(200)
})
})
之后的测试就畅通无阻了
# 总结
就这样,引入一个包,设置注解后就可以轻松实现角色访问控制了。
完整的项目例子见 nest-keycloak