Spring Security OAuth2 SSO_业务笔记

版本

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步为业务平台:剩下的为认证服务器:

  1. AbstractAuthenticationProcessingFilter:追踪源码发现异常抛出的大致过程为 AbstractAuthenticationProcessingFilter.doFilter()里面调用 attemptAuthentication()
  2. OAuth2ClientAuthenticationProcessingFilter:进入到子类OAuth2ClientAuthenticationProcessingFilter.attemptAuthentication()方法中,调用restTemplate.getAccessToken()(为什么会进入到OAuth2ClientAuthenticationProcessingFilter? OAuth2 SSO核心配置_SsoSecurityConfigurer,说白了就是开启了注解@EnableOAuth2Sso)
  3. OAuth2RestTemplate.java:restTemplate具体实现类为OAuth2RestTemplate.javagetAccessToken()方法中调用本身的acquireAccessToken()acquireAccessToken()方法中调用accessTokenProvider.obtainAccessToken()
  4. AuthorizationCodeAccessTokenProvider.java:accessTokenProvider具体实现类为AuthorizationCodeAccessTokenProvider.javaobtainAccessToken()内部调用getRedirectForAuthorization(),抛出异常UserRedirectRequiredException
  5. OAuth2ClientContextFilter.java:UserRedirectRequiredExceptionOAuth2ClientContextFilter捕获,调用内部方法redirectUser()redirectUser()内调用redirectStrategy.sendRedirect()
  6. 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
  7. 跳转到认证服务器,内部流程待完善,关键步骤如下
  8. 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

  1. 自定义AuthenticationEntryPoint名为UnauthenticatedEntryPoint,主要是重写方法buildRedirectUrlToLoginPage(),将业务平台传过来的service拼接到url上,
    重定向到/login,然后在自己写的login方法中,把URLDecoder的service写到model给前端放到一个隐藏的input,在提交登录表单时返回给服务器,供失败时调用AuthenticationFailureHandler获取service重写拼接url使用
  2. 自定义AuthenticationFailureHandler名为MyAuthenticationFailureHandler,重写方法onAuthenticationFailure()
  3. 在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);

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值