现状
我在集群中使用Keycloak + kubelgoin + Webhook的认证模式,具体流程如下:
- 在API-Server中将认证模式设置为Webhook模式,由Webhook手动校验OIDC
- 执行
kubectl时,exec命令启动kubelogin,以device-code形式唤起Keycloak进行认证 - 认证完成后Keycloak给出
token,由kubectl将id_token添加到GET请求的Authorization字段发送至API-Server - API-Server向Webhook POST一个
TokenReview,Webhook解析token、验证后根据规则组装成UserInfo发往后续准入环节等
现在项目中引入了Kite作为集群管理面板
Kite的底层是通过K8s-Client向API-Server发送HTTP请求执行相应操作,本质上与kubectl相似,且都需要读取预先配置的kubeconfig文件获取配置上下文,如集群地址、当前集群用户、用户证书等
Kite本身不负责集群身份认证,而是将安全性转移至进入Kite面板的认证。因此Kite虽然原生支持OIDC,但只能支持进入Kite管理界面时的OIDC认证,而非集群内身份认证
也正因这个原因,Kite无法处理kubeconfig中配置了exec参数动态获取认证凭证的用户,在界面中将该用户上下文显示为不可用状态
为了解决这个问题,Kite创建了一个专用的Service Account,并使用其token通过集群认证(将token放入kubeconfig然后被Kite正常读取使用)
需求
我需要二次开发Kite,使Kite支持进行集群内OIDC device-code模式认证
- 用户只需要指定Issuer、client_secret等必要信息,即可提供Keycloak认证界面供用户进行认证
- 认证通过后,需要能够接收Keycloak响应的各种
token - 记录该
token,禁用原kubeconfig配置 - 之后调用
K8s-Client发送API请求时都在Authorization字段附加token - 如果
token过期,则在前端提示用户需要重新进行OIDC认证 - 如果请求被准入环节拦截,需要提示用户根据准入规定重新认证 / 增量认证 / 拒绝请求
准备
使用Postman测试不带Authorization的API请求响应,返回403:
{
"kind": "Status",
"apiVersion": "v1",
"metadata": {},
"status": "Failure",
"message": "nodes is forbidden: User \"system:anonymous\" cannot list resource \"nodes\" in API group \"\" at the cluster scope",
"reason": "Forbidden",
"details": {
"kind": "nodes"
},
"code": 403
}
使用Postman附带Keycloak客户端ID、客户端Secret向$base_url发送认证请求:
{
"device_code": "vfpIiDEl8-M7euodTglJTkbUnKr99l7GGDNQK7If0EM",
"user_code": "PSWY-TSRS",
"verification_uri": "https://192.168.5.96:31443/realms/k8s-auth/device",
"verification_uri_complete": "https://192.168.5.96:31443/realms/k8s-auth/device?user_code=PSWY-TSRS",
"expires_in": 600,
"interval": 5
}
认证完成后尝试拉取Token:
{
"access_token": "<access_token>",
"expires_in": 180,
"refresh_expires_in": 180,
"refresh_token": "<refresh_token>",
"token_type": "Bearer",
"id_token": "<id_token>",
"not-before-policy": 0,
"session_state": "07976fb2-e9ed-afda-8c03-09bfed348abc",
"scope": "openid email profile"
}
将id_token填入GET请求的Authorization字段后,可以正常获得预期响应;
id_token过期后:
{
"kind": "Status",
"apiVersion": "v1",
"metadata": {},
"status": "Failure",
"message": "Unauthorized",
"reason": "Unauthorized",
"code": 401
}
认证与token获取开发
后端
首先在oauth_provider.go中为GenericProvider结构体定义获取认证地址与token的方法:
func (g *GenericProvider) StartDeviceCodeFlow() (*DeviceCodeResponse, error) {
// Construct device code endpoint from token URL
deviceCodeURL := g.TokenURL
if strings.Contains(deviceCodeURL, "/token") {
deviceCodeURL = strings.Replace(deviceCodeURL, "/token", "/auth/device", 1)
}
data := url.Values{}
data.Set("client_id", g.Config.ClientID)
data.Set("client_secret", g.Config.ClientSecret)
data.Set("scope", g.Config.Scopes)
req, err := http.NewRequest("POST", deviceCodeURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
var deviceCodeResp DeviceCodeResponse
if err := json.NewDecoder(resp.Body).Decode(&deviceCodeResp); err != nil {
return nil, err
}
return &deviceCodeResp, nil
}
func (g *GenericProvider) PollToken(deviceCode string) (*TokenResponse, error) {
data := url.Values{}
data.Set("client_id", g.Config.ClientID)
data.Set("client_secret", g.Config.ClientSecret)
data.Set("device_code", deviceCode)
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
req, err := http.NewRequest("POST", g.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, err
}
return &tokenResp, nil
}
然后就可以在auth/handler.go中使用预先配置好的OIDC参数创建GenericProvider实例,传入参数调用相应方法即可:
func (h *AuthHandler) StartDeviceCodeFlow(c *gin.Context) {
// 从配置文件中读取OIDC配置
oidcConfig := GetOIDCConfig()
if oidcConfig == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "加载OIDC配置失败",
})
return
}
issuer := oidcConfig.Issuer
clientID := oidcConfig.ClientID
clientSecret := oidcConfig.ClientSecret
scopes := oidcConfig.Scopes
// 创建Keycloak的GenericProvider实例
oauthProvider, err := NewGenericProvider(model.OAuthProvider{
Name: "keycloak",
ClientID: clientID,
ClientSecret: model.SecretString(clientSecret),
Issuer: issuer,
Scopes: scopes,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to create Keycloak provider: " + err.Error(),
})
return
}
// 启动device-code认证,返回响应
deviceCodeResp, err := oauthProvider.StartDeviceCodeFlow()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to start device code flow: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, deviceCodeResp)
}
func (h *AuthHandler) PollToken(c *gin.Context) {
// 从请求获取device-code码
deviceCode := c.Query("device_code")
if deviceCode == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "device_code parameter is required",
})
return
}
// 加载OIDC配置
oidcConfig := GetOIDCConfig()
if oidcConfig == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "加载OIDC配置失败",
})
return
}
issuer := oidcConfig.Issuer
clientID := oidcConfig.ClientID
clientSecret := oidcConfig.ClientSecret
scopes := oidcConfig.Scopes
// 创建Keycloak的GenericProvider实例
oauthProvider, err := NewGenericProvider(model.OAuthProvider{
Name: "keycloak",
ClientID: clientID,
ClientSecret: model.SecretString(clientSecret),
Issuer: issuer,
Scopes: scopes,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to create Keycloak provider: " + err.Error(),
})
return
}
// 请求token,返回响应
tokenResp, err := oauthProvider.PollToken(deviceCode)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, tokenResp)
}
最后,在启动文件main.go中定义后端API:
authHandler := auth.NewAuthHandler()
authGroup := r.Group("/api/auth")
{
authGroup.GET("/providers", authHandler.GetProviders)
authGroup.POST("/login/password", authHandler.PasswordLogin)
authGroup.GET("/login", authHandler.Login)
authGroup.GET("/callback", authHandler.Callback)
authGroup.POST("/logout", authHandler.Logout)
authGroup.POST("/refresh", authHandler.RefreshToken)
authGroup.GET("/user", authHandler.RequireAuth(), authHandler.GetUser)
// OIDC Device Code Flow
authGroup.GET("/oidc/device-code", authHandler.StartDeviceCodeFlow)
authGroup.GET("/oidc/token", authHandler.PollToken)
}
只要前端请求/api/auth/oidc/*,即可获取到相应消息