这一篇来说说 Pac4j , 为什么说他是认证工具呢 ,因为它真的提供了满满的封装类 ,可以让大部分应用快速的集成完成 ,使用者不需要关系认证协议的流程 , 只需要请求和获取用户即可
需要注意的是 , Pac4j 中多个不同的版本其实现差距较大 ,我的源码以 3.8.0 为主 ,分析其思想 , 然后再单独对比一下后续版本的优化 , 就不过多的深入源码细节了
Pac4j 的一大特点就是为不同供应商提供了很完善的 Client , 基本上无需定制就可以实现认证的处理 , 但是这里我们尽量定制一个自己的流程 , 来看看 Pac4j 的一个定制流程是怎样的
以OAuth 为例 :
我们先构建一个 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 返回的数据
整个类中做了下面这些事 :
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);
}
}
@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());
}
总结一下就是 :
很简单的一个定制 , 可以适配多种不同的 OAuth 供应商
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 操作
在上文中 ,我们为 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?
看了 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);
?- 构建最后的对象
// Step 1 : 发起请求
- 构建一个 Configuration
- 构建一个 Client
- 因为 saml 的 API 都在 metadata 中 , 所以这里没有注入 API 的需求
--> 发起调用
RedirectAction action = client.getRedirectAction(context);
action.perform(context);
- return redirectActionBuilder.redirect(context);
?- 一样的套路 , 这里的 builder 是 SAML2RedirectActionBuilder
// 最后还是一样构建了一个 SAML 的 302 请求
看一下请求的结果
后面仍然是一模一样的 , 只不过 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);
Pac4j 是一个很好的开源项目 , 从流程上讲 , 他拥有非常好的扩展性 (PS: 个人写产品很喜欢扩展性 , 什么都可配) , 这在开源里面是一个很大的优势 , 它的整体流程基本上可以看成以下几个部分
Configuration 体系
Client 体系
Credentials 体系
Profile 体系
在这么多体系的情况下 ,通过 Context 完成整体容器的协调 , 在通过 RedirectAction 做统一的 请求重定向 .
为什么专门提 RedirectAction 呢 ?
因为我认为 pac4j 把所有的请求都抽象成了2个部分 , 一个是发起认证 , 一个的 callback 返回 ,
以这2个直观的操作为边界 , 再在其中加入认证信息获取等操作 , 用户基本上对请求的调用是不可见的.
那么 , 从 pac4j 里面 , 能学到哪些优点呢?
首先 , pac4j 的定位是什么?
pac4j 是一个认证工具 , 或者说 SDK , 他解决了认证过程的复杂性 , 使用者进行简单的调用就可以直接拿到用户信息.
而他的第一个优点 , 就是兼容性和可配置性 , 我提供了了这么多 client , 你可以省事直接调 , 也可以自己定制 ,都没关系.
从代码结构上说 , pac4j 的第二个优点就是结构清晰 .
我们从上面的分析中 , 就能感觉到 , 能做什么 , 怎么做 , 怎么命名 ,其实都规约好了 , 进行简单的实现就可以满足要求.
而我认为第三个优点 , 就是耦合性低.
pac4j 采用的使 maven 聚合方式 , 想实现什么协议 , 就只引用相关包即可 . 代码与代码间的依赖度也低 , 这同样对定制有很大的好处, 值得学习.
pac4j 这工具 , 如果为了快速集成上线 , 确实是一个很理想的工具 ,
个人在写demo 的时候 , 也经常用他做基础测试 , 别说 , 真挺好用
代码已经发在 git 上面 , case 4.6.2 , 可以直接看.
阅读量:2015
点赞量:0
收藏量:0