GO微服务实战第二十七节 案例:如何自定义授权服务器?

今天我和你分享的是如何自定义授权服务器的案例。

在上一课时中,我们基于 OAuth2 和 JWT 设计了认证与授权服务体系,在本课时,我们将通过 Go 来搭建一个授权服务器。

授权服务器的主要交互对象为客户端和资源服务器,它们之间的交互流程如下图所示:

Drawing 1.png

交互流程示意图

客户端在访问资源服务器中用户存储的数据之前,需要携带用户凭证向授权服务器请求访问令牌。授权服务器会验证客户端以及其携带的用户凭证,验证通过的话将会生成并返回访问令牌。

最后,客户端携带访问令牌请求资源服务器中存储的用户数据,资源服务器会请求授权服务器验证访问令牌,如果访问令牌有效将会返回对应的用户数据。

从这个交互流程我们可以发现,授权服务器的主要职责是颁发令牌和验证访问令牌。对此,授权服务器需要对外提供两个相应的接口:

  • /oauth/token 用于客户端携带用户凭证请求访问令牌;

  • /oauth/check_token 用于验证访问令牌的有效性,返回访问令牌绑定的客户端和用户信息。

除此之外,我们还可以让授权服务器承担客户端信息管理和权限管理等额外功能,也可以由其他的服务提供该部分能力。每个客户端都可以为用户申请访问令牌,访问令牌是与申请的客户端、授权的用户绑定的,表示某一用户授予某一个客户端有限的访问资源权限。

一个基本的授权服务器应该由以下五个模块组成,如图所示:

Drawing 3.png

授权服务器主要模块组成图

  • TokenGranter(令牌生成器),根据客户端请求的授权类型,使用不同的方式验证客户端和用户信息,并使用 TokenService 生成访问令牌;

  • TokenService(令牌服务),生成并管理令牌,它使用 TokenStore 存储令牌;

  • TokenStore(令牌存储器),负责令牌的存取工作;

  • ClientDetailsService(客户端详情服务),根据 ClientId 查询系统中注册的客户端信息;

  • UserDetailsService(用户详情服务),用于获取用户信息。

接下来,我们就来讲解如何在授权服务器中实现密码类型来获取访问令牌。

客户端详情服务和用户详情服务

ClientDetailsService(客户端详情服务) 和 UserDetailsService(用户详情服务) 主要用于根据唯一标识查找客户端或者用户信息,用于验证客户端或者用户信息,或者根据对应的信息生成令牌。客户端和用户的主要信息描述如下:

type ClientDetails struct {
	// client 的标识
	ClientId string
	// client 的密钥
	ClientSecret string
	// 访问令牌有效时间,秒
	AccessTokenValiditySeconds int
	// 刷新令牌有效时间,秒
	RefreshTokenValiditySeconds int
	// 重定向地址,授权码类型中使用
	RegisteredRedirectUri string
	// 可以使用的授权类型
	AuthorizedGrantTypes []string
}
type UserDetails struct {
	// 用户标识
	UserId int64
	// 用户名 唯一
	Username string
	// 用户密码
	Password string
	// 用户具有的权限
	Authorities []string
}

客户端信息主要包括客户端标识、客户端密钥、令牌有效时间、重定向地址和可使用的授权类型等,而用户的主要信息包括用户 ID、用户名和用户权限列表等。

在不同的系统中,客户端和用户信息的管理可能有专门的微服务提供实现,并对内提供相应的 HTTP 或者 RPC 访问接口,也可能是由授权服务器直接管理。为了统一获取客户端和用户信息的方式,我们定义了以下的 UserDetailsService 和 ClientDetailsService 接口,代码如下所示:

type UserDetailsService interface {
	// 根据用户名加载用户信息
	GetUserDetailsByUsername(ctx context.Context, username, password string) (model.UserDetails, error)
}
type ClientDetailsService interface {
	// 根据 clientId 加载客户端信息
	GetClientDetailsByClientId(ctx context.Context, clientId string, clientSecret string)( model.ClientDetails, error)
}

无论系统中是通过何种方式管理客户端和用户信息,我们仅需使用相应的方式实现上述接口,即可为授权服务器提供一致的方式获取客户端或者用户信息。假如我们将客户端信息维护在内存中,可以实现如下的内存客户端详情服务,代码如下所示:

type InMemoryClientDetailsService struct {
	clientDetailsDict map[string]*model.ClientDetails
}
func NewInMemoryClientDetailsService(clientDetailsList []*model.ClientDetails ) *InMemoryClientDetailsService{
	clientDetailsDict := make(map[string]*model.ClientDetails)
	if len(clientDetailsList) > 0  {
		for _, value := range clientDetailsList {
			clientDetailsDict[value.ClientId] = value
		}
	}
	return &InMemoryClientDetailsService{
		clientDetailsDict:clientDetailsDict,
	}
}
func (service *InMemoryClientDetailsService)GetClientDetailsByClientId(ctx context.Context, clientId string, clientSecret string)(model.ClientDetails, error) {
	// 根据 clientId 获取 clientDetails
	clientDetails, ok := service.clientDetailsDict[clientId]; if ok{
		// 比较 clientSecret 是否正确
		if clientDetails.ClientSecret == clientSecret{
			return *clientDetails, nil
		}else {
			return model.ClientDetails{}, ErrClientSecret
		}
	}else {
		return model.ClientDetails{}, ErrClientNotExist
	}
}

在上述代码中,我们将客户端信息通过 map 维护在内存中,根据客户端的标识从 map 加载客户端信息。在实际生产开发中,可以根据系统中维护客户端信息的方式,选择从数据库、缓存或者通过 RPC 的方式从其他微服务中加载客户端信息。

令牌生成器

TokenGranter(令牌生成器)是授权服务器的核心所在,它会根据客户端请求的授权类型进行不同的用户和客户端信息认证流程,并使用 TokenService 生成相应的访问令牌返回给客户端,它将提供以下的接口用于生成访问令牌:

type TokenGranter interface {
	grant(grantType string, client ClientDetails, reader *http.Request) (*OAuth2Token, error)
}

授权服务器主要对外提供 HTTP 接口用于请求访问令牌,因此 TokenGranter.grant 方法接受授权类型、请求的客户端和请求体作为参数。为了支持多种授权类型的实现,我们采用组合设计模式来实现 TokenGranter,如下图所示:

Drawing 4.png

TokenGranter 组合设计模式类图

通过该模式图,我们可以看到 TokenGranter 主要有以下两种实现类型:

  • ComposeTokenGranter 组合节点,管理了多种 LeafTokenGranter 授权类型的具体叶节点实现;

  • LeafTokenGranter 叶节点,它是授权类型的具体实现,包括密码类型、授权码类型等多种授权类型实现类。

ComposeTokenGranter 的功能是根据 grantType 获取具体的 TokenGranter 实现,并委托其验证客户端和用户凭证,从而生成访问令牌,代码如下所示:

func (tokenGranter *ComposeTokenGranter) Grant(ctx context.Context, grantType string, client ClientDetails, reader *http.Request) (*OAuth2Token, error) {
   // 检查客户端是否允许该种授权类型
   var isSupport bool
   if len(client.AuthorizedGrantTypes) > 0 {
      for _, v := range client.AuthorizedGrantTypes {
         if v == grantType {
            isSupport = true
            break
         }
      }
   }
   if !isSupport{
      return nil, ErrNotSupportOperation
   }
   // 查找具体的授权类型实现节点
   dispatchGranter,ok := tokenGranter.TokenGrantDict[grantType]; if ok{
      return dispatchGranter.Grant(ctx, grantType, client, reader)
   } else{
      return nil, ErrNotSupportGrantType
   }
}

我们以密码类型 UsernamePasswordTokenGranter 的具体实现为例:UsernamePasswordTokenGranter 会验证客户端提交的用户名和密码是否正确,如果用户名和密码有效,将会调用 TokenService 为该客户端生成访问令牌。代码如下所示:

func (tokenGranter *UsernamePasswordTokenGranter) Grant(ctx context.Context,
	grantType string, client ClientDetails, reader *http.Request) (*OAuth2Token, error) {
	if grantType != tokenGranter.supportGrantType{
		return nil, ErrNotSupportGrantType
	}
	// 从请求体中获取用户名密码
	username := reader.FormValue("username")
	password := reader.FormValue("password")
	if username == "" || password == ""{
		return nil, ErrInvalidUsernameAndPasswordRequest
	}
	// 验证用户名密码是否正确
	userDetails, err := tokenGranter.userDetailsService.GetUserDetailsByUsername(ctx, username, password)
	if err != nil{
		return nil, ErrInvalidUsernameAndPasswordRequest
	}
	// 根据用户信息和客户端信息生成访问令牌
	return tokenGranter.tokenService.CreateAccessToken(&OAuth2Details{
		Client:client,
		User:userDetails,
	})
}

在上述代码中,UsernamePasswordTokenGranter 首先从请求体中获取到客户端提交的用户名和密码,然后调用 UserDetailsService 根据用户名获取用户信息用于验证用户信息,如果用户信息有效,将会委托 TokenService 根据用户信息和客户端信息生成访问令牌。

令牌服务

TokenGranter(令牌生成器)最后将会使用 TokenService(令牌服务)生成访问令牌。前面在讲解授权服务器的主要模块时,我们介绍了TokenService 的主要功能为生成和管理令牌,并借助 TokenStore 存取令牌。它提供了以下的主要接口:

type TokenService interface {
	....
	// 根据用户信息和客户端信息生成访问令牌
	CreateAccessToken(oauth2Details *OAuth2Details) (*OAuth2Token, error)
	// 根据访问令牌获取对应的用户信息和客户端信息
	GetOAuth2DetailsByAccessToken(tokenValue string) (*OAuth2Details, error)
	....
}

TokenGranter 在验证完有效的客户端和用户信息后,将会调用 CreateAccessToken 方法生成访问令牌。CreateAccessToken 会首先根据用户信息和客户端信息从 TokenStore 中获取已保存的访问令牌,如果访问令牌存在且未失效,将会直接返回该访问令牌;如果访问令牌已经失效,那么将尝试根据用户信息和客户端信息生成一个新的访问令牌并返回。代码如下所示:

func (tokenService *DefaultTokenService) CreateAccessToken(oauth2Details *OAuth2Details) (*OAuth2Token, error) {
	existToken, err := tokenService.tokenStore.GetAccessToken(oauth2Details)
	if err != nil{
		return nil, err
	}
	var refreshToken *OAuth2Token
	// 存在未失效访问令牌,直接返回
	if existToken != nil {
		if !existToken.IsExpired() {
			err = tokenService.tokenStore.StoreAccessToken(existToken, oauth2Details)
			return existToken, err
		}
		// 访问令牌已失效,移除
		err = tokenService.tokenStore.RemoveAccessToken(existToken.TokenValue)
		if err != nil {
			return nil, err
		}
		if existToken.RefreshToken != nil {
			refreshToken = existToken.RefreshToken
			err = tokenService.tokenStore.RemoveRefreshToken(refreshToken.TokenType)
			if err != nil {
				return nil, err
			}
		}
	}
	if refreshToken == nil || refreshToken.IsExpired() {
		refreshToken, err = tokenService.createRefreshToken(oauth2Details)
		if err != nil {
			return nil, err
		}
	}
	// 生成新的访问令牌
	accessToken, err := tokenService.createAccessToken(refreshToken, oauth2Details)
	if err != nil{
		return nil, err
	}
	// 保存新生成令牌
	err = tokenService.tokenStore.StoreAccessToken(accessToken, oauth2Details)
	if err != nil{
		return nil, err
	}
	err = tokenService.tokenStore.StoreRefreshToken(refreshToken, oauth2Details)
	if err != nil{
		return nil, err
	}
	return accessToken, err
}

在上述代码中,除了生成访问令牌,还会生成对应的刷新令牌。在令牌生成成功之后,CreateAccessToken 方法会通过 TokenStore 将它们保存到系统中。

另一个GetOAuth2DetailsByAccessToken 方法主要用于验证访问令牌的有效性,并根据访问令牌获取其绑定的客户端和用户信息, 代码如下所示:

func (tokenService *DefaultTokenService) GetOAuth2DetailsByAccessToken(tokenValue string) (*OAuth2Details, error) {
    // 从 TokenStore 中获取令牌数据
	accessToken, err := tokenService.tokenStore.ReadAccessToken(tokenValue)
	if err != nil {
		return nil, err
	}
	if accessToken.IsExpired() {
		return nil, ErrExpiredToken
	}
    // 获取令牌对应的客户端和用户信息
	return tokenService.tokenStore.ReadOAuth2Details(tokenValue)
}

在上述代码中,GetOAuth2DetailsByAccessToken 方法首先根据访问令牌的值从 TokenStore 中获取到对应的访问令牌结构体,如果访问令牌没有失效,再通过 TokenStore 获取生成访问令牌时绑定的用户信息和客户端信息。

令牌存储器

TokenStore(令牌存储器) 为 TokenService(令牌服务)提供了令牌的存储和查找能力,维护了令牌和用户、客户端之间的绑定关系,主要提供了以下的接口:

type TokenStore interface {
	....
	// 存储访问令牌
	StoreAccessToken(oauth2Token *OAuth2Token, oauth2Details *OAuth2Details) error
	// 根据令牌值获取访问令牌结构体
	ReadAccessToken(tokenValue string) (*OAuth2Token, error)
	// 根据令牌值获取令牌对应的客户端和用户信息
	ReadOAuth2Details(tokenValue string)(*OAuth2Details, error)
	// 根据令牌值获取刷新令牌
	ReadRefreshToken(tokenValue string)(*OAuth2Token, error)
	// 根据令牌值获取刷新令牌对应的客户端和用户信息
	ReadOAuth2DetailsForRefreshToken(tokenValue string)(*OAuth2Details, error)
	....
}

考虑到 JWT 自包含的特性,我们使用 JWT 作为访问令牌的载体,将令牌和用户、客户端之间的绑定关系维护在 JWT 样式的令牌中,避免将这些关联关系存储在系统,这就减少了对存储系统的访问次数,有利于提高系统的吞吐量。TokenStore 主要借助TokenEnhancer 组装和解析令牌中的信息,TokenEnhancer提供以下接口:

type TokenEnhancer interface {
	// 组装 Token 信息
	Enhance(oauth2Token *OAuth2Token, oauth2Details *OAuth2Details) (*OAuth2Token, error)
	// 从 Token 中还原信息
	Extract(tokenValue string) (*OAuth2Token, *OAuth2Details, error)
}

JwtTokenEnhancer 会把令牌对应的用户信息和客户端信息写入 JWT 样式的令牌声明中,也就是说通过令牌值即可知道令牌绑定的用户信息和客户端信息,它的 Enhance 方法实现如下:

func (enhancer *JwtTokenEnhancer) Enhance(oauth2Token *OAuth2Token, oauth2Details *OAuth2Details) (*OAuth2Token, error) {
	return enhancer.sign(oauth2Token, oauth2Details)
}
func (enhancer *JwtTokenEnhancer) sign(oauth2Token *OAuth2Token, oauth2Details *OAuth2Details)  (*OAuth2Token, error) {
	expireTime := oauth2Token.ExpiresTime
	clientDetails := oauth2Details.Client
	userDetails := oauth2Details.User
	clientDetails.ClientSecret = ""
	userDetails.Password = ""
    // 添加 JWT 声明
	claims := OAuth2TokenCustomClaims{
		UserDetails:userDetails,
		ClientDetails:clientDetails,
		StandardClaims:jwt.StandardClaims{
			ExpiresAt:expireTime.Unix(),
			Issuer:"System",
		},
	}
	if oauth2Token.RefreshToken != nil{
		claims.RefreshToken = *oauth2Token.RefreshToken
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    // JWT 签名,生成令牌
	tokenValue, err := token.SignedString(enhancer.secretKey)
<span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span>{
	<span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
}
oauth2Token.TokenValue = tokenValue
oauth2Token.TokenType = <span class="hljs-string">"jwt"</span>
<span class="hljs-keyword">return</span> oauth2Token, <span class="hljs-literal">nil</span>;

}

在上述代码中,Enhance 方法将令牌对应的用户信息和客户端信息写入 JWT 的声明中,这样授权服务器在下次拿到令牌时,就可以根据令牌值获取到令牌绑定的用户信息和客户端信息。令牌解析的代码如下:

func (enhancer *JwtTokenEnhancer) Extract(tokenValue string) (*OAuth2Token, *OAuth2Details, error)  {
    // 使用签名解析 JWT 值
	token, err := jwt.ParseWithClaims(tokenValue, &OAuth2TokenCustomClaims{}, func(token *jwt.Token) (i interface{}, e error) {
		return enhancer.secretKey, nil
	})
<span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span>{
	<span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, <span class="hljs-literal">nil</span>, err
}
claims := token.Claims.(*OAuth2TokenCustomClaims)
expiresTime := time.Unix(claims.ExpiresAt, <span class="hljs-number">0</span>)
<span class="hljs-comment">// 返回从 JWT 中解析出来的客户端和用户信息</span>
<span class="hljs-keyword">return</span> &amp;OAuth2Token{
		RefreshToken:&amp;claims.RefreshToken,
		TokenValue:tokenValue,
		ExpiresTime: &amp;expiresTime,
	}, &amp;OAuth2Details{
		User:claims.UserDetails,
		Client:claims.ClientDetails,
	}, <span class="hljs-literal">nil</span>

}

以 JWT 作为令牌的载体,每个令牌都是自包含的,它携带了请求它的用户信息和客户端信息,在资源服务器解析 JWT 成功后,即可定位当前请求的来源方。

JwtTokenStore 存取令牌的能力主要借助 JwtTokenEnhancer 编码 JWT 和解码 JWT 实现。借助 JwtTokenEnhancer,我们可以很方便地管理 JwtTokenStore 中的令牌和用户、客户端之间的绑定关系。由于 JWT 签发之后不可更改,所以令牌只有在有效时长过后才会过期失效;如果存在立即失效访问令牌的需求,我还是建议你将令牌和用户、客户端的存储关系维护在外部存储中,这样就能提供令牌主动失效的能力。

令牌端点

前面我们说明过授权服务器中提供了 /oauth/token 端点和 /oauth/check_token 端点分别用于请求访问令牌和验证令牌的有效性。

/oauth/token 端点通过请求参数中的 grant_type 来识别请求访问令牌的授权类型,并验证请求中携带的客户端凭证和用户凭证是否有效,只有通过验证的客户端请求才能获取访问令牌。它通过 TokenGranter.Grant 方法为客户端生成访问令牌。

客户端和资源服务器使用/oauth/check_token 端点验证访问令牌的有效性。如果访问令牌有效,该端点将会返回与访问令牌绑定的用户信息和客户端信息,它使用TokenService.GetOAuth2DetailsByAccessToken 方法验证访问令牌的有效性。

在请求访问令牌之前,授权服务器会首先验证 Authorization 请求头中携带的客户端信息,对此需要添加 makeClientAuthorizationContext 请求处理器来获取请求中的客户端信息,代码如下所示:

func makeClientAuthorizationContext(clientDetailsService service.ClientDetailsService, logger log.Logger) kithttp.RequestFunc {
	return func(ctx context.Context, r *http.Request) context.Context {
        // 解析 Authorization,获取 clientId 和 clientSecret
		if clientId, clientSecret, ok := r.BasicAuth(); ok {
            // 验证客户端信息
			clientDetails, err := clientDetailsService.GetClientDetailsByClientId(ctx, clientId, clientSecret)
			if err != nil{
				return context.WithValue(ctx, endpoint.OAuth2ErrorKey, ErrInvalidClientRequest)
			}
			return context.WithValue(ctx, endpoint.OAuth2ClientDetailsKey, clientDetails)
		}
		return context.WithValue(ctx, endpoint.OAuth2ErrorKey, ErrInvalidClientRequest)
	}
}

一般来讲,客户端信息会通过 base64 的方式加密后放入 Authorization 请求头中,加密前的信息按照 clientId:clientSecret 的规则组装。在上述代码中,通过 makeClientAuthorizationContext 方法从 Authorization 请求头获取到请求的客户端信息后,使用 ClientDetailsService 加载客户端信息并校验,验证成功后,将客户端信息放入 context 中传递到下游。

在请求正式进入 Endpoint 之前,授权服务器还要验证 context 的客户端信息是否存在,这就需要添加客户端验证中间件 MakeClientAuthorizationMiddleware,代码如下所示:

func MakeClientAuthorizationMiddleware(logger log.Logger) endpoint.Middleware {
  return func(next endpoint.Endpoint) endpoint.Endpoint {
    return func(ctx context.Context, request interface{}) (response interface{}, err error) {
      // 请求上下文是否存在错误
      if err, ok := ctx.Value(OAuth2ErrorKey).(error); ok{
        return nil, err
      }
      // 验证客户端信息是否存在,不存在返回异常
      if _, ok := ctx.Value(OAuth2ClientDetailsKey).(*model.ClientDetails); !ok{
        return  nil, ErrInvalidClientRequest
      }
      return next(ctx, request)
    }
  }
}

在 MakeClientAuthorizationMiddleware 中间件中会验证请求上下文是否携带了客户端信息,如果请求中没有携带验证过的客户端信息,将直接返回错误给请求方。

接下来我们就来演示如何通过 /oauth/token 端点获取访问令牌和通过 /oauth/check_token 端点验证访问令牌。我们在授权服务器内内置用户名为 aoho、密码为 123456 的用户信息和客户端标识为 clientId、密钥为 clientSecret 的客户端信息,使用密码类型为该客户端请求用户 aoho 的访问令牌,curl 命令如下:

curl -X POST \
'http://localhost:10098/oauth/token?grant_type=password' \
  -H 'Authorization: Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0' \
  -H 'Content-Type: multipart/form-data' \
  -H 'Host: localhost:10098' \
  -F username=aoho \
  -F password=123456

将访问令牌的 TokenValue 提交到 /oauth/check_token 端点中,即可验证访问令牌的有效性,获取令牌绑定的客户端和用户信息,curl 命令如下所示:

curl -X POST \
  'http://localhost:10098/oauth/check_token?token=...' \
  -H 'Authorization: Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0' \
  -H 'Host: localhost:10098'

由于 TokenValue 过长,参数中使用省略号表示,有效的访问令牌能够获取令牌绑定的客户端信息和用户信息,如下所示:

{
    "o_auth_details":{
        "Client":{
            "ClientId":"clientId",
            "ClientSecret":"",
            "AccessTokenValiditySeconds":1800,
            "RefreshTokenValiditySeconds":18000,
            "RegisteredRedirectUri":"http://127.0.0.1",
            "AuthorizedGrantTypes":[
                "password",
                "refresh_token"
            ]
        },
        "User":{
            "UserId":1,
            "Username":"aoho",
            "Password":"",
            "Authorities":[
                "Simple"
            ]
        }
    },
    "error":""
}

到这里,我们就实现了授权服务器颁发访问令牌和验证访问令牌有效性的主要能力。

小结

在统一认证与授权服务体系中,授权服务器的主要职责为颁发令牌验证令牌的有效性

在本课时,我们使用 Go 搭建了一个提供 HTTP 接口的授权服务器。客户端能够使用密码类型,通过携带用户的用户名密码向授权服务器请求访问令牌。访问令牌是一段自包含的 JWT 对象,其内包含了令牌绑定的用户和客户端信息。另外,授权服务器还提供相应的接口给客户端和资源服务器验证访问令牌的有效性,以及获取令牌绑定的用户和客户端信息。

希望通过本课时的学习能够帮助你了解如何实现一个基本的授权服务器,加深你对统一认证与授权服务体系的认知。在下一课时,我们将介绍如何实现资源服务器,并尝试使用授权服务器颁发的访问令牌访问资源服务器中的受保护用户资源。


精选评论

相关文章

学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习...
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面...
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生...
Can’t connect to local MySQL server through socket \'/v...
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 ...
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服...