今天我和你分享的是如何自定义授权服务器的案例。在前文我们基于OAuth2和JWT设计了认证与授权服务体系在本文我们将通过 Go 来搭建一个授权服务器。授权服务器的主要交互对象为客户端和资源服务器它们之间的交互流程如下图所示客户端在访问资源服务器中用户存储的数据之前需要携带用户凭证向授权服务器请求访问令牌。授权服务器会验证客户端以及其携带的用户凭证验证通过的话将会生成并返回访问令牌。最后客户端携带访问令牌请求资源服务器中存储的用户数据资源服务器会请求授权服务器验证访问令牌如果访问令牌有效将会返回对应的用户数据。从这个交互流程我们可以发现授权服务器的主要职责是颁发令牌和验证访问令牌。对此授权服务器需要对外提供两个相应的接口/oauth/token用于客户端携带用户凭证请求访问令牌;/oauth/check_token用于验证访问令牌的有效性返回访问令牌绑定的客户端和用户信息。除此之外我们还可以让授权服务器承担客户端信息管理和权限管理等额外功能也可以由其他的服务提供该部分能力。每个客户端都可以为用户申请访问令牌访问令牌是与申请的客户端、授权的用户绑定的表示某一用户授予某一个客户端有限的访问资源权限。一个基本的授权服务器应该由以下五个模块组成如图所示:TokenGranter令牌生成器根据客户端请求的授权类型使用不同的方式验证客户端和用户信息并使用TokenService 生成访问令牌;TokenService令牌服务生成并管理令牌它使用TokenStore存储令牌;TokenStore令牌存储器负责令牌的存取工作;ClientDetailsService客户端详情服务根据Clientld 查询系统中注册的客户端信息;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, ni1 }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如下图所示通过该模式图我们可以看到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[grantTypel; 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.IsExpiredO { err tokenService.tokenStore.StoreAccessToken(existToken, oauth2Detai1s) 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 ni1 Il 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(tokenValuestring) *oAuth2Details error) { // 从Tokenstore中获取令牌数据 accessToken, err : tokenService.tokenstore.ReadAccessToken(tokenValue) if err ! nil { return nil, err } if accessToken.IsExpiredO { 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) .... }考虑到WT自包含的特性我们使用JWT作为访问令牌的载体将令牌和用户、客户端之间的绑定关系维护在WT样式的令牌中避免将这些关联关系存储在系统这就减少了对存储系统的访问次数有利于提高系统的吞吐量。TokenStore 主要借助TokenEnhancer 组装和解析令牌中的信息TokenEnhancer提供以下接口:type TokenEnhancer interface { // 组装 Token信息 Enhanceoauth2Token *OAuth2Token, oauth2Details *oAuth2Details (*OAuth2Token, error) // 从Token中还原信息 Extract(tokenValue string (*oAuth2Token *OAuth2Details error) }JwtTokenEnhancer会把令牌对应的用户信息和客户端信息写入JWT样式的令牌声明中也就是说通过令牌值即可知道令牌绑定的用户信息和客户端信息它的Enhance方法实现如下func enhancer *JwtTokenEnhancer) Enhanceoauth2Token *OAuth2Token, oauth2Details*OAuth2Details) *oAuth2Token error) { return enhancer.sign(oauth2Token, oauth2Details) } func (enhancer *JwtTokenEnhancer) sign(oauth2Token *OAuth2Token, Oauth2Details *OAuth2Detai1s) (*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 classh1js-keywordif/span err ! span classhljs-literalnil/span { span classh1js-keywordreturn/span span classh1js- literalnil/span, err } oauth2Token.TokenValue tokenValue oauth2Token.TokenType span classhljs-stringjwt/span span classhljs-keywordreturn/span oauth2Token, span classhljs-literalnil/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 classhljs-keywordif/span err ! span classh1js-literalni1/span { span classhljs-keywordreturn/span span classhljs- literalnil/spanspan classhljs-literalnil/spanerr } claims : token.Claims.(*oAuth2TokenCustomClaims) expiresTime : time.Unix(claims.ExpiresAt span classhljs-number0/span)spanclassh1js-comment//返回从 JwT 中解析出来的客户端和用户信息/span span classh1js-keywordreturn/span amp;OAuth2Token{ RefreshToken : amp;claims . RefreshToken, TokenValue: tokenValue , ExpiresTime: amp;expiresTime, } amp;OAuth2Detai1s{ User:claims.UserDetails, client:claims.ClientDetails, }span classh1js-literalnil/span }以WT作为令牌的载体每个令牌都是自包含的它携带了请求它的用户信息和客户端信息在资源服务器解析JWT 成功后即可定位当前请求的来源方。JwtTokenStore 存取令牌的能力主要借助 JwtTokenEnhancer 编码 JWT 和解码 JWT 实现。借助JwtTokenEnhancer我们可以很方便地管理JwtTokenStore中的令牌和用户、客户端之间的绑定关系。由于MT签发之后不可更改所以令牌只有在有效时长过后才会过期失效如果存在立即失效访问令牌的需求我还是建议你将令牌和用户、客户端的存储关系维护在外部存储中这样就能提供令牌主动失效的能力。令牌端点前面我们说明过授权服务器中提供了/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.BasicAuthQ; 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 请求头中加密前的信息按照clientld: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 的用户信息和客户端标识为clientld、密钥为clientSecret 的客户端信息使用密码类型为该客户端请求用户 aoho 的访问令牌curl 命令如下:curl -X POST \ http://1ocalhost:10098/oauth/token?grant_typepassword \ -H Authorization Basic Y2xpZw50swQ6Y2xpZw50u2vjcmV0 \ -H Content-Type: multipart/form-data \ -H Host: localhost:10098 \ -F usernameaoho \ -F password123456将访问令牌的 TokenValue 提交到/oauth/check_token 端点中即可验证访问令牌的有效性获取令牌绑定的客户端和用户信息curl 命令如下所示:curl -X POST \ http://localhost:10098/oauth/check_token?token... \ -H Authorization Basic Y2xpZw50swQ6Y2xpZw50u2vjcmv0 \ -H Host: localhost:l0098由于TokenValue 过长参数中使用省略号表示有效的访问令牌能够获取令牌绑定的客户端信息和用户信息如下所示{ o_auth_details:{ client: { clientId:clientId ClientSecret: , AccessTokenValiditySeconds:l800, RefreshTokenValiditySeconds:18000, RegisteredRedirecturi: http://127.0.0.1, AuthorizedGrantTypes: [ password, refresh_token ] }, user: { userId:1, username Password:, Authorities:[ simple ] } }, error: }到这里我们就实现了授权服务器颁发访问令牌和验证访问令牌有效性的主要能力。小结在统一认证与授权服务体系中授权服务器的主要职责为颁发令牌和验证令牌的有效性。在本文我们使用Go搭建了一个提供HTTP 接口的授权服务器。客户端能够使用密码类型通过携带用户的用户名密码向授权服务器请求访问令牌。访问令牌是一段自包含的MT对象其内包含了令牌绑定的用户和客户端信息。另外授权服务器还提供相应的接口给客户端和资源服务器验证访问令牌的有效性以及获取令牌绑定的用户和客户端信息。