Open Fkbqf opened 8 months ago
@Fkbqf 可以先整理下实现思路,周会讨论下,不急着编码
<input type="hidden" name="csrftoken" value="tokenvalue"/>
。Token 是一个比较有效的 CSRF 防护方法,只要页面没有 XSS 漏洞泄露 Token,那么接口的 CSRF 攻击就无法成功,存储也会有压力,对于 wasm 插件来说找不到一个比较好的存储的方法。
但是此方法的实现比较复杂,需要给每一个页面都写入 Token(前端无法使用纯静态页面),每一个 Form 及 Ajax 请求都携带这个 Token,后端对每一个接口都进行校验,并保证页面 Token 及请求 Token 一致。这就使得这个防护策略不能在通用的拦截上统一拦截处理,而需要每一个页面和接口都添加对应的输出和校验。
csrfcookie=v8g9e4ksfhw
)。POST <https://www.a.com/comment?csrfcookie=v8g9e4ksfhw
)。>用双重 Cookie 防御 CSRF 的优点:
cookie=cs2abv34dc45aw
)。POST https://www.a.com/comment?csrfcookie=cs2abv34dc45aw
)。(做法和state参数类似)优点
缺点:
倾向于第二种,因为第一种可能存储压力过大,将存储的东西都放到客户端
核心思路:使用一个临时的cookie
生成临时的cookie
state, err := encryption.Nonce(32)
if err != nil {
return nil, err
}
nonce, err := encryption.Nonce(32)
if err != nil {
return nil, err
}
return &check {
OAuthState: state,//未hashed的原值
OIDCNonce: nonce,
CodeVerifier: codeVerifier,
cookieOpts: opts,
}
callbackRedirect := getOAuthRedirectURI(req) // 回掉到插件验证的地址
loginURL := GetLoginURL(
callbackRedirect, //回掉到插件验证的地址
encodeState(check.HashOAuthState(), appRedirect), //在url中,会回传回来的state参数,将需要回调到应用的地址
check.HashOIDCNonce(), // 放到token里面的noce
extraParams,
)
if _, err := check.SetCookie(rw, req); err != nil { // 设置成临时cookie
}
http.Redirect(rw, req, loginURL, http.StatusFound) // 跳转
重新加载出来csrf结构体里面的是hashed的state和nonce
check, err := cookies.LoadcheckCookie(req, CookieOptions)
check,.ClearCookie(rw, req)//清除cookie,设置成过期
nonce, appRedirect, err := decodeState(req) //获取url回传回来的state参数 ,解码就是hashed以后的noce 和回传到用户app地址
if !check,.CheckOAuthState(nonce) { //检验 hashed以后 之前保存的未hash的noce 经过hash
// 以后是否相等
}
客户端存储:
原始 nonce 存储在用户浏览器的 Cookie 中:这是为了在后期能够从同一客户端验证请求。 哈希后的 nonce 在请求的 state 参数中传递:这是在进行 OAuth 登录或类似的跨站点请求时,在客户端和服务端之间安全传递 state,不是存储在客户端。 服务端验证:
服务端不会存储原始的 nonce;而是在用户发送请求时,服务端会从用户的 Cookie 中读取原始 nonce,并在服务端进行哈希处理。 然后,服务端使用这个刚刚计算的哈希值与用户在请求中传递的 state 参数(哈希后的 nonce)进行比较,,与时效性校验。
对于第一次校验后的常用地址信息和密钥,可以将其存储在与令牌一起的 cookie 中,这些信息很少变化。将过期时间设置与 cookie 相同,这样在后续的校验中就无需发起网络请求。
当用户再次访问时,首先检查cookie中的信息。
比如这些地方
@Fkbqf 你说的第一种CSRF防御方式跟OIDC本身没什么关系,这个是web业务自身防范要做的事情。OIDC容易被CSRF攻击主要在于使用code换取token的机制,可以被攻击者用于将自己的三方平台账号跟被攻击者的站点登陆账号关联,从而用自己的三方平台账号轻而易举地获取到用户在该站点的个人信息。可以参考这篇文章介绍的攻击方式,举了一个用攻击者的微信账号拿到被攻击者极客时间账号信息的例子。 要解决这个问题,本质就是要引入状态机制,你上面方案中的nonce方式是可行的,但是有两个问题:
@Fkbqf 关于减少完整一次验证之后的的网络请求,我们理解是一致,现在插件最大的问题是,每次验证都需要去请求/.well-known/openid-configuration来获取公钥信息,然后对jwt进行验证,这样显然是不合理的。会导致业务请求延时增加,而且网关CPU浪费。
借鉴一下Kong oidc插件的逻辑架构图,业界都是这样的做法:
另外将cookie的过期时间与令牌过期时间保持一致,其实也是不合理的,因为大部分OIDC Provider签发的令牌时间都比较短,如果cookie很快过期,又要重新走一遍第一次校验的过程,对用户体验影响是很大的。这就引出必须增加 refresh token 的机制,在 token 即将过期时进行 refresh,而 cookie 的过期时间应该允许让用户配置一个比较大的值。
- 应该使用state参数而不是nonce参数,可以参考Auth0对这块的state参数说明,如果在Authorize阶段传递了state参数,那么后面通过code获取token阶段,也必须传正确的state参数,才能拿到token,Auth0,Keycloak等OIDC Provider都实现了这样的机制
这里我没有表达清楚,我这里表达的nonce是 未经过hash的一次性数字,是一个意思
2. state参数应该加签存储在cookie中,而不是直接在cookie中存储明文,这样可以防止state通过url参数被泄漏后,攻击者进行伪造
先明确一下“state”参数的语义在oidc中是一个专门的query参数,用于URL中的回跳检验。
@Fkbqf hash和加签是一个意思,目的是加密,并用于后续计算签名进行对比是否一致。
可以将“state”拼接成类似JWT的格式
一般state都是一个比较短的随机字符串,虽然Auth0没有限制长度,但不同OIDC Provider实现不一样,建议还是参考业界通用的做法
我理解你是想生成一个JWT token作为state,里面包含nonce和appredirect,在code换token环节,从state参数中提取出nonce进行hash,然后跟用户cookie中存储的hash过的nonce进行对比。但我没明白在state里存放appredirct的目的是什么?
简化成用随机字符串的state,然后hash后存储到cookie,再在code换token环节对state进行hash进行对比,也能满足需求。
appredirect是为了能重定向到用户首次触发跳转到页面对吧。
我看了oauth2proxy也用到了这个机制: https://github.com/oauth2-proxy/oauth2-proxy/blob/509287b555c295168a19ead4e29efcd0ed5a54b4/oauthproxy.go#L803
不过只是用冒号分割,分别存储hash后的state和appredirect
可以参考下 oauth2proxy 的 callback 流程,简单清晰
func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
remoteAddr := ip.GetClientString(p.realClientIPParser, req, true)
// finish the oauth cycle
err := req.ParseForm()
if err != nil {
logger.Errorf("Error while parsing OAuth2 callback: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
errorString := req.Form.Get("error")
if errorString != "" {
logger.Errorf("Error while parsing OAuth2 callback: %s", errorString)
message := fmt.Sprintf("Login Failed: The upstream identity provider returned an error: %s", errorString)
// Set the debug message and override the non debug message to be the same for this case
p.ErrorPage(rw, req, http.StatusForbidden, message, message)
return
}
// 从 cookie 中获取 csrf 信息,主要包含 state(用于oidc状态管理) ,nonce(用于防重放)以及 codeverify(用于传递给provider用code换token) 三部分;
// 上述信息都是在 cookie 中加密存储,使用配置中的 cookie-secret 进行加密,并在这里进行解密获取
csrf, err := cookies.LoadCSRFCookie(req, p.CookieOptions)
if err != nil {
logger.Println(req, logger.AuthFailure, "Invalid authentication via OAuth2. Error while loading CSRF cookie:", err.Error())
p.ErrorPage(rw, req, http.StatusForbidden, err.Error(), "Login Failed: Unable to find a valid CSRF token. Please try again.")
return
}
// 调用 provider 的 code 换 token 流程,不同 provider 可能实现不一样
session, err := p.redeemCode(req, csrf.GetCodeVerifier())
if err != nil {
logger.Errorf("Error redeeming code during OAuth2 callback: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
// 允许 provider 往 token 中设置一些附加信息
err = p.enrichSessionState(req.Context(), session)
if err != nil {
logger.Errorf("Error creating session during OAuth2 callback: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
// 已经用 code 换到了 token,csrf cookie 已经没用了,进行清理
csrf.ClearCookie(rw, req)
// 从 state 参数中解析出 state(hash后的) 和 appRedirect
nonce, appRedirect, err := decodeState(req.Form.Get("state"), p.encodeState)
if err != nil {
logger.Errorf("Error while parsing OAuth2 state: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
// 将 csrf 中的未经hash的state(从cookie中对称解密得到)进行hash计算,对比state参数中解析出的hash值,判断一致
if !csrf.CheckOAuthState(nonce) {
logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: CSRF token mismatch, potential attack")
p.ErrorPage(rw, req, http.StatusForbidden, "CSRF token mismatch, potential attack", "Login Failed: Unable to find a valid CSRF token. Please try again.")
return
}
// 将防重放的nonce设置到session中
csrf.SetSessionNonce(session)
// 根据openid-configuration中提供的jwks信息对jwt进行校验,避免provider签发了错误的jwt,提前识别错误
// 由provider的实现自己决定要不要校验防重放nonce,因为有的proivder会将nonce放到jwt token的claim中
if !p.provider.ValidateSession(req.Context(), session) {
logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Session validation failed: %s", session)
p.ErrorPage(rw, req, http.StatusForbidden, "Session validation failed")
return
}
// 检查重定向地址是否合法,不合法则定向到根路径
if !p.redirectValidator.IsValidRedirect(appRedirect) {
appRedirect = "/"
}
// 在完成认证的基础上继续做鉴权,查看token中的group claim,这样可以允许用户配置只有特定 role 的权限
authorized, err := p.provider.Authorize(req.Context(), session)
if err != nil {
logger.Errorf("Error with authorization: %v", err)
}
if p.Validator(session.Email) && authorized {
logger.PrintAuthf(session.Email, req, logger.AuthSuccess, "Authenticated via OAuth2: %s", session)
// 存储session信息到cookie里,用于下次请求业务接口时判断正确后直接放行,我们可以参考这里Minimal的方式,去掉session中的access token/id token/refresh token
err := p.SaveSession(rw, req, session)
if err != nil {
logger.Errorf("Error saving session state for %s: %v", remoteAddr, err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
// 完成首次认证和鉴权,将用户重定向到最初访问的页面
http.Redirect(rw, req, appRedirect, http.StatusFound)
} else {
logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: unauthorized")
p.ErrorPage(rw, req, http.StatusForbidden, "Invalid session: unauthorized")
}
}
我们自有的系统想使用"higress"作为微服务网关,接入OIDC进行认证,这样就可以借助higress网关实现单点登录。我们自有的用户系统支持SAML和OAuth协议。 我看了下JWT Auth好像不能满足我们的需求,请问OIDC的插件支持什么时间可以release。
我看阿里云已经支持了 https://help.aliyun.com/zh/mse/user-guide/configure-oidc-authentication?spm=a2c4g.11186623.0.i1
@cx2c 开源会通过wasm插件提供out-of-box的方案,这个插件目前还需要进行一些优化和重构,目前等待认领中,欢迎有兴趣的同学认领
我们还在调研阶段,我看1.3版本是提供了oidc插件的,现在是又下架了么, 旧版本的oidc插件可以放开来试用么。
@cx2c 这个插件现在有安全和性能问题,你可以试用下,需要自己编译插件
试了下,开启插件会500,日志[Envoy (Epoch 0)] [2024-02-21 05:01:14.411][24][error][wasm] wasm log higress-system.oidc-1.0.0: [oidc] ProcessRedirect error : error status returned by host: bad argument
service_domain 和 service_name 我该怎么配置呢。日志是这样的
我怀疑是我配置错了,导致/.well-known/openid-configuration 路径没有拼对
@cx2c 你的服务通过mcpbridge绑定来源了吗 另外,可能需要设置下这个helm参数: --set global.onlyPushRouteCluster=false
否则不会推送没出现在路由里的服务
我试了市面上流行的 auth0、authing、casdoor等都不行,问题各异,看来这个插件是需要重构
https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/oidc/doc/Oidc.md @cx2c keycloak和okta测试过是可以的,可能是你使用方式的问题
auth0是按照文档配置的,提示 call failed with status code err_info: upstream connect error or disconnect/reset before headers. reset reason: connection failure, transport failure reason: TLS error: 268435703:SSL routines:OPENSSL_internal:WRONG_VERSION_NUMBER 。 casdoor 认证成功后一直跳302,大致看了下,没看懂cookie是怎么写入的😭。 authing 登录成功后跳转 /oauth2/callback 显示404。
@cx2c 看下oauth2 proxy能满足你的需求么,后面插件会重构完全基于oauth2 proxy
看了下 oauth2 proxy应该可以满足我们需求。 又看了下这个插件,定位到了我的问题出在哪里,认证和交换token都是正常的,最后一步写Set-Cookie的时候遇到问题了,我试着把nonce关闭也不行,看了这个 _oidc_wasm value的生成逻辑,感觉长度肯定会超过4096,我水平有限,先不折腾了😭。
Record the following necessary changes: 1.Avoid CSRF attacks. 2.Avoid initiating a network request with every request. 3.Support refresh tokens. 4.Support a logout endpoint.