alibaba / higress

🤖 AI Gateway | AI Native API Gateway
https://higress.io
Apache License 2.0
2.76k stars 453 forks source link

Enhancing OIDC Plugin's Security and Performance. #697

Open Fkbqf opened 8 months ago

Fkbqf commented 8 months ago

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.

johnlanni commented 8 months ago

@Fkbqf 可以先整理下实现思路,周会讨论下,不急着编码

Fkbqf commented 7 months ago

对于第一个问题 有两种常见方案

CSRF Token

  1. 在服务器端生成 CSRF Token。
  2. 将 CSRF Token 输出到页面:
    • 在 HTML 页面中的所有表单(form)和链接(a标签)中嵌入此 Token。
    • 对于表单使用隐藏域 <input type="hidden" name="csrftoken" value="tokenvalue"/>
    • 对于链接,将 Token 作为查询参数附加到 URL 上。
  3. 要求所有修改服务器状态的请求附带 CSRF Token:
    • 页面通过 POST/GET 请求提交数据时必须包含 CSRF Token。
  4. 在服务器端验证 CSRF Token:
    • 验证请求中的 Token 是否存在且有效。
    • 检查它与会话中存储的 Token 是否相匹配。
    • 如果不匹配或者 Token 缺失,拒绝请求并记录异常。

Token 是一个比较有效的 CSRF 防护方法,只要页面没有 XSS 漏洞泄露 Token,那么接口的 CSRF 攻击就无法成功,存储也会有压力,对于 wasm 插件来说找不到一个比较好的存储的方法。

但是此方法的实现比较复杂,需要给每一个页面都写入 Token(前端无法使用纯静态页面),每一个 Form 及 Ajax 请求都携带这个 Token,后端对每一个接口都进行校验,并保证页面 Token 及请求 Token 一致。这就使得这个防护策略不能在通用的拦截上统一拦截处理,而需要每一个页面和接口都添加对应的输出和校验。

双重 Cookie 验证

用双重 Cookie 防御 CSRF 的优点:

优点

缺点:

倾向于第二种,因为第一种可能存储压力过大,将存储的东西都放到客户端

一,防止csrf攻击

核心思路:使用一个临时的cookie

  1. 生成 Nonce:
    • 用户开始请求。
    • 系统生成一个唯一的、随机的 nonce。
  2. 设置临时 Cookie:
    • 将生成的 nonce 作为临时 cookie 。
  3. 存储哈希化的 Nonce:
    • 在服务器端,对 nonce 应用哈希函数(如 SHA-256)。
    • 将未哈希的原始 nonce 值放到cookie中。
  4. 传递哈希值:
    • 将哈希化的 nonce 值包含在重定向 URL 中。
  5. 用户浏览器跳转:
    • 用户浏览器根据 URL 进行跳转。
  6. 验证 Nonce:
    • 用户从跳转返回。
    • 从用户浏览器的临时 cookie 中提取原始 nonce。
    • 在服务器端对提取的 nonce 进行哈希处理。
    • 比较处理后的哈希值与存储的哈希值是否匹配。
  7. 完成操作:
    • 如果哈希值匹配,继续操作。
    • 如果不匹配,拒绝操访问。

生成临时的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 相同,这样在后续的校验中就无需发起网络请求。

  1. 用户完成首次验证。
  2. 将验证后的常用地址信息和密钥以加密形式存储在cookie中。
  3. 设置cookie的过期时间,与令牌(token)的有效期一致。
  4. 当用户再次访问时,首先检查cookie中的信息。

    • 如果cookie未过期,从中提取地址信息和密钥进行快速验证,无需发起新网络请求;
    • 如果cookie已过期,让用户重新验证,获取新的信息和令牌,并更新cookie。

    比如这些地方

    h[ttps://github.com/alibaba/higress/blob/90f89cf588b9a30c0965ca89c406cc319d8671f9/plugins/wasm-go/extensions/oidc/oc/provider.go#L225](https://github.com/alibaba/higress/blob/90f89cf588b9a30c0965ca89c406cc319d8671f9/plugins/wasm-go/extensions/oidc/oc/provider.go#L225)

johnlanni commented 6 months ago

@Fkbqf 你说的第一种CSRF防御方式跟OIDC本身没什么关系,这个是web业务自身防范要做的事情。OIDC容易被CSRF攻击主要在于使用code换取token的机制,可以被攻击者用于将自己的三方平台账号跟被攻击者的站点登陆账号关联,从而用自己的三方平台账号轻而易举地获取到用户在该站点的个人信息。可以参考这篇文章介绍的攻击方式,举了一个用攻击者的微信账号拿到被攻击者极客时间账号信息的例子。 要解决这个问题,本质就是要引入状态机制,你上面方案中的nonce方式是可行的,但是有两个问题:

  1. 应该使用state参数而不是nonce参数,可以参考Auth0对这块的state参数说明,如果在Authorize阶段传递了state参数,那么后面通过code获取token阶段,也必须传正确的state参数,才能拿到token,Auth0,Keycloak等OIDC Provider都实现了这样的机制
  2. state参数应该加签存储在cookie中,而不是直接在cookie中存储明文,这样可以防止state通过url参数被泄漏后,攻击者进行伪造
johnlanni commented 6 months ago

@Fkbqf 关于减少完整一次验证之后的的网络请求,我们理解是一致,现在插件最大的问题是,每次验证都需要去请求/.well-known/openid-configuration来获取公钥信息,然后对jwt进行验证,这样显然是不合理的。会导致业务请求延时增加,而且网关CPU浪费。 借鉴一下Kong oidc插件的逻辑架构图,业界都是这样的做法: image

另外将cookie的过期时间与令牌过期时间保持一致,其实也是不合理的,因为大部分OIDC Provider签发的令牌时间都比较短,如果cookie很快过期,又要重新走一遍第一次校验的过程,对用户体验影响是很大的。这就引出必须增加 refresh token 的机制,在 token 即将过期时进行 refresh,而 cookie 的过期时间应该允许让用户配置一个比较大的值。

Fkbqf commented 6 months ago
  1. 应该使用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中的回跳检验。

  1. 这个“加签存储的state”应该我理解 是“加签存储的一个未经过hash的一次性数字”。
  2. 可以将“state”拼接成类似JWT的格式,暂时由“hashedNoce”+“appredirct”组成也可以把过期时间也拼接进去,就是由1中的未经过hash的一次性数字加一定特殊信息经过hash加密 得到的hashednonce与 用户想要验证完成后跳转的地址,这两个都有匹配性。
  3. 从一个存活时间很短的加密签名cookie中,可以取出这个未经hash处理的一次性数字。
  4. 然后我们再经过利用这个一次性数字和特殊信息,校验得到的两个hashed值是否相等,校验appredict是不是与用户匹配的重定向地址
johnlanni commented 6 months ago

@Fkbqf hash和加签是一个意思,目的是加密,并用于后续计算签名进行对比是否一致。

johnlanni commented 6 months ago

可以将“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进行对比,也能满足需求。

johnlanni commented 6 months ago

appredirect是为了能重定向到用户首次触发跳转到页面对吧。

我看了oauth2proxy也用到了这个机制: https://github.com/oauth2-proxy/oauth2-proxy/blob/509287b555c295168a19ead4e29efcd0ed5a54b4/oauthproxy.go#L803

不过只是用冒号分割,分别存储hash后的state和appredirect

johnlanni commented 6 months ago

可以参考下 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")
    }
}
cx2c commented 6 months ago

我们自有的系统想使用"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

johnlanni commented 6 months ago

@cx2c 开源会通过wasm插件提供out-of-box的方案,这个插件目前还需要进行一些优化和重构,目前等待认领中,欢迎有兴趣的同学认领

cx2c commented 6 months ago

我们还在调研阶段,我看1.3版本是提供了oidc插件的,现在是又下架了么, 旧版本的oidc插件可以放开来试用么。

johnlanni commented 6 months ago

@cx2c 这个插件现在有安全和性能问题,你可以试用下,需要自己编译插件

cx2c commented 6 months ago

试了下,开启插件会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

image

service_domain 和 service_name 我该怎么配置呢。日志是这样的

image

我怀疑是我配置错了,导致/.well-known/openid-configuration 路径没有拼对

johnlanni commented 6 months ago

@cx2c 你的服务通过mcpbridge绑定来源了吗 另外,可能需要设置下这个helm参数: --set global.onlyPushRouteCluster=false

否则不会推送没出现在路由里的服务

cx2c commented 5 months ago

我试了市面上流行的 auth0、authing、casdoor等都不行,问题各异,看来这个插件是需要重构

johnlanni commented 5 months ago

https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/oidc/doc/Oidc.md @cx2c keycloak和okta测试过是可以的,可能是你使用方式的问题

cx2c commented 5 months ago

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。

johnlanni commented 5 months ago

@cx2c 看下oauth2 proxy能满足你的需求么,后面插件会重构完全基于oauth2 proxy

cx2c commented 5 months ago

看了下 oauth2 proxy应该可以满足我们需求。 又看了下这个插件,定位到了我的问题出在哪里,认证和交换token都是正常的,最后一步写Set-Cookie的时候遇到问题了,我试着把nonce关闭也不行,看了这个 _oidc_wasm value的生成逻辑,感觉长度肯定会超过4096,我水平有限,先不折腾了😭。

image