盘点认证框架 Pac4j 认证工具

总文档 :文章目录

Github : github.com/black-ant

一 . 前言

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

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

二 . 基础使用

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

以OAuth 为例 :

2.1 构建 Authoriza 请求

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
java复制代码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 不做任何限制 , 可以把任何需要的接口都放进去 , 用于后续取用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码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 请求的地址
  • 定义了 转换数据的实际实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
java复制代码
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码
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 , 用于接收数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码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 构建一个接收对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码    @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 认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
java复制代码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 认证完成后会回调该方法 , 我们来看看其中的一些有趣的点 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    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 方法干了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
java复制代码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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码
// 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 发起请求

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码
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 , 可以直接看.

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%