ITPub博客

首页 > IT基础架构 > 网络安全 > 在Go中从零实现OpenID Connect (OIDC)

在Go中从零实现OpenID Connect (OIDC)

网络安全 作者:ITPUB社群 时间:1970-01-01 08:33:46 0 删除 编辑

大多数团队直接引入 coreos/go-oidc 就认为万事大吉了。直到遇到非标准提供商、隐蔽的验证绕过,或者因为从未读过规范而无法解释的令牌泄露 bug 时,这种做法就行不通了。从零构建 OIDC 大约需要 300 行 Go 代码——本文逐步讲解每一个步骤。

授权码流程实际做了什么

OIDC 构建在 OAuth 2.0 之上并增加了身份层。OAuth 2.0 处理授权(该客户端能否访问此资源?),而 OIDC 增加了认证:这个用户是谁,我能信任这个声明吗?

授权码流程有四个步骤:

  • 将用户重定向到提供商的授权端点,并携带 state 和 nonce

  • 提供商对用户进行认证,并携带 code 重定向回你的应用

  • 你的服务器用 code 换取令牌——绝不在浏览器端进行,这样密钥始终留在服务端

  • 你验证 id_token JWT——大多数 bug 就出在这里

state 参数是你在重定向前存入会话的随机值。当提供商将用户送回时,你验证它是否匹配——这就是你的 CSRF 防御。nonce 嵌入在 ID token 中,将特定令牌绑定到特定登录会话,防止重放攻击。

获取提供商配置

每个合规的 OIDC 提供商都会在 /.well-known/openid-configuration 发布发现文档。从这里开始——它提供了所有端点,无需硬编码。

type
ProviderMetadata
struct
{
Issuer
string
`json:"issuer"`
AuthorizationEndpoint
string
`json:"authorization_endpoint"`
TokenEndpoint
string
`json:"token_endpoint"`
JWKSURI
string
`json:"jwks_uri"`
}
func
fetchProviderMetadata
(
issuerURL
string
)
(
*
ProviderMetadata
,
error
)
{
url
:=
strings
.
TrimRight
(
issuerURL
,
"/"
)
+
"/.well-known/openid-configuration"
resp
,
err
:=
http
.
Get
(
url
)
if
err
!=
nil
{
return
nil
,
err
}
defer
resp
.
Body
.
Close
()
var
meta
ProviderMetadata
if
err
:=
json
.
NewDecoder
(
resp
.
Body
)
.
Decode
(
&
meta
);
err
!=
nil
{
return
nil
,
err
}
if
meta
.
Issuer
!=
issuerURL
{
return
nil
,
fmt
.
Errorf
(
"issuer mismatch: got %s, want %s"
,
meta
.
Issuer
,
issuerURL
)
}
return
&
meta
,
nil
}

issuer 检查很重要。配置错误或恶意的提供商可能返回声称不同 issuer 的发现文档——尽早捕获这一点可以防止令牌混淆攻击,即来自一个提供商的令牌被另一个提供商接受。

拿到元数据后,构建授权 URL。为 state 和 nonce 生成加密安全的随机值,并在重定向前存入会话:

func
randomBase64
(
n
int
)
(
string
,
error
)
{
b
:=
make
([]
byte
,
n
)
if
_
,
err
:=
rand
.
Read
(
b
);
err
!=
nil
{
return
""
,
err
}
return
base64
.
RawURLEncoding
.
EncodeToString
(
b
),
nil
}
func
buildAuthURL
(
meta
*
ProviderMetadata
,
clientID
,
redirectURI
,
state
,
nonce
string
)
string
{
params
:=
url
.
Values
{}
params
.
Set
(
"response_type"
,
"code"
)
params
.
Set
(
"client_id"
,
clientID
)
params
.
Set
(
"redirect_uri"
,
redirectURI
)
params
.
Set
(
"scope"
,
"openid email profile"
)
params
.
Set
(
"state"
,
state
)
params
.
Set
(
"nonce"
,
nonce
)
return
meta
.
AuthorizationEndpoint
+
"?"
+
params
.
Encode
()
}

不要跳过 nonce。它将 ID token 绑定到你的特定授权请求,防止攻击者将来自不同流程的捕获 ID token 注入到你的流程中。

用 Code 换取令牌

当用户返回到你的重定向 URI 时,验证 state 是否与会话中存储的值匹配,然后将 code POST 到令牌端点:

type
TokenResponse
struct
{
AccessToken
string
`json:"access_token"`
IDToken
string
`json:"id_token"`
TokenType
string
`json:"token_type"`
ExpiresIn
int
`json:"expires_in"`
}
func
exchangeCode
(
meta
*
ProviderMetadata
,
clientID
,
clientSecret
,
code
,
redirectURI
string
)
(
*
TokenResponse
,
error
)
{
data
:=
url
.
Values
{}
data
.
Set
(
"grant_type"
,
"authorization_code"
)
data
.
Set
(
"code"
,
code
)
data
.
Set
(
"redirect_uri"
,
redirectURI
)
data
.
Set
(
"client_id"
,
clientID
)
data
.
Set
(
"client_secret"
,
clientSecret
)
req
,
_
:=
http
.
NewRequest
(
"POST"
,
meta
.
TokenEndpoint
,
strings
.
NewReader
(
data
.
Encode
()))
req
.
Header
.
Set
(
"Content-Type"
,
"application/x-www-form-urlencoded"
)
resp
,
err
:=
http
.
DefaultClient
.
Do
(
req
)
if
err
!=
nil
{
return
nil
,
err
}
defer
resp
.
Body
.
Close
()
if
resp
.
StatusCode
!=
http
.
StatusOK
{
body
,
_
:=
io
.
ReadAll
(
resp
.
Body
)
return
nil
,
fmt
.
Errorf
(
"token endpoint returned %d: %s"
,
resp
.
StatusCode
,
body
)
}
var
tokens
TokenResponse
return
&
tokens
,
json
.
NewDecoder
(
resp
.
Body
)
.
Decode
(
&
tokens
)
}

这是一个机密客户端流程——client_secret 从你的服务器发送到令牌端点。公共客户端(SPA、移动应用)无法安全地持有 client_secret,必须改用 PKCE。切勿从浏览器端代码发送 client_secret。

验证 ID Token

id_token 是由提供方签名的 JWT。大多数手写实现正是在这里出错。验证步骤如下:

  • 解析 JWT 头部,找出用于签名的密钥 ID(kid)

  • 获取提供方的 JWKS 并找到匹配的公钥

  • 使用该密钥验证签名

  • 检查 iss、aud、exp 和 nonce 声明

func
validateIDToken
(
rawToken
,
jwksURI
,
issuer
,
clientID
,
expectedNonce
string
)
(
map
[
string
]
any
,
error
)
{
parts
:=
strings
.
Split
(
rawToken
,
"."
)
if
len
(
parts
)
!=
3
{
return
nil
,
errors
.
New
(
"malformed JWT"
)
}
headerJSON
,
_
:=
base64
.
RawURLEncoding
.
DecodeString
(
parts
[
0
])
var
header
struct
{
Alg
string
`json:"alg"`
Kid
string
`json:"kid"`
}
if
err
:=
json
.
Unmarshal
(
headerJSON
,
&
header
);
err
!=
nil
{
return
nil
,
err
}
if
header
.
Alg
!=
"RS256"
{
return
nil
,
fmt
.
Errorf
(
"unsupported algorithm: %s"
,
header
.
Alg
)
}
key
,
err
:=
fetchPublicKey
(
jwksURI
,
header
.
Kid
)
if
err
!=
nil
{
return
nil
,
err
}
message
:=
parts
[
0
]
+
"."
+
parts
[
1
]
sig
,
_
:=
base64
.
RawURLEncoding
.
DecodeString
(
parts
[
2
])
h
:=
crypto
.
SHA256
.
New
()
h
.
Write
([]
byte
(
message
))
if
err
:=
rsa
.
VerifyPKCS1v15
(
key
,
crypto
.
SHA256
,
h
.
Sum
(
nil
),
sig
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"invalid signature: %w"
,
err
)
}
claimsJSON
,
_
:=
base64
.
RawURLEncoding
.
DecodeString
(
parts
[
1
])
var
claims
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
claimsJSON
,
&
claims
);
err
!=
nil
{
return
nil
,
err
}
if
claims
[
"iss"
]
!=
issuer
{
return
nil
,
fmt
.
Errorf
(
"issuer mismatch"
)
}
if
claims
[
"aud"
]
!=
clientID
{
return
nil
,
fmt
.
Errorf
(
"audience mismatch"
)
}
if
exp
,
ok
:=
claims
[
"exp"
]
.
(
float64
);
!
ok
||
time
.
Now
()
.
Unix
()
>
int64
(
exp
)
{
return
nil
,
fmt
.
Errorf
(
"token expired"
)
}
if
claims
[
"nonce"
]
!=
expectedNonce
{
return
nil
,
fmt
.
Errorf
(
"nonce mismatch"
)
}
return
claims
,
nil
}

几点生产环境注意事项。第一,显式拒绝非 RS256 算法——"alg": "none" 漏洞利用并非理论上的,一些库在历史上曾接受它。第二,按照 Cache-Control TTL 缓存 JWKS 密钥,而不是在每个请求时都去获取。第三,在 exp 检查上添加小的时钟偏差容忍度(30 秒是惯例),以处理服务器之间的轻微时间漂移。第四,如果提供方返回多个受众,规范规定 aud 可以是数组——在你的类型断言中同时处理 string 和 []string。

当开发者遇到边缘情况时,nonce 检查往往是最先被丢弃的。不要丢弃它——正是它防止捕获到的有效令牌在不同用户会话中被重放。

关于安全审计人员所关注的全套 JWT 和 OIDC 验证的参考清单,AYI NEDJIMI Consultants 提供的免费安全加固清单以 PDF 和 Excel 格式涵盖了 OIDC 加固以及 TLS、密钥管理和其他控制措施。


转载声明:本文为转载整理,原文版权归原作者及原站点所有。

原文来源:DEV.to

原创地址:https://dev.to/ayinedjimi-consultants/implementing-openid-connect-oidc-from-scratch-in-go-50p0

原文作者:Ayi NEDJIMI;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2022-11-21

  • 博文量
    1255
  • 访问量
    561116