You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

722 lines
32 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 26 | IAM项目是如何设计和实现访问认证功能的
你好,我是孔令飞。
上一讲我们学习了应用认证常用的四种方式Basic、Digest、OAuth、Bearer。这一讲我们再来看下IAM项目是如何设计和实现认证功能的。
IAM项目用到了Basic认证和Bearer认证。其中Basic认证用在前端登陆的场景Bearer认证用在调用后端API服务的场景下。
接下来我们先来看下IAM项目认证功能的整体设计思路。
## 如何设计IAM项目的认证功能
在认证功能开发之前我们要根据需求认真考虑下如何设计认证功能并在设计阶段通过技术评审。那么我们先来看下如何设计IAM项目的认证功能。
首先,我们要**梳理清楚认证功能的使用场景和需求**。
* IAM项目的iam-apiserver服务提供了IAM系统的管理流功能接口它的客户端可以是前端这里也叫控制台也可以是App端。
* 为了方便用户在Linux系统下调用IAM项目还提供了iamctl命令行工具。
* 为了支持在第三方代码中调用iam-apiserver提供的API接口还支持了API调用。
* 为了提高用户在代码中调用API接口的效率IAM项目提供了Go SDK。
可以看到iam-apiserver有很多客户端每种客户端适用的认证方式是有区别的。
控制台、App端需要登录系统所以需要使用`用户名:密码`这种认证方式也即Basic认证。iamctl、API调用、Go SDK因为可以不用登录系统所以可以采用更安全的认证方式Bearer认证。同时Basic认证作为iam-apiserver已经集成的认证方式仍然可以供iamctl、API调用、Go SDK使用。
这里有个地方需要注意如果iam-apiserver采用Bearer Token的认证方式目前最受欢迎的Token格式是JWT Token。而JWT Token需要密钥后面统一用secretKey来指代因此需要在iam-apiserver服务中为每个用户维护一个密钥这样会增加开发和维护成本。
业界有一个更好的实现方式将iam-apiserver提供的API接口注册到API网关中通过API网关中的Token认证功能来实现对iam-apiserver API接口的认证。有很多API网关可供选择例如腾讯云API网关、Tyk、Kong等。
这里需要你注意通过iam-apiserver创建的密钥对是提供给iam-authz-server使用的。
另外我们还需要调用iam-authz-server提供的RESTful API接口`/v1/authz`来进行资源授权。API调用比较适合采用的认证方式是Bearer认证。
当然,`/v1/authz`也可以直接注册到API网关中。在实际的Go项目开发中也是我推荐的一种方式。但在这里为了展示实现Bearer认证的过程iam-authz-server自己实现了Bearer认证。讲到iam-authz-server Bearer认证实现的时候我会详细介绍这一点。
Basic认证需要用户名和密码Bearer认证则需要密钥所以iam-apiserver需要将用户名/密码、密钥等信息保存在后端的MySQL中持久存储起来。
在进行认证的时候需要获取密码或密钥进行反加密这就需要查询密码或密钥。查询密码或密钥有两种方式。一种是在请求到达时查询数据库。因为数据库的查询操作延时高会导致API接口延时较高所以不太适合用在数据流组件中。另外一种是将密码或密钥缓存在内存中这样请求到来时就可以直接从内存中查询从而提升查询速度提高接口性能。
但是将密码或密钥缓存在内存中时就要考虑内存和数据库的数据一致性这会增加代码实现的复杂度。因为管控流组件对性能延时要求不那么敏感而数据流组件则一定要实现非常高的接口性能所以iam-apiserver在请求到来时查询数据库而iam-authz-server则将密钥信息缓存在内存中。
那在这里可以总结出一张IAM项目的认证设计图
![](https://static001.geekbang.org/resource/image/7e/b6/7eed8e2364d358a8483c671d972fd2b6.jpg?wh=2248x1094)
另外为了将控制流和数据流区分开来密钥的CURD操作也放在了iam-apiserver中但是iam-authz-server需要用到这些密钥信息。为了解决这个问题目前的做法是
* iam-authz-server通过gRPC API请求iam-apiserver获取所有的密钥信息
* 当iam-apiserver有密钥更新时会Pub一条消息到Redis Channel中。因为iam-authz-server订阅了同一个Redis Channeliam-authz-searver监听到channel有新消息时会获取、解析消息并更新它缓存的密钥信息。这样我们就能确保iam-authz-server内存中缓存的密钥和iam-apiserver中的密钥保持一致。
学到这里你可能会问将所有密钥都缓存在iam-authz-server中那岂不是要占用很大的内存别担心这个问题我也想过并且替你计算好了8G的内存大概能保存约8千万个密钥信息完全够用。后期不够用的话可以加大内存。
不过这里还是有个小缺陷如果Redis down掉或者出现网络抖动可能会造成iam-apiserver中和iam-authz-server内存中保存的密钥数据不一致但这不妨碍我们学习认证功能的设计和实现。至于如何保证缓存系统的数据一致性我会在新一期的特别放送里专门介绍下。
最后注意一点Basic 认证请求和 Bearer 认证请求都可能被截获并重放。所以为了确保Basic认证和Bearer认证的安全性**和服务端通信时都需要配合使用HTTPS协议**。
## IAM项目是如何实现Basic认证的
我们已经知道IAM项目中主要用了Basic 和 Bearer 这两种认证方式。我们要支持Basic认证和Bearer认证并根据需要选择不同的认证方式这很容易让我们想到使用设计模式中的策略模式来实现。所以在IAM项目中我将每一种认证方式都视作一个策略通过选择不同的策略来使用不同的认证方法。
IAM项目实现了如下策略
* [auto策略](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/auto.go)该策略会根据HTTP头`Authorization: Basic XX.YY.ZZ`和`Authorization: Bearer XX.YY.ZZ`自动选择使用Basic认证还是Bearer认证。
* [basic策略](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/basic.go)该策略实现了Basic认证。
* [jwt策略](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/jwt.go)该策略实现了Bearer认证JWT是Bearer认证的具体实现。
* [cache策略](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/cache.go)该策略其实是一个Bearer认证的实现Token采用了JWT格式因为Token中的密钥ID是从内存中获取的所以叫Cache认证。这一点后面会详细介绍。
iam-apiserver通过创建需要的认证策略并加载到需要认证的API路由上来实现API认证。具体代码如下
```
jwtStrategy, _ := newJWTAuth().(auth.JWTStrategy)
g.POST("/login", jwtStrategy.LoginHandler)
g.POST("/logout", jwtStrategy.LogoutHandler)
// Refresh time can be longer than token timeout
g.POST("/refresh", jwtStrategy.RefreshHandler)
```
上述代码中,我们通过[newJWTAuth](https://github.com/marmotedu/iam/blob/75b978b722f0af3d6aefece3f9668269be3f5b2e/internal/apiserver/auth.go#L59)函数创建了`auth.JWTStrategy`类型的变量,该变量包含了一些认证相关函数。
* LoginHandler实现了Basic认证完成登陆认证。
* RefreshHandler重新刷新Token的过期时间。
* LogoutHandler用户注销时调用。登陆成功后如果在Cookie中设置了认证相关的信息执行LogoutHandler则会清空这些信息。
下面我来分别介绍下LoginHandler、RefreshHandler和LogoutHandler。
1. LoginHandler
这里我们来看下LoginHandler Gin中间件该函数定义位于`github.com/appleboy/gin-jwt`包的[auth\_jwt.go](https://github.com/appleboy/gin-jwt/blob/v2.6.4/auth_jwt.go#L431)文件中。
```
func (mw *GinJWTMiddleware) LoginHandler(c *gin.Context) {
if mw.Authenticator == nil {
mw.unauthorized(c, http.StatusInternalServerError, mw.HTTPStatusMessageFunc(ErrMissingAuthenticatorFunc, c))
return
}
data, err := mw.Authenticator(c)
if err != nil {
mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c))
return
}
// Create the token
token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm))
claims := token.Claims.(jwt.MapClaims)
if mw.PayloadFunc != nil {
for key, value := range mw.PayloadFunc(data) {
claims[key] = value
}
}
expire := mw.TimeFunc().Add(mw.Timeout)
claims["exp"] = expire.Unix()
claims["orig_iat"] = mw.TimeFunc().Unix()
tokenString, err := mw.signedString(token)
if err != nil {
mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrFailedTokenCreation, c))
return
}
// set cookie
if mw.SendCookie {
expireCookie := mw.TimeFunc().Add(mw.CookieMaxAge)
maxage := int(expireCookie.Unix() - mw.TimeFunc().Unix())
if mw.CookieSameSite != 0 {
c.SetSameSite(mw.CookieSameSite)
}
c.SetCookie(
mw.CookieName,
tokenString,
maxage,
"/",
mw.CookieDomain,
mw.SecureCookie,
mw.CookieHTTPOnly,
)
}
mw.LoginResponse(c, http.StatusOK, tokenString, expire)
}
```
从LoginHandler函数的代码实现中我们可以知道LoginHandler函数会执行`Authenticator`函数来完成Basic认证。如果认证通过则会签发JWT Token并执行 `PayloadFunc`函数设置Token Payload。如果我们设置了 `SendCookie=true` 还会在Cookie中添加认证相关的信息例如 Token、Token的生命周期等最后执行 `LoginResponse` 方法返回Token和Token的过期时间。
`Authenticator`、`PayloadFunc`、`LoginResponse`这三个函数是我们在创建JWT认证策略时指定的。下面我来分别介绍下。
先来看下[Authenticator](https://github.com/marmotedu/iam/blob/v1.0.0/internal/apiserver/auth.go#L97)函数。Authenticator函数从HTTP Authorization Header中获取用户名和密码并校验密码是否合法。
```
func authenticator() func(c *gin.Context) (interface{}, error) {
return func(c *gin.Context) (interface{}, error) {
var login loginInfo
var err error
// support header and body both
if c.Request.Header.Get("Authorization") != "" {
login, err = parseWithHeader(c)
} else {
login, err = parseWithBody(c)
}
if err != nil {
return "", jwt.ErrFailedAuthentication
}
// Get the user information by the login username.
user, err := store.Client().Users().Get(c, login.Username, metav1.GetOptions{})
if err != nil {
log.Errorf("get user information failed: %s", err.Error())
return "", jwt.ErrFailedAuthentication
}
// Compare the login password with the user password.
if err := user.Compare(login.Password); err != nil {
return "", jwt.ErrFailedAuthentication
}
return user, nil
}
}
```
`Authenticator`函数需要获取用户名和密码。它首先会判断是否有`Authorization`请求头,如果有,则调用`parseWithHeader`函数获取用户名和密码,否则调用`parseWithBody`从Body中获取用户名和密码。如果都获取失败则返回认证失败错误。
所以IAM项目的Basic支持以下两种请求方式
```
$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login # 用户名:密码通过base64加码后通过HTTP Authorization Header进行传递因为密码非明文建议使用这种方式。
$ curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login # 用户名和密码在HTTP Body中传递因为密码是明文所以这里不建议实际开发中使用这种方式。
```
这里,我们来看下 `parseWithHeader` 是如何获取用户名和密码的。假设我们的请求为:
```
$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login
```
其中,`YWRtaW46QWRtaW5AMjAyMQ==`值由以下命令生成:
```
$ echo -n 'admin:Admin@2021'|base64
YWRtaW46QWRtaW5AMjAyMQ==
```
`parseWithHeader`实际上执行的是上述命令的逆向步骤:
1. 获取`Authorization`头的值并调用strings.SplitN函数获取一个切片变量auth其值为 `["Basic","YWRtaW46QWRtaW5AMjAyMQ=="]`
2. 将`YWRtaW46QWRtaW5AMjAyMQ==`进行base64解码得到`admin:Admin@2021`。
3. 调用`strings.SplitN`函数获取 `admin:Admin@2021` ,得到用户名为`admin`,密码为`Admin@2021`。
`parseWithBody`则是调用了Gin的`ShouldBindJSON`函数来从Body中解析出用户名和密码。
获取到用户名和密码之后,程序会从数据库中查询出该用户对应的加密后的密码,这里我们假设是`xxxx`。最后`authenticator`函数调用`user.Compare`来判断 `xxxx` 是否和通过`user.Compare`加密后的字符串相匹配,如果匹配则认证成功,否则返回认证失败。
再来看下`PayloadFunc`函数:
```
func payloadFunc() func(data interface{}) jwt.MapClaims {
return func(data interface{}) jwt.MapClaims {
claims := jwt.MapClaims{
"iss": APIServerIssuer,
"aud": APIServerAudience,
}
if u, ok := data.(*v1.User); ok {
claims[jwt.IdentityKey] = u.Name
claims["sub"] = u.Name
}
return claims
}
}
```
PayloadFunc函数会设置JWT Token中Payload部分的 iss、aud、sub、identity字段供后面使用。
再来看下我们刚才说的第三个函数LoginResponse函数
```
func loginResponse() func(c *gin.Context, code int, token string, expire time.Time) {
return func(c *gin.Context, code int, token string, expire time.Time) {
c.JSON(http.StatusOK, gin.H{
"token": token,
"expire": expire.Format(time.RFC3339),
})
}
}
```
该函数用来在Basic认证成功之后返回Token和Token的过期时间给调用者
```
$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login
{"expire":"2021-09-29T01:38:49+08:00","token":"XX.YY.ZZ"}
```
登陆成功后iam-apiserver会返回Token和Token的过期时间前端可以将这些信息缓存在Cookie中或LocalStorage中之后的请求都可以使用Token来进行认证。使用Token进行认证不仅能够提高认证的安全性还能够避免查询数据库从而提高认证效率。
2. RefreshHandler
`RefreshHandler`函数会先执行Bearer认证如果认证通过则会重新签发Token。
3. LogoutHandler
最后,来看下`LogoutHandler`函数:
```
func (mw *GinJWTMiddleware) LogoutHandler(c *gin.Context) {
// delete auth cookie
if mw.SendCookie {
if mw.CookieSameSite != 0 {
c.SetSameSite(mw.CookieSameSite)
}
c.SetCookie(
mw.CookieName,
"",
-1,
"/",
mw.CookieDomain,
mw.SecureCookie,
mw.CookieHTTPOnly,
)
}
mw.LogoutResponse(c, http.StatusOK)
}
```
可以看到LogoutHandler其实是用来清空Cookie中Bearer认证相关信息的。
最后我们来做个总结Basic认证通过用户名和密码来进行认证通常用在登陆接口/login中。用户登陆成功后会返回JWT Token前端会保存该JWT Token在浏览器的Cookie或LocalStorage中供后续请求使用。
后续请求时均会携带该Token以完成Bearer认证。另外有了登陆接口一般还会配套/logout接口和/refresh接口分别用来进行注销和刷新Token。
这里你可能会问为什么要刷新Token因为通过登陆接口签发的Token有过期时间有了刷新接口前端就可以根据需要自行刷新Token的过期时间。过期时间可以通过iam-apiserver配置文件的[jwt.timeout](https://github.com/marmotedu/iam/blob/master/configs/iam-apiserver.yaml#L66)配置项来指定。登陆后签发Token时使用的密钥secretKey由[jwt.key](https://github.com/marmotedu/iam/blob/master/configs/iam-apiserver.yaml#L65)配置项来指定。
## IAM项目是如何实现Bearer认证的
上面我们介绍了Basic认证。这里我再来介绍下IAM项目中Bearer认证的实现方式。
IAM项目中有两个地方实现了Bearer认证分别是 iam-apiserver 和 iam-authz-server。下面我来分别介绍下它们是如何实现Bearer认证的。
### iam-authz-server Bearer认证实现
先来看下iam-authz-server是如何实现Bearer认证的。
iam-authz-server通过在 `/v1` 路由分组中加载cache认证中间件来使用cache认证策略
```
auth := newCacheAuth()
apiv1 := g.Group("/v1", auth.AuthFunc())
```
来看下[newCacheAuth](https://github.com/marmotedu/iam/blob/v1.0.4/internal/authzserver/jwt.go#L15)函数:
```
func newCacheAuth() middleware.AuthStrategy {
return auth.NewCacheStrategy(getSecretFunc())
}
func getSecretFunc() func(string) (auth.Secret, error) {
return func(kid string) (auth.Secret, error) {
cli, err := store.GetStoreInsOr(nil)
if err != nil {
return auth.Secret{}, errors.Wrap(err, "get store instance failed")
}
secret, err := cli.GetSecret(kid)
if err != nil {
return auth.Secret{}, err
}
return auth.Secret{
Username: secret.Username,
ID: secret.SecretId,
Key: secret.SecretKey,
Expires: secret.Expires,
}, nil
}
}
```
newCacheAuth函数调用`auth.NewCacheStrategy`创建了一个cache认证策略创建时传入了`getSecretFunc`函数,该函数会返回密钥的信息。密钥信息包含了以下字段:
```
type Secret struct {
Username string
ID string
Key string
Expires int64
}
```
再来看下cache认证策略实现的[AuthFunc](https://github.com/marmotedu/iam/blob/master/internal/pkg/middleware/auth/cache.go#L48)方法:
```
func (cache CacheStrategy) AuthFunc() gin.HandlerFunc {
return func(c *gin.Context) {
header := c.Request.Header.Get("Authorization")
if len(header) == 0 {
core.WriteResponse(c, errors.WithCode(code.ErrMissingHeader, "Authorization header cannot be empty."), nil)
c.Abort()
return
}
var rawJWT string
// Parse the header to get the token part.
fmt.Sscanf(header, "Bearer %s", &rawJWT)
// Use own validation logic, see below
var secret Secret
claims := &jwt.MapClaims{}
// Verify the token
parsedT, err := jwt.ParseWithClaims(rawJWT, claims, func(token *jwt.Token) (interface{}, error) {
// Validate the alg is HMAC signature
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, ErrMissingKID
}
var err error
secret, err = cache.get(kid)
if err != nil {
return nil, ErrMissingSecret
}
return []byte(secret.Key), nil
}, jwt.WithAudience(AuthzAudience))
if err != nil || !parsedT.Valid {
core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, err.Error()), nil)
c.Abort()
return
}
if KeyExpired(secret.Expires) {
tm := time.Unix(secret.Expires, 0).Format("2006-01-02 15:04:05")
core.WriteResponse(c, errors.WithCode(code.ErrExpired, "expired at: %s", tm), nil)
c.Abort()
return
}
c.Set(CtxUsername, secret.Username)
c.Next()
}
}
// KeyExpired checks if a key has expired, if the value of user.SessionState.Expires is 0, it will be ignored.
func KeyExpired(expires int64) bool {
if expires >= 1 {
return time.Now().After(time.Unix(expires, 0))
}
return false
}
```
AuthFunc函数依次执行了以下四大步来完成JWT认证每一步中又有一些小步骤下面我们来一起看看。
第一步从Authorization: Bearer XX.YY.ZZ请求头中获取XX.YY.ZZXX.YY.ZZ即为JWT Token。
第二步调用github.com/dgrijalva/jwt-go包提供的ParseWithClaims函数该函数会依次执行下面四步操作。
调用ParseUnverified函数依次执行以下操作
从Token中获取第一段XXbase64解码后得到JWT Token的Header{“alg”:“HS256”,“kid”:“a45yPqUnQ8gljH43jAGQdRo0bXzNLjlU0hxa”,“typ”:“JWT”}。
从Token中获取第二段YYbase64解码后得到JWT Token的Payload{“aud”:“iam.authz.marmotedu.com”,“exp”:1625104314,“iat”:1625097114,“iss”:“iamctl”,“nbf”:1625097114}。
根据Token Header中的alg字段获取Token加密函数。
最终ParseUnverified函数会返回Token类型的变量Token类型包含 Method、Header、Claims、Valid这些重要字段这些字段会用于后续的认证步骤中。
调用传入的keyFunc获取密钥这里来看下keyFunc的实现
```
func(token *jwt.Token) (interface{}, error) {
// Validate the alg is HMAC signature
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, ErrMissingKID
}
var err error
secret, err = cache.get(kid)
if err != nil {
return nil, ErrMissingSecret
}
return []byte(secret.Key), nil
}
```
可以看到keyFunc接受 `*Token` 类型的变量并获取Token Header中的kidkid即为密钥IDsecretID。接着调用cache.get(kid)获取密钥secretKey。cache.get函数即为getSecretFuncgetSecretFunc函数会根据kid从内存中查找密钥信息密钥信息中包含了secretKey。
3. 从Token中获取Signature签名字符串ZZ也即Token的第三段。
4. 获取到secretKey之后token.Method.Verify验证Signature签名字符串ZZ也即Token的第三段是否合法。token.Method.Verify实际上是使用了相同的加密算法和相同的secretKey加密XX.YY字符串。假设加密之后的字符串为WW接下来会用WW和ZZ base64解码后的字符串进行比较如果相等则认证通过如果不相等则认证失败。
**第三步,**调用KeyExpired验证secret是否过期。secret信息中包含过期时间你只需要拿该过期时间和当前时间对比就行。
**第四步,**设置HTTP Header`username: colin`。
到这里iam-authz-server的Bearer认证分析就完成了。
我们来做个总结iam-authz-server通过加载Gin中间件的方式在请求`/v1/authz`接口时进行访问认证。因为Bearer认证具有过期时间而且可以在认证字符串中携带更多有用信息还具有不可逆加密等优点所以**/v1/authz采用了Bearer认证Token格式采用了JWT格式**这也是业界在API认证中最受欢迎的认证方式。
Bearer认证需要secretID和secretKey这些信息会通过gRPC接口调用从iam-apisaerver中获取并缓存在iam-authz-server的内存中供认证时查询使用。
当请求来临时iam-authz-server Bearer认证中间件从JWT Token中解析出Header并从Header的kid字段中获取到secretID根据secretID查找到secretKey最后使用secretKey加密JWT Token的Header和Payload并与Signature部分进行对比。如果相等则认证通过如果不等则认证失败。
### iam-apiserver Bearer认证实现
再来看下 iam-apiserver的Bearer认证。
iam-apiserver的Bearer认证通过以下代码位于[router.go](https://github.com/marmotedu/iam/blob/v1.1.0/internal/apiserver/router.go#L65)文件中指定使用了auto认证策略
```
v1.Use(auto.AuthFunc())
```
我们来看下[auto.AuthFunc()](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/auto.go#L38)的实现:
```
func (a AutoStrategy) AuthFunc() gin.HandlerFunc {
return func(c *gin.Context) {
operator := middleware.AuthOperator{}
authHeader := strings.SplitN(c.Request.Header.Get("Authorization"), " ", 2)
if len(authHeader) != authHeaderCount {
core.WriteResponse(
c,
errors.WithCode(code.ErrInvalidAuthHeader, "Authorization header format is wrong."),
nil,
)
c.Abort()
return
}
switch authHeader[0] {
case "Basic":
operator.SetStrategy(a.basic)
case "Bearer":
operator.SetStrategy(a.jwt)
// a.JWT.MiddlewareFunc()(c)
default:
core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, "unrecognized Authorization header."), nil)
c.Abort()
return
}
operator.AuthFunc()(c)
c.Next()
}
}
```
从上面代码中可以看到AuthFunc函数会从Authorization Header中解析出认证方式是Basic还是Bearer。如果是Bearer就会使用JWT认证策略如果是Basic就会使用Basic认证策略。
我们再来看下JWT认证策略的[AuthFunc](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/jwt.go#L30)函数实现:
```
func (j JWTStrategy) AuthFunc() gin.HandlerFunc {
return j.MiddlewareFunc()
}
```
我们跟随代码,可以定位到`MiddlewareFunc`函数最终调用了`github.com/appleboy/gin-jwt`包`GinJWTMiddleware`结构体的[middlewareImpl](https://github.com/appleboy/gin-jwt/blob/v2.6.4/auth_jwt.go#L369)方法:
```
func (mw *GinJWTMiddleware) middlewareImpl(c *gin.Context) {
claims, err := mw.GetClaimsFromJWT(c)
if err != nil {
mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c))
return
}
if claims["exp"] == nil {
mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrMissingExpField, c))
return
}
if _, ok := claims["exp"].(float64); !ok {
mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c))
return
}
if int64(claims["exp"].(float64)) < mw.TimeFunc().Unix() {
mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c))
return
}
c.Set("JWT_PAYLOAD", claims)
identity := mw.IdentityHandler(c)
if identity != nil {
c.Set(mw.IdentityKey, identity)
}
if !mw.Authorizator(identity, c) {
mw.unauthorized(c, http.StatusForbidden, mw.HTTPStatusMessageFunc(ErrForbidden, c))
return
}
c.Next()
}
```
分析上面的代码我们可以知道middlewareImpl的Bearer认证流程为
**第一步**:调用`GetClaimsFromJWT`函数从HTTP请求中获取Authorization Header并解析出Token字符串进行认证最后返回Token Payload。
**第二步**校验Payload中的`exp`是否超过当前时间如果超过就说明Token过期校验不通过。
**第三步**给gin.Context中添加`JWT_PAYLOAD`键,供后续程序使用(当然也可能用不到)。
**第四步**通过以下代码在gin.Context中添加IdentityKey键IdentityKey键可以在创建`GinJWTMiddleware`结构体时指定,这里我们设置为`middleware.UsernameKey`也就是username。
```
identity := mw.IdentityHandler(c)
if identity != nil {
c.Set(mw.IdentityKey, identity)
}
```
IdentityKey键的值由IdentityHandler函数返回IdentityHandler函数为
```
func(c *gin.Context) interface{} {
claims := jwt.ExtractClaims(c)
return claims[jwt.IdentityKey]
}
```
上述函数会从Token的Payload中获取identity域的值identity域的值是在签发Token时指定的它的值其实是用户名你可以查看[payloadFunc](https://github.com/marmotedu/iam/blob/v1.0.0/internal/apiserver/auth.go#L177)函数了解。
**第五步**:接下来,会调用`Authorizator`方法,`Authorizator`是一个callback函数成功时必须返回真失败时必须返回假。`Authorizator`也是在创建GinJWTMiddleware时指定的例如
```
func authorizator() func(data interface{}, c *gin.Context) bool {
return func(data interface{}, c *gin.Context) bool {
if v, ok := data.(string); ok {
log.L(c).Infof("user `%s` is authenticated.", v)
return true
}
return false
}
}
```
`authorizator`函数返回了一个匿名函数,匿名函数在认证成功后,会打印一条认证成功日志。
## IAM项目认证功能设计技巧
我在设计IAM项目的认证功能时也运用了一些技巧这里分享给你。
### 技巧1面向接口编程
在使用[NewAutoStrategy](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/auto.go#L30)函数创建auto认证策略时传入了[middleware.AuthStrategy](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth.go#L12)接口类型的参数这意味着Basic认证和Bearer认证都可以有不同的实现这样后期可以根据需要扩展新的认证方式。
### 技巧2使用抽象工厂模式
[auth.go](https://github.com/marmotedu/iam/blob/v1.0.0/internal/apiserver/auth.go)文件中通过newBasicAuth、newJWTAuth、newAutoAuth创建认证策略时返回的都是接口。通过返回接口可以在不公开内部实现的情况下让调用者使用你提供的各种认证功能。
### 技巧3使用策略模式
在auto认证策略中我们会根据HTTP 请求头`Authorization: XXX X.Y.X`中的XXX来选择并设置认证策略Basic 或 Bearer。具体可以查看`AutoStrategy`的[AuthFunc](https://github.com/marmotedu/iam/blob/v1.0.0/internal/pkg/middleware/auth/auto.go#L38)函数:
```
func (a AutoStrategy) AuthFunc() gin.HandlerFunc {
return func(c *gin.Context) {
operator := middleware.AuthOperator{}
authHeader := strings.SplitN(c.Request.Header.Get("Authorization"), " ", 2)
...
switch authHeader[0] {
case "Basic":
operator.SetStrategy(a.basic)
case "Bearer":
operator.SetStrategy(a.jwt)
// a.JWT.MiddlewareFunc()(c)
default:
core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, "unrecognized Authorization header."), nil)
c.Abort()
return
}
operator.AuthFunc()(c)
c.Next()
}
}
```
上述代码中如果是Basic则设置为Basic认证方法`operator.SetStrategy(a.basic)`如果是Bearer则设置为Bearer认证方法`operator.SetStrategy(a.jwt)`。 `SetStrategy`方法的入参是AuthStrategy类型的接口都实现了`AuthFunc() gin.HandlerFunc`函数,用来进行认证,所以最后我们调用`operator.AuthFunc()(c)`即可完成认证。
## 总结
在IAM项目中iam-apiserver实现了Basic认证和Bearer认证iam-authz-server实现了Bearer认证。这一讲重点介绍了iam-apiserver的认证实现。
用户要访问iam-apiserver首先需要通过Basic认证认证通过之后会返回JWT Token和JWT Token的过期时间。前端将Token缓存在LocalStorage或Cookie中后续的请求都通过Token来认证。
执行Basic认证时iam-apiserver会从HTTP Authorization Header中解析出用户名和密码将密码再加密并和数据库中保存的值进行对比。如果不匹配则认证失败否则认证成功。认证成功之后会返回Token并在Token的Payload部分设置用户名Key为 username 。
执行Bearer认证时iam-apiserver会从JWT Token中解析出Header和Payload并从Header中获取加密算法。接着用获取到的加密算法和从配置文件中获取到的密钥对Header.Payload进行再加密得到Signature并对比两次的Signature是否相等。如果不相等则返回 HTTP 401 Unauthorized 错误如果相等接下来会判断Token是否过期如果过期则返回认证不通过否则认证通过。认证通过之后会将Payload中的username添加到gin.Context类型的变量中供后面的业务逻辑使用。
我绘制了整个流程的示意图,你可以对照着再回顾一遍。
![](https://static001.geekbang.org/resource/image/64/7e/642a010388e759dd76d411055bbd637e.jpg?wh=2248x1104)
## 课后练习
1. 走读`github.com/appleboy/gin-jwt`包的`GinJWTMiddleware`结构体的[GetClaimsFromJWT](https://github.com/appleboy/gin-jwt/blob/v2.6.4/auth_jwt.go#L407)方法分析一下GetClaimsFromJWT方法是如何从gin.Context中解析出Token并进行认证的
2. 思考下iam-apiserver和iam-authzserver是否可以使用同一个认证策略如果可以又该如何实现
欢迎你在留言区与我交流讨论,我们下一讲见。