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

Enhancing OIDC Plugin's Security and Performance. #697

Open
Fkbqf opened this issue Dec 14, 2023 · 20 comments · May be fixed by #1049
Open

Enhancing OIDC Plugin's Security and Performance. #697

Fkbqf opened this issue Dec 14, 2023 · 20 comments · May be fixed by #1049

Comments

@Fkbqf
Copy link
Contributor

Fkbqf commented Dec 14, 2023

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
Copy link
Collaborator

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

@Fkbqf
Copy link
Contributor Author

Fkbqf commented Jan 24, 2024

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

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,内容为随机字符串(例如 csrfcookie=v8g9e4ksfhw)。
  • 在前端向后端发起请求时,取出 Cookie,并添加到 URL 的参数中(接上例 POST <https://www.a.com/comment?csrfcookie=v8g9e4ksfhw)。>
  • 后端接口验证 Cookie 中的字段与 URL 参数中的字段是否一致,不一致则拒绝。

用双重 Cookie 防御 CSRF 的优点:

  • 在用户访问网站页面时,向请求域名注入一个Cookie,内容为随机字符串(例如cookie=cs2abv34dc45aw)。
  • 在前端向后端发起请求时,取出Cookie,并添加到URL的参数中(接上例POST https://www.a.com/comment?csrfcookie=cs2abv34dc45aw)。(做法和state参数类似)
  • 后端接口验证Cookie中的字段与URL参数中的字段是否一致,不一致则拒绝。

优点

  • 无需使用 Session,适用面更广,易于实施。
  • Token 储存在客户端中,不会给服务器带来压力。
  • 相对于 Token,实施成本更低,可以在前后端统一拦截校验,而不需要一个个接口和页面添加。

缺点:

  • Cookie 中增加了额外的字段。
  • 如果有其他漏洞(例如 XSS)(防御XSS:对于存储在Cookie中的数据,使用HttpOnly标志,使其对JavaScript不可访问。),攻击者可以注入 Cookie,那么该防御方式失效。
  • 难以做到子域名的隔离。
  • 为了确保 Cookie 传输安全,采用这种防御方式的最好确保用整站 HTTPS 的方式,如果还没切 HTTPS 的使用这种方式也会有风险。

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

一,防止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](

    for _, val := range res.Get("keys").Array() {
    )

@johnlanni
Copy link
Collaborator

@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
Copy link
Collaborator

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

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

@Fkbqf
Copy link
Contributor Author

Fkbqf commented Jan 26, 2024

  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
Copy link
Collaborator

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

@johnlanni
Copy link
Collaborator

可以将“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
Copy link
Collaborator

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

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

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

@johnlanni
Copy link
Collaborator

可以参考下 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")
	}
}

@johnlanni johnlanni added the help wanted Extra attention is needed label Feb 20, 2024
@cx2c
Copy link

cx2c commented Feb 20, 2024

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

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

@cx2c
Copy link

cx2c commented Feb 20, 2024

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

@johnlanni
Copy link
Collaborator

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

@cx2c
Copy link

cx2c commented Feb 21, 2024

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

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

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

@cx2c
Copy link

cx2c commented Feb 27, 2024

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

@johnlanni
Copy link
Collaborator

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

@cx2c
Copy link

cx2c commented Feb 27, 2024

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
Copy link
Collaborator

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

@cx2c
Copy link

cx2c commented Feb 28, 2024

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

@Jing-ze Jing-ze linked a pull request Jun 21, 2024 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Todo
Development

Successfully merging a pull request may close this issue.

4 participants