文章目录
版本
spring-security-web-5.2.2.RELEASE.jar
Q1:业务平台从不拦截页面跳转到认证服务器登录, 成功后跳回业务平台原页面
请求url格式为:http://10.0.17.85/login?service=http%3A%2F%2F10.0.17.85%2Flogin(service后面内容经过URL编码),登录成功后通过service跳转回原页面
具体流程如下,1-6步为业务平台:剩下的为认证服务器:
- AbstractAuthenticationProcessingFilter:追踪源码发现异常抛出的大致过程为
AbstractAuthenticationProcessingFilter.doFilter()
里面调用attemptAuthentication()
- OAuth2ClientAuthenticationProcessingFilter:进入到子类
OAuth2ClientAuthenticationProcessingFilter.attemptAuthentication()
方法中,调用restTemplate.getAccessToken()
(为什么会进入到OAuth2ClientAuthenticationProcessingFilter? OAuth2 SSO核心配置_SsoSecurityConfigurer,说白了就是开启了注解@EnableOAuth2Sso) - OAuth2RestTemplate.java:
restTemplate
具体实现类为OAuth2RestTemplate.java
,getAccessToken()
方法中调用本身的acquireAccessToken()
,acquireAccessToken()
方法中调用accessTokenProvider.obtainAccessToken()
- AuthorizationCodeAccessTokenProvider.java:
accessTokenProvider
具体实现类为AuthorizationCodeAccessTokenProvider.java
,obtainAccessToken()
内部调用getRedirectForAuthorization(),抛出异常UserRedirectRequiredException
- OAuth2ClientContextFilter.java:
UserRedirectRequiredException
被OAuth2ClientContextFilter
捕获,调用内部方法redirectUser()
,redirectUser()
内调用redirectStrategy.sendRedirect()
- DefaultRedirectStrategy.java:
redirectStrategy
具体实现类为DefaultRedirectStrategy.java
,发送请求到认证服务器http://10.0.17.85:7003/oauth/authorize?client_id=a2e646f6e6424e52bee903e06c266033&redirect_uri=http://10.0.17.85/login?service%3Dhttp%253A%252F%252F10.0.17.85%252F&response_type=code&state=J6gNZQ
- 跳转到认证服务器,内部流程待完善,关键步骤如下
- ExceptionTranslationFilter.java:
doFilter()
捕获异常AccessDeniedException,调用内部方法handleSpringSecurityException().
,调用内部方法sendStartAuthentication()
,调用内部方法authenticationEntryPoint.commence()
,重写authenticationEntryPoint
(关键)
业务平台端
重写认证成功处理器SavedRequestAwareAuthenticationSuccessHandler.java
,在他的父类SimpleUrlAuthenticationSuccessHandler.java
的父类AbstractAuthenticationTargetUrlRequestHandler
中,一个关键方法determineTargetUrl()
,里面说明了如果有属性targetUrlParameter
则按这个属性的值去重定向,也就是对自定义的成功处理器设置这个targetUrlParameter
值为service
主要难在如何去启用这个自定义成功处理器,因为开启了SSO所以只能走OAuth2ClientAuthenticationProcessingFilter.java
,需要解决的就是将OAuth2ClientAuthenticationProcessingFilter.java
的成功处理器改为自定义的,没有办法对原来的操作,只能想办法在WebSecurityConfig中新建一个一样的(参考
OAuth2 SSO核心配置_SsoSecurityConfigurer),然后把过滤器放的靠前一些,具体实现如下:
WebSecurityConfig.java
@Configuration
@EnableWebSecurity// 这个注解必须加,开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableOAuth2Sso
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 自定义OAuth2ClientAuthenticationProcessingFilter, 实现根据认证服务器传回来参数service进行指定页面跳转
*/
private Filter ssoFilter() {
/* 源码部分 */
OAuth2ClientAuthenticationProcessingFilter ssoFilter = new OAuth2ClientAuthenticationProcessingFilter("/login");
ResourceServerTokenServices tokenServices = this.applicationContext.getBean(ResourceServerTokenServices.class);
OAuth2RestOperations restTemplate = this.applicationContext.getBean(UserInfoRestTemplateFactory.class)
.getUserInfoRestTemplate();
ssoFilter.setRestTemplate(restTemplate);
ssoFilter.setTokenServices(tokenServices);
ssoFilter.setApplicationEventPublisher(this.applicationContext);
/* 自定义部分 */
ssoFilter.setAuthenticationSuccessHandler(mySavedRequestAwareAuthenticationSuccessHandler);
return ssoFilter;
}
/**
* 安全过滤器链配置方法, 通过它来进行自定义安全访问策略
* 这个是我们使用最多的,用来配置 HttpSecurity
* HttpSecurity 用于构建一个安全过滤器链 SecurityFilterChain 。SecurityFilterChain 最终被注入核心过滤器
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// ************ 这里应用上新建的OAuth2ClientAuthenticationProcessingFilter **********
http.addFilterBefore(ssoFilter(), BasicAuthenticationFilter.class);
if(csrfDisable) {
http
.cors()
.and()
.authorizeRequests()
// .antMatchers(HttpMethod.OPTIONS).permitAll()
.antMatchers(excludeUrl).permitAll() //excludeUrl为不需要权限即可访问的
// .antMatchers(HttpMethod.GET).permitAll()//get请求不需要鉴权
.anyRequest().authenticated()//别的请求都需要鉴权,如果鉴定不过则登录
.and()
.logout().logoutRequestMatcher(new AntPathRequestMatcher("/saas/logout", "GET"))
.logoutSuccessUrl(oauthLogoutUrl)
.and()
.csrf().disable()//关闭csrf
.headers().frameOptions().sameOrigin();
}else{
http
.cors()
.and()
.authorizeRequests()
// .antMatchers(HttpMethod.OPTIONS).permitAll()
.antMatchers(excludeUrl).permitAll() //excludeUrl为不需要权限即可访问的
// .antMatchers(HttpMethod.GET).permitAll()//get请求不需要鉴权
.anyRequest().authenticated()//别的请求都需要鉴权,如果鉴定不过则登录
.and()
.logout().logoutRequestMatcher(new AntPathRequestMatcher("/saas/logout", "GET"))
.logoutSuccessUrl(oauthLogoutUrl)
.and()
.csrf().ignoringAntMatchers(ignoringCsrfUrl);// 在 ignoringCsrfUrl 端点忽略csrf验证
if(frameOptionsDisable) {
http.headers().frameOptions().disable();
}else {
http.headers().frameOptions().sameOrigin();
}
}
}
// 省略其他代码...
}
MySavedRequestAwareAuthenticationSuccessHandler.java
/**
* 自定义成功处理器, 设置目标url获取方式为 【request参数service】, 主要流程还是走源码
*/
@Component
public class MySavedRequestAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
public MySavedRequestAwareAuthenticationSuccessHandler() {
super();
super.setTargetUrlParameter("service");
}
}
认证服务器端
重写AuthenticationEntryPoint、AuthenticationFailureHandler
- 自定义AuthenticationEntryPoint名为UnauthenticatedEntryPoint,主要是重写方法
buildRedirectUrlToLoginPage()
,将业务平台传过来的service拼接到url上,
重定向到/login,然后在自己写的login方法中,把URLDecoder的service
写到model给前端放到一个隐藏的input,在提交登录表单时返回给服务器,供失败时调用AuthenticationFailureHandler获取service重写拼接url使用 - 自定义AuthenticationFailureHandler名为MyAuthenticationFailureHandler,重写方法
onAuthenticationFailure()
- 在WebSecurityConfig中启用这些自定义的类
具体代码实现:
省略前端登录页input标签
UnauthenticatedEntryPoint,java
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.*;
import org.springframework.security.web.util.RedirectUrlBuilder;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 自定义认证失败重定向, 仿 {@link org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint}
*/
@Component
public class UnauthenticatedEntryPoint implements AuthenticationEntryPoint {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
private final PortResolver portResolver = new PortResolverImpl();
public UnauthenticatedEntryPoint() {
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
System.out.println("------- do 修改来源url --------");
String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
redirectStrategy.sendRedirect(request, response, redirectUrl);
}
protected String buildRedirectUrlToLoginPage(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) {
String loginForm = "/login";
int serverPort = portResolver.getServerPort(request);
String scheme = request.getScheme();
RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
urlBuilder.setScheme(scheme);
urlBuilder.setServerName(request.getServerName());
urlBuilder.setPort(serverPort);
urlBuilder.setContextPath(request.getContextPath());
urlBuilder.setPathInfo(loginForm);
String queryString = request.getQueryString();
if (StringUtils.isNotBlank(queryString)) {
String[] params = queryString.split("&");
for (String param : params) {
if (param.contains("redirect_uri") && param.contains("service")) {
// 获取从哪跳过来的并放到session
String fromUrl = param.substring(param.indexOf("?") + 11);
// request.getSession().setAttribute("service", fromUrl);
// 拼接新的url重定向
urlBuilder.setQuery("service=" + fromUrl);
break;
}
}
}
return urlBuilder.getUrl();
}
}
MyAuthenticationFailureHandler.java
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null) {
request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION", exception.getMessage());
request.getSession().setAttribute("username", request.getParameter("username"));
request.getSession().setAttribute("password", request.getParameter("password"));
request.getSession().setAttribute("loginType", "password");
}
String url = "/login?error";
// 不拦截页面设置固定url跳转时会赋值 url格式: http://业务平台ip/xxx?service=???
String fromUrl = request.getParameter("service");
// 带有#的拦截页面跳转会赋值
String suffix = request.getParameter("suffix");
if (StringUtils.isNotBlank(fromUrl)) {
url += "&service=" + fromUrl;
}else if (StringUtils.isNotBlank(suffix)) {
request.setAttribute("suffix", suffix);
url += "&suffix=" + suffix;
}
this.redirectStrategy.sendRedirect(request, response,url);
}
}
WebSecurityConfig.java部分代码
@Configuration
@EnableWebSecurity// 这个注解必须加,开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyAuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private UnauthenticatedEntryPoint unauthenticatedEntryPoint;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.apply(smsAuthenticationConfig);
http
.cors()
.and()
.csrf().disable()
.requestMatchers()
.antMatchers("/**")
.and()
.authorizeRequests()
// .mvcMatchers("/").permitAll()
.antMatchers("/home/**").authenticated()
.antMatchers("/oauth/**").authenticated()
.antMatchers("/**").permitAll()
// .antMatchers("/error").permitAll()
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login")
.successHandler(authenticationSucessHandler)
.failureHandler(authenticationFailureHandler)
// .successHandler(authenticationSucessHandler)
// .successForwardUrl("")
// .failureHandler(authenticationFailureHandler)
// .failureUrl(failureUrl)
.permitAll()
.and()
.logout()
// .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
// .logoutSuccessUrl("/")
// .deleteCookies("JSESSIONID", "remember-me")
.permitAll()
.and()
.exceptionHandling().authenticationEntryPoint(unauthenticatedEntryPoint) // 自定义认证失败重定向
}
// 省略其他代码
}
Q2:拦截带有#的url时,成功登录后跳转回原url
业务平台ip+port为10.0.17.85:80, 认证服务器ip+port为10.0.17.85:7003
认证服务器端
url上#之后的内容服务器是无法拿到的
,
但是当复制业务平台的某个带#的url,如10.0.17.85/home/#/navigation/home
到浏览器上请求时,
被拦截到认证服务器登录页url变成了http://10.0.17.85:7003/login#/navigation/home
,此时在认证服务器的登录页通过js可以拿到url#之后的内容,
这样就可以在提交登录表单时,把url上#之后的#/navigation/home
放入到登录表单input中(如input的name叫suffix),通过参数的形式传给认证服务器,js获取url上#后内容方法如下:
$(function () {
var url = window.location.href;
if (url.indexOf("#") != -1) {
var suffix = url.substring(url.indexOf("#"), url.length);
$("input[name='suffix']").val(suffix);
console.info(suffix);
}
})
然后就是如何把认证服务器接收到的参数传回去到业务平台?
通过在认证服务器表单页面输入正确的账号密码,成功后会跳回业务平台,过程中在浏览器可以看到的共四个请求(如果是从请求复制的url开始的话是6个),分别为:
- http://10.0.17.85:7003/login
- http://10.0.17.85:7003/oauth/authorize?client_id=a2e646f6e6424e52bee903e06c266033&redirect_uri=http://10.0.17.85/login&response_type=code&state=2g5Z03
- http://10.0.17.85/login?code=Q14o6N&state=2g5Z03
- http://10.0.17.85/home/
为什么执行完
http://10.0.17.85:7003/login
后跳到了http://10.0.17.85:7003/oauth/authorize?......
?
这就需要从10.0.17.85/home/#/navigation/home
起开始分析,文章内的Q3是前提部分,在重定向到认证服务器的/login
之前,将业务平台的请求保存到RequestCache里的SavedRequest,这样在登录成功后会重定向到SavedRequest里保存的redirect_url
/login
是在认证服务器,可以进行拼接
/oauth/authorize
是封装在AuthorizationEndpoint.java
中的,不好动
/login?code=Q14o6N&state=2g5Z03
是已经回到业务平台了,根本不可能了,业务平台通过code和state内部发送Post请求认证服务器TokenEndpoint.java
的/oauth/token
获取token,所以在浏览器F12看不到请求/oauth/token
所以只能在第一个请求下手
第一个请求成功后会调用认证成功处理器,默认会走的SavedRequestAwareAuthenticationSuccessHandler.java
,自定义成功处理器MyAuthenticationSucessHandler.java
,将参数拼接到targetUrl,targetUrl就是savedRequest里保存的redirect_url,也就是/login
之后的/oauth/authorize
,自定义成功处理器代码如下:
MyAuthenticationSucessHandler.java
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class MyAuthenticationSucessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest == null) {
super.onAuthenticationSuccess(request, response, authentication);
return;
}
String targetUrlParameter = getTargetUrlParameter();
if (isAlwaysUseDefaultTargetUrl()
|| (targetUrlParameter != null && org.springframework.util.StringUtils.hasText(request
.getParameter(targetUrlParameter)))) {
requestCache.removeRequest(request, response);
super.onAuthenticationSuccess(request, response, authentication);
return;
}
clearAuthenticationAttributes(request);
// Use the DefaultSavedRequest URL
String targetUrl = savedRequest.getRedirectUrl();
// 带有#的拦截页面跳转会赋值
String suffix = request.getParameter("suffix");
if (StringUtils.isNotBlank(suffix) && targetUrl.contains("redirect_uri")) {
targetUrl = targetUrl + "&suffix=" + suffix;
}
logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
还有就是认证失败后,也需要对url进行重写,将#之后的内容保存到form表单并在url上显示出来,需要在form表单加个input name为suffix,自定义认证失败处理器MyAuthenticationFailureHandler.java
,代码如下:
MyAuthenticationFailureHandler.java
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null) {
request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION", exception.getMessage());
request.getSession().setAttribute("username", request.getParameter("username"));
request.getSession().setAttribute("password", request.getParameter("password"));
request.getSession().setAttribute("loginType", "password");
}
String url = "/login?error";
// 不拦截页面设置固定url跳转时会赋值 url格式: http://业务平台ip/xxx?service=???
String fromUrl = request.getParameter("service");
// 带有#的拦截页面跳转会赋值
String suffix = request.getParameter("suffix");
if (StringUtils.isNotBlank(fromUrl)) {
url += "&service=" + fromUrl;
}else if (StringUtils.isNotBlank(suffix)) {
request.setAttribute("suffix", suffix);
url += "&suffix=" + suffix;
}
this.redirectStrategy.sendRedirect(request, response,url);
}
自定义成功处理器和失败处理器写好了之后,在WebSecurityConfig.java
中配置上(这个类是自定义的),也就是extends WebSecurityConfigurerAdapter
的那个,部分代码如下:
@Configuration
@EnableWebSecurity// 这个注解必须加,开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// Spring会自动寻找实现接口的类注入,会找到我们的 UserDetailsServiceImpl 类
@Autowired
private SysUserDetailsService sysUserDetailsService;
@Autowired
private MyAuthenticationSucessHandler authenticationSucessHandler;
@Autowired
private MyAuthenticationFailureHandler authenticationFailureHandler;
// 省略其他代码...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.apply(smsAuthenticationConfig);
http
.cors()
.and()
.csrf().disable()
.requestMatchers()
.antMatchers("/**")
.and()
.authorizeRequests()
.antMatchers("/home/**").authenticated()
.antMatchers("/oauth/**").authenticated()
.antMatchers("/**").permitAll()
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login")
.successHandler(authenticationSucessHandler)
.failureHandler(authenticationFailureHandler)
.permitAll()
.and()
.logout()
.permitAll()
.and()
.exceptionHandling().authenticationEntryPoint(unauthenticatedEntryPoint) // 自定义认证失败重定向
}
// 省略其他代码...
}
业务平台端
无代码
Q3:访问需要认证的url是如何拦截到认证服务器的?
FilterSecurityInterceptor:FilterSecurityInterceptor.doFilter()
开始,具体实现待完善,大概就是投票,比对一下当前用户跟被认证的表达式,不一样拒绝+1,比完后票数>1就抛AccessDeniedException
ExceptionTranslationFilter:ExceptionTranslationFilter.doFilter()
会抓异常,调用内部方法handleSpringSecurityException()
根据异常AccessDeniedException
发送开始认证请求sendStartAuthentication()
,sendStartAuthentication()
里会保存这次请求,即requestCache.saveRequest(request, response);