盘点认证框架:Pac4j认证工具

总文档 :文章目录
Github : github.com/black-ant

一 . 前言

这一篇来说说 Pac4j , 为什么说他是认证工具呢 ,因为它真的提供了满满的封装类 ,可以让大部分应用快速的集成完成 ,使用者不需要关系认证协议的流程 , 只需要请求和获取用户即可

需要注意的是 , Pac4j 中多个不同的版本其实现差距较大 ,我的源码以 3.8.0 为主 ,分析其思想 , 然后再单独对比一下后续版本的优化 , 就不过多的深入源码细节了

二 . 基础使用

Pac4j 的一大特点就是为不同供应商提供了很完善的 Client , 基本上无需定制就可以实现认证的处理 , 但是这里我们尽量定制一个自己的流程 , 来看看 Pac4j 的一个定制流程是怎样的

以OAuth 为例 :

2.1 构建 Authoriza 请求

我们先构建一个 Client ,用来发起请求 :

OAuth20Configuration : 最原生的 OAuth 配置类 , 可以自行定制符合特定规范的配置类
OAuth20Client : 最原生的客户端调用类 , 后面可以看到 pac4j 有很多定制的client 类

public class OAuthService extends BasePac4jService {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    private final static String CLIENT_ID = "b45e4-41c0-demo";
    private final static String CLIENT_SECRET = "0407581-ef15-f773-demo";
    private final static String CALLBACK_URL = "http://127.0.0.1:8088/oauth/callback";

    /**
     * 执行 Authorization 请求
     *
     * @return
     */
    public void doOAuthRequest(HttpServletRequest request, HttpServletResponse response) {

        // Step 1 :构建请求 Client
        OAuth20Configuration config = new OAuth20Configuration();
        config.setApi(new DefaultOAuthAPI());
        config.setProfileDefinition(new DefaultOAuthDefinition());
        config.setScope("user");
        config.setKey(CLIENT_ID);
        config.setSecret(CLIENT_SECRET);

        // Step 2 : 构建一个 Client
        OAuth20Client client = new OAuth20Client();

        // 补充完善属性
        client.setConfiguration(config);
        client.setCallbackUrl(CALLBACK_URL);

        // Step 3 : 构建请求 , 这里通过 302 重定向
        J2EContext context = new J2EContext(request, response);
        client.redirect(context);

        // Step 4 : 缓存数据
        request.getSession().setAttribute("client", client);

    }
}


复制代码

注意 , 这里有个 DefaultOAuthAPI 和 DefaultOAuthDefinition , 定义的是 SSO 路径和 Profile 声明

DefaultOAuthAPI

DefaultOAuthAPI 中主要包含了请求的地址 , DefaultApi20 有2个抽象接口 , 我额外添加了一个自己的接口

DefaultOAuthAPI 不做任何限制 , 可以把任何需要的接口都放进去 , 用于后续取用.

public class DefaultOAuthAPI extends DefaultApi20 {

    public String getRootEndpoint() {
        return "http://127.0.0.1/sso/oauth2.0/";
    }

    @Override
    public String getAccessTokenEndpoint() {
        return getRootEndpoint() + "accessToken";
    }

    @Override
    protected String getAuthorizationBaseUrl() {
        return getRootEndpoint() + "authorize";
    }
}

复制代码

DefaultOAuthDefinition

该声明相当于一个字典 , 用于翻译 profile 返回的数据

整个类中做了下面这些事 :

  • 定义了 user profile 会返回的属性
  • 定义了各种转换类和映射
  • 定义了 profile 请求的地址
  • 定义了 转换数据的实际实现

public class DefaultOAuthDefinition extends OAuth20ProfileDefinition<DefaultOAuhtProfile, OAuth20Configuration> {

    public static final String IS_FROM_NEW_LOGIN = "isFromNewLogin";
    public static final String AUTHENTICATION_DATE = "authenticationDate";
    public static final String AUTHENTICATION_METHOD = "authenticationMethod";
    public static final String SUCCESSFUL_AUTHENTICATION_HANDLERS = "successfulAuthenticationHandlers";
    public static final String LONG_TERM_AUTHENTICATION_REQUEST_TOKEN_USED = "longTermAuthenticationRequestTokenUsed";

    public DefaultOAuthDefinition() {
        super(x -> new DefaultOAuhtProfile());
        primary(IS_FROM_NEW_LOGIN, Converters.BOOLEAN);
        primary(AUTHENTICATION_DATE, new DefaultDateConverter());
        primary(AUTHENTICATION_METHOD, Converters.STRING);
        primary(SUCCESSFUL_AUTHENTICATION_HANDLERS, Converters.STRING);
        primary(LONG_TERM_AUTHENTICATION_REQUEST_TOKEN_USED, Converters.BOOLEAN);
    }

    @Override
    public String getProfileUrl(final OAuth2AccessToken accessToken, final OAuth20Configuration configuration) {
        return ((DefaultOAuthAPI) configuration.getApi()).getRootEndpoint() + "/profile";
    }

    @Override
    public DefaultOAuhtProfile extractUserProfile(final String body) {
        final DefaultOAuhtProfile profile = newProfile();
        
        // 参数从 attributes 中获取
        final String attributesNode = "attributes 中获取";
        JsonNode json = JsonHelper.getFirstNode(body);
        if (json != null) {
            profile.setId(ProfileHelper.sanitizeIdentifier(profile, JsonHelper.getElement(json, "id")));
            json = json.get(attributesNode);
            if (json != null) {
            
                // 这里以 CAS 的返回做了不同的处理
                if (json instanceof ArrayNode) {
                    final Iterator<JsonNode> nodes = json.iterator();
                    while (nodes.hasNext()) {
                        json = nodes.next();
                        final String attribute = json.fieldNames().next();
                        convertAndAdd(profile, PROFILE_ATTRIBUTE, attribute, JsonHelper.getElement(json, attribute));
                    }

                } else if (json instanceof ObjectNode) {
                    final Iterator<String> keys = json.fieldNames();
                    while (keys.hasNext()) {
                        final String key = keys.next();
                        convertAndAdd(profile, PROFILE_ATTRIBUTE, key, JsonHelper.getElement(json, key));
                    }
                }
            } else {
                raiseProfileExtractionJsonError(body, attributesNode);
            }
        } else {
            raiseProfileExtractionJsonError(body);
        }
        return profile;
    }
}

复制代码

DefaultDateConverter

该对象用于解析数据 , 例如此处解析时间类型


public class DefaultDateConverter extends DateConverter {

    public DefaultDateConverter() {
        super("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
    }

    @Override
    public Date convert(final Object attribute) {
        Object a = attribute;
        if (a instanceof String) {
            String s = (String) a;
            int pos = s.lastIndexOf("[");
            if (pos > 0) {
                s = s.substring(0, pos);
                pos = s.lastIndexOf(":");
                if (pos > 0) {
                    s = s.substring(0, pos) + s.substring(pos + 1, s.length());
                }
                a = s;
            }
        }
        return super.convert(a);
    }
}

复制代码

DefaultOAuhtProfile

可以理解为一个 TO , 用于接收数据

public class DefaultOAuhtProfile extends OAuth20Profile {

    private static final long serialVersionUID = 1347249873352825528L;

    public Boolean isFromNewLogin() {
        return (Boolean) getAttribute(DefaultOAuthDefinition.IS_FROM_NEW_LOGIN);
    }

    public Date getAuthenticationDate() {
        return (Date) getAttribute(DefaultOAuthDefinition.AUTHENTICATION_DATE);
    }

    public String getAuthenticationMethod() {
        return (String) getAttribute(DefaultOAuthDefinition.AUTHENTICATION_METHOD);
    }

    public String getSuccessfulAuthenticationHandlers() {
        return (String) getAttribute(DefaultOAuthDefinition.SUCCESSFUL_AUTHENTICATION_HANDLERS);
    }

    public Boolean isLongTermAuthenticationRequestTokenUsed() {
        return (Boolean) getAttribute(DefaultOAuthDefinition.LONG_TERM_AUTHENTICATION_REQUEST_TOKEN_USED);
    }
}

复制代码

2.2 构建一个接收对象

    @GetMapping("callback")
    public void callBack(final HttpServletRequest request, final HttpServletResponse response) throws IOException {

        logger.info("------> [SSO 回调 pac4j OAuth 逻辑] <-------");

        // 从 Session 中获取缓存的对象
        OAuth20Client client = (OAuth20Client) request.getSession().getAttribute("client");
        J2EContext context = new J2EContext(request, response);

        // 获取 AccessToken 对应的Credentials
        final Credentials credentials = client.getCredentials(context);

        // 通过 Profile 获取 Profile
        final CommonProfile profile = client.getUserProfile(credentials, context);

        // Web 返回数据信息
        logger.info("------> Pac4j Demo 获取用户信息 :[{}] <-------", profile.toString());
        response.getWriter().println(profile.toString());
    }

复制代码

总结一下就是 :

  • DefaultOAuthAPI : 作为 metadata , 来标识请求的路径
  • DefaultOAuthDefinition : 解释器用于解释返回的含义
  • DefaultDateConverter : 用于转换数据
  • DefaultOAuhtProfile : to 对象用于承载数据

很简单的一个定制 , 可以适配多种不同的 OAuth 供应商

三 . 源码一览

3.1 OAuth 请求篇

3.1.1 Authoriza 流程

Authoriza 中核心类为 IndirectClient , 我们来简单看一下 IndirectClient的逻辑

发起 Authoriza 认证

C01- IndirectClient
    M101- redirect(WebContext context)
        ?- 之前可以看到 , 我们调用 redirect 发起了请求
    
    M102- getRedirectAction
        - 如果请求类型是 ajaxRequest , 则由 ajaxRequestResolver 进行额外处理
        - 
    
  
// M101 伪代码
public final HttpAction redirect(WebContext context) {
    RedirectAction action = this.getRedirectAction(context);
    return action.perform(context);
}
    
// M102 伪代码
public RedirectAction getRedirectAction(WebContext context) {
        this.init();
        if (this.ajaxRequestResolver.isAjax(context)) {
            RedirectAction action = this.redirectActionBuilder.redirect(context);
            this.cleanRequestedUrl(context);
            return this.ajaxRequestResolver.buildAjaxResponse(action.getLocation(), context);
        } else {
            String attemptedAuth = (String)context.getSessionStore().get(context, this.getName() + "$attemptedAuthentication");
            if (CommonHelper.isNotBlank(attemptedAuth)) {
                this.cleanAttemptedAuthentication(context);
                this.cleanRequestedUrl(context);
                throw HttpAction.unauthorized(context);
            } else {
                return this.redirectActionBuilder.redirect(context);
            }
        }
}    

C02- RedirectActionBuilder (OAuth20RedirectActionBuilder)
    M201- redirect
        - 生成 state 且放入 session
        2- this.configuration.buildService : 构建一个 OAuth20Service
        3- 通过 param 属性获取一个 authorizationUrl
        - RedirectAction.redirect(authorizationUrl) : 发起认证
        
        
// M201 伪代码
public RedirectAction redirect(WebContext context) {

    //伪代码 , 通过 generateState 生成 state ,且会放入 session
    String state=this.configuration.isWithState()?generateState : null;

    // M201-2 : OAuth20Service 是 OAuth 的业务类 
    OAuth20Service service = (OAuth20Service)this.configuration.buildService(context, this.client, state);
    // M201-3 : 设置了认证的地址
    String authorizationUrl = service.getAuthorizationUrl(this.configuration.getCustomParams());
    return RedirectAction.redirect(authorizationUrl);

} 


// 到这里还没有看到实际请求的情况 ,我们再往底层看看

我们回到 M101 方法的 perform 中
C- RedirectAction
    M- perform(WebContext context)
        -  this.type == RedirectAction.RedirectType.REDIRECT ? HttpAction.redirect(context, this.location) : HttpAction.ok(context, this.content);
        
// 再深入一层 , 真相大白了
public static HttpAction redirect(WebContext context, String url) {
    context.setResponseHeader("Location", url);
    context.setResponseStatus(302);
    return new HttpAction(302);
}

他使用的是302 重定向的状态码 , 由浏览器完成重定向 , 这里的充电关系地址是
http://127.0.0.1/sso/oauth2.0/authorize?response_type=code&client_id=b7a8cc2a-5dec-4a78&redirect_uri=http%3A%2F%2F127.0.0.1%3A9081%2Fmfa-client%2Foauth%2Fcallback%3Fclient_name%3DCasOAuthWrapperClient

复制代码

补充一 : OAuth20Service

OAuth20Service 是一个 OAuth 业务类 , 其中包含常用的 OAuth 操作

3.1.2 AccessToken 流程

在上文中 ,我们为 OAuth 请求构建了一个 CallBack 方法 , SSO 认证完成后会回调该方法 , 我们来看看其中的一些有趣的点 :

    public void oauthCallBack(final HttpServletRequest request, final HttpServletResponse response) throws IOException {

        // 这里可以和之前构建 Context 进行对比
        // 当时将多个属性放在了session 中 , 这里就形成了一个循环 , 将状态发了回来
        CasOAuthWrapperClient client = (CasOAuthWrapperClient) request.getSession().getAttribute("oauthClient");
        
        // 第二步 : 获取 AccessToken
        J2EContext context = new J2EContext(request, response);
        final OAuth20Credentials credentials = client.getCredentials(context);
        
        final CommonProfile profile = client.getUserProfile(credentials, context);

        response.getWriter().println(profile.toString());


    }

复制代码

来看一看 getCredentials 方法干了什么

C01- IndirectClient
    M103- getCredentials(WebContext context)
        - this.init() : OAuth 这一块主要是断言
            - CommonHelper.assertNotBlank("key", this.key);
            - CommonHelper.assertNotBlank("secret", this.secret);
            - CommonHelper.assertNotNull("api", this.api);
            - CommonHelper.assertNotNull("hasBeenCancelledFactory", this.hasBeenCancelledFactory);
            - CommonHelper.assertNotNull("profileDefinition", this.profileDefinition);
    
    


    public final C getCredentials(WebContext context) {
        this.init();
        C credentials = this.retrieveCredentials(context);
        if (credentials == null) {
            context.getSessionStore().set(context, this.getName() + "$attemptedAuthentication", "true");
        } else {
            this.cleanAttemptedAuthentication(context);
        }

        return credentials;
    }
    
// 继续索引 , 可以看到更复杂得
C03- BaseClient
    M301- retrieveCredentials
        -  this.credentialsExtractor.extract(context) : 获取一个 credentials 对象
            ?- 这个对象是之前 Authoriza 完成后返回的 Code 对象 :PS001
        - this.authenticator.validate(credentials, context) : 发起校验
            ?- 这里是 OAuth20Authenticator , 详见 
            
    
    
// 补充 PS001
#OAuth20Credentials# | code: OC-1-wVu2cc3p33ChsQshKd1rUabk6lggPB1QhWh | accessToken: null |
    
C04- OAuth20Authenticator
    M401- retrieveAccessToken
        - 从 OAuth20Credentials 获得 code
        - 通过 OAuth20Configuration 构建 OAuth20Service , 调用 getAccessToken

// M401 伪代码 : 这里就很清楚了
    protected void retrieveAccessToken(WebContext context, OAuthCredentials credentials) {
        OAuth20Credentials oAuth20Credentials = (OAuth20Credentials)credentials;
        String code = oAuth20Credentials.getCode();
        this.logger.debug("code: {}", code);

        OAuth2AccessToken accessToken;
        try {
            accessToken = ((OAuth20Service)((OAuth20Configuration)this.configuration).buildService(context, this.client, (String)null)).getAccessToken(code);
        } catch (InterruptedException | ExecutionException | IOException var7) {
            throw new HttpCommunicationException("Error getting token:" + var7.getMessage());
        }

        this.logger.debug("accessToken: {}", accessToken);
        oAuth20Credentials.setAccessToken(accessToken);
    }
    
    
C05- OAuth20Service
    M501- getAccessToken
        - OAuthRequest request = this.createAccessTokenRequest(code, pkceCodeVerifier);
        - this.sendAccessTokenRequestSync(request);
    M502- sendAccessTokenRequestSync
        - (OAuth2AccessToken)this.api.getAccessTokenExtractor().extract(this.execute(request));
               ?- 点进去可以发现 ,其底层实际上是一个 HttpClient 调用
               - httpClient.execute(userAgent, request.getHeaders(), request.getVerb(), request.getCompleteUrl(),request.getByteArrayPayload());
                   ?- PS002
                    
                

// PS002 补充 : 参数详见下图
http://127.0.0.1/sso/oauth2.0/accessToken?



        

复制代码

PS002

image.png

3.1.3 UserInfo

看了 AccessToken 的获取 , 再看看怎么换取 Userinfo


// Step 1 :起点方法 
final CommonProfile profile = client.getUserProfile(credentials, context);

C03- BaseClient
    M302- getUserProfile
        -  U profile = retrieveUserProfile(credentials, context);
    M303- retrieveUserProfile
        - this.profileCreator.create(credentials, context);
            ?- 这里是 OAuth20ProfileCreator : M601
            
C06- OAuthProfileCreator
    M601- create
        - T token = this.getAccessToken(credentials) : 获取了 Token
        - return this.retrieveUserProfileFromToken(context, token);
    M602- retrieveUserProfileFromToken
        - final OAuthProfileDefinition<U, T, O> profileDefinition = configuration.getProfileDefinition();
            ?- OAuthProfileDefinition 用于构建请求 , 包括发送的类型等
        - final String profileUrl = profileDefinition.getProfileUrl(accessToken, configuration);
            ?- profile 地址
        - final S service = this.configuration.buildService(context, client, null);
            ?- 构建一个 Service
        - final String body = sendRequestForData(service, accessToken, profileUrl, profileDefinition.getProfileVerb());
            ?- 请求 Profile , 这里实际上就已经调用拿到数据了
       -  final U profile = (U) configuration.getProfileDefinition().extractUserProfile(body);
           ?- 解析成 Profile 对象
       -  addAccessTokenToProfile(profile, accessToken);
           ?- 构建最后的对象
            


复制代码

image.png

3.2 SAML 篇

3.2.1 发起请求

// Step 1 : 发起请求
- 构建一个 Configuration
- 构建一个 Client 
    - 因为 saml 的 API 都在 metadata 中 , 所以这里没有注入 API 的需求    
    
--> 发起调用
RedirectAction action = client.getRedirectAction(context);
action.perform(context);    

    -  return redirectActionBuilder.redirect(context);
        ?- 一样的套路 , 这里的 builder 是 SAML2RedirectActionBuilder

// 最后还是一样构建了一个 SAML 的 302 请求

复制代码

看一下请求的结果

image-20210414115606499.png

3.2.2 接收数据

后面仍然是一模一样的 , 只不过 Authenticator 变成了 SAML2Authenticator


final SAML2Client client = (SAML2Client) request.getSession().getAttribute("samlclient");

// 获取 J2EContext 对象
J2EContext context=new J2EContext(request,response);
final SAML2Credentials credentials = client.getCredentials(context);

// 获取 profile 数据
final CommonProfile profile = client.getUserProfile(credentials, context);
response.getWriter().println(profile.toString());

C- SAML2Authenticator
    M- validate
        - final SAML2Profile profile = getProfileDefinition().newProfile();
            ?- 获取返回的 Profile
        - profile.addAuthenticationAttribute(SESSION_INDEX, credentials.getSessionIndex());
        - profile.addAuthenticationAttribute(SAML_NAME_ID_FORMAT, nameId.getFormat());
        - profile.addAuthenticationAttribute(SAML_NAME_ID_NAME_QUALIFIER, nameId.getNameQualifier());
        - profile.addAuthenticationAttribute(SAML_NAME_ID_SP_NAME_QUALIFIER, nameId.getSpNameQualifier());
        - profile.addAuthenticationAttribute(SAML_NAME_ID_SP_PROVIDED_ID, nameId.getSpProviderId());
            ?- 配置相关属性
                
                
//  final CommonProfile profile = client.getUserProfile(credentials, context);

复制代码

saml0011.jpg

四 . 深入分析

Pac4j 是一个很好的开源项目 , 从流程上讲 , 他拥有非常好的扩展性 (PS: 个人写产品很喜欢扩展性 , 什么都可配) , 这在开源里面是一个很大的优势 , 它的整体流程基本上可以看成以下几个部分

Configuration 体系

saml0012.jpg

Client 体系

IndirectClient.png

Credentials 体系

Credentials.png

Profile 体系

UserProfile.png

在这么多体系的情况下 ,通过 Context 完成整体容器的协调 , 在通过 RedirectAction 做统一的 请求重定向 .

为什么专门提 RedirectAction 呢 ?

因为我认为 pac4j 把所有的请求都抽象成了2个部分 , 一个是发起认证 , 一个的 callback 返回 ,
以这2个直观的操作为边界 , 再在其中加入认证信息获取等操作 , 用户基本上对请求的调用是不可见的.

DemoSSO配置 Configuration构建 Demo client发起 OAuth 认证返回认证信息Definition 解析返回 , 拿到一个 AccessToken Credentials通过 token 发起信息获取返回用户信息解析为 ProfileDemoSSO

五 . 开源分析

那么 , 从 pac4j 里面 , 能学到哪些优点呢?

首先 , pac4j 的定位是什么?

pac4j 是一个认证工具 , 或者说 SDK , 他解决了认证过程的复杂性 , 使用者进行简单的调用就可以直接拿到用户信息.

而他的第一个优点 , 就是兼容性和可配置性 , 我提供了了这么多 client , 你可以省事直接调 , 也可以自己定制 ,都没关系.


从代码结构上说 , pac4j 的第二个优点就是结构清晰 .

我们从上面的分析中 , 就能感觉到 , 能做什么 , 怎么做 , 怎么命名 ,其实都规约好了 , 进行简单的实现就可以满足要求.


而我认为第三个优点 , 就是耦合性低.

pac4j 采用的使 maven 聚合方式 , 想实现什么协议 , 就只引用相关包即可 . 代码与代码间的依赖度也低 , 这同样对定制有很大的好处, 值得学习.

六. 总结

pac4j 这工具 , 如果为了快速集成上线 , 确实是一个很理想的工具 ,

个人在写demo 的时候 , 也经常用他做基础测试 , 别说 , 真挺好用

代码已经发在 git 上面 , case 4.6.2 , 可以直接看.