spring oauth2实现单点登录,Vue+spring boot+oauth2前后端分离

相关文章

1、spring boot oauth2单点登录(一)-实现例子
2、spring boot oauth2单点登录(二)-客户端信息存储
3、spring boot oauth2单点登录(三)-token存储方式

源码地址

后端:https://gitee.com/fengchangxin/sso
前端:https://gitee.com/fengchangxin/sso-page
前后端分离单点登录,后端返回json数据,不涉及页面渲染。最近在学习如何用spring oauth2来做单点登录时,发现网上的例子基本上都是不分离的,或者只讲原理而没有代码。通过对spring oauth2的debug跟踪,大概了解它的执行流程,然后才做出这个例子,但由于前端了解不多,以及对spring oauth2源码了解不够深,与标准的oauth2流程有些差异,如果大家有更好的想法可以留言,但不一定回。下面进入正题:

一、环境准备

此篇文章涉及的项目基于windows系统
后端:jdk1.8、三个spring boot服务(授权中心服务:auth、客户端服务1:client1、客户端服务2:client2)
前端:node.js、vue.js,三个Vue项目(授权中心前端:auth、客户端1前端:client1、客户端2前端:client2)
三个域名:oauth.com(授权中心)、client1.com(客户端1)、client2.com(客户端2)
准备好nginx

后端项目模块.png

前端项目模块.png

二、后端项目

1、授权中心服务:auth

1.1 自定义未登录、登录成功、登录失败的返回处理

未登录处理
在这里做了两个逻辑处理,根据参数isRedirect是否是true,如果是true则重定向到授权中心auth的前端登录页,若为空或false,则返回授权中心的后端授权接口,并带上isRedirect=true,定义Result对象的code为800则为未登录。

@Component("unauthorizedEntryPoint")
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        Map paramMap = request.getParameterMap();
        StringBuilder param = new StringBuilder();
        paramMap.forEach((k, v) -> {
            param.append("&").append(k).append("=").append(v[0]);
        });
        param.deleteCharAt(0);
        String isRedirectValue = request.getParameter("isRedirect");
        if (!StringUtils.isEmpty(isRedirectValue) && Boolean.valueOf(isRedirectValue)) {
            response.sendRedirect("http://oauth.com/authPage/login?"+param.toString());
            return;
        }
        String authUrl = "http://oauth.com/auth/oauth/authorize?"+param.toString()+"&isRedirect=true";
        Result result = new Result();
        result.setCode(800);
        result.setData(authUrl);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        PrintWriter writer = response.getWriter();
        ObjectMapper mapper = new ObjectMapper();
        writer.print(mapper.writeValueAsString(result));
        writer.flush();
        writer.close();
    }
}

登录成功处理
这比较简单,就返回一个json对象,Result对象的code为0则是成功,其他失败。

@Component("successAuthentication")
public class SuccessAuthentication extends SavedRequestAwareAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        PrintWriter writer = response.getWriter();
        Result result = new Result();
        result.setCode(0);
        result.setMsg("成功");
        ObjectMapper mapper = new ObjectMapper();
        writer.println(mapper.writeValueAsString(result));
        writer.flush();
        writer.close();
    }
}

登录失败处理
和登录成功差不多的处理

@Component("failureAuthentication")
public class FailureAuthentication extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        PrintWriter writer = response.getWriter();
        Result result = new Result();
        result.setCode(1000);
        result.setMsg("登录失败");
        ObjectMapper mapper = new ObjectMapper();
        writer.println(mapper.writeValueAsString(result));
        writer.flush();
        writer.close();
    }
}
1.2 资源配置和security配置

资源配置
定义了两个客户端,可以通过数据库方式来加载,至于如何实现网上有教程,我这里图方便用硬编码两个客户端信息,这里有个问题需要注意,就是客户端的回调地址只能写/login,这是因为@EnableOAuth2Sso的客户端默认传的授权回调地址就是login,这应该可以修改,但我不知道如何操作。

@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {


    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .tokenKeyAccess("isAuthenticated()");
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(inMemoryClientDetailsService());
    }


    @Bean
    public ClientDetailsService inMemoryClientDetailsService() throws Exception {
        return new InMemoryClientDetailsServiceBuilder()
                // client oa application
                .withClient("client1")
                .secret(passwordEncoder.encode("client1_secret"))
                .scopes("all")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .redirectUris("http://client1.com/client1/login")
                .accessTokenValiditySeconds(7200)
                .autoApprove(true)

                .and()

                // client crm application
                .withClient("client2")
                .secret(passwordEncoder.encode("client2_secret"))
                .scopes("all")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .redirectUris("http://client2.com/client2/login")
                .accessTokenValiditySeconds(7200)
                .autoApprove(true)

                .and()
                .build();
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.accessTokenConverter(jwtAccessTokenConverter())
                .tokenStore(jwtTokenStore());
    }

    @Bean
    public JwtTokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey("123456");
        return jwtAccessTokenConverter;
    }

}

security 配置
这里把上面自定义的未登录、登录成功和失败的处理加载进来,同时设了两个用户账号admin和user1,密码都是123456,用于页面登录。

@EnableWebSecurity
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Autowired
    private SuccessAuthentication successAuthentication;
    @Autowired
    private FailureAuthentication failureAuthentication;
    @Autowired
    private UnauthorizedEntryPoint unauthorizedEntryPoint;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsServiceBean()).passwordEncoder(passwordEncoder());
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
//        http.formLogin()
//                .loginPage("/login")
//                .and()
//                .authorizeRequests()
//                .antMatchers("/login").permitAll()
//                .anyRequest()
//                .authenticated()
//                .and().csrf().disable().cors();

        http.cors().and().csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedEntryPoint)
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().successHandler(successAuthentication).failureHandler(failureAuthentication);
    }

    @Bean
    @Override
    public UserDetailsService userDetailsServiceBean() {
        Collection users = buildUsers();

        return new InMemoryUserDetailsManager(users);
    }

    private Collection buildUsers() {
        String password = passwordEncoder().encode("123456");

        List users = new ArrayList<>();

        UserDetails user_admin = User.withUsername("admin").password(password).authorities("ADMIN", "USER").build();
        UserDetails user_user1 = User.withUsername("user1").password(password).authorities("USER").build();

        users.add(user_admin);
        users.add(user_user1);

        return users;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

}
1.3 设置允许跨域

当前端调用客户端接口时,如果未登录客户端就会重定向到授权中心服务auth请求授权,这就涉及到跨域了,如果不加这个配置,sso流程无法走通。在这里设置了所有域都可以访问,这是不安全的,可以结合动态配置中心或者数据库来动态加载允许访问的域名。

@Order(Ordered.HIGHEST_PRECEDENCE)
@Configuration
public class CORSFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) res;
        HttpServletRequest request = (HttpServletRequest) req;
        //允许所有的域访问,可以设置只允许自己的域访问
        response.setHeader("Access-Control-Allow-Origin", "*");
        //允许所有方式的请求
        response.setHeader("Access-Control-Allow-Methods", "*");
        //头信息缓存有效时长(如果不设 Chromium 同时规定了一个默认值 5 秒),没有缓存将已OPTIONS进行预请求
        response.setHeader("Access-Control-Max-Age", "3600");
        //允许的头信息
        response.setHeader("Access-Control-Allow-Headers", "Content-Type,XFILENAME,XFILECATEGORY,XFILESIZE,x-requested-with,Authorization");

        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
        } else {
            chain.doFilter(req, res);
        }
    }
}
1.4 yml配置
server:
  port: 8080
  servlet:
    context-path: /auth
    session:
      cookie:
        name: SSO-SESSION

2、客户端服务

因为两个客户端的是几乎相同的,所以这里只展示client1的,详细代码可以到文章开头那里下载。

2.1 security配置

使用@EnableOAuth2Sso注解,使用单点登录,所有的接口都需要登录之后才可访问。

@EnableOAuth2Sso
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.logout()
                .and()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }
}
2.2 yml配置

这里配置了oauth2流程的必要配置。

server:
  port: 8081
  servlet:
    context-path: /client1

security:
  oauth2:
    client:
      client-id: client1
      client-secret: client1_secret
      access-token-uri: http://oauth.com/auth/oauth/token
      user-authorization-uri: http://oauth.com/auth/oauth/authorize
    resource:
      jwt:
        key-uri: http://oauth.com/auth/oauth/token_key
2.3 定义两个接口

定义了一个测试接口/test,至于第二个接口是回调接口,当客户端授权成功后最后一步调用,这里重定向返回到对应客户端的前端地址。

@RestController
public class Controller {

    @GetMapping("/test")
    public Result test() {
        System.out.println("11111");
        Result result = new Result();
        result.setCode(0);
        result.setData("hello client1");
        return result;
    }

    @GetMapping("/")
    public void callback(HttpServletResponse response) throws IOException {
        response.sendRedirect("http://client1.com/client1Page/home");
    }
}

三、前端项目

1、授权中心前端:auth

授权中心的前端页面写了一个简单的登录页,当点击登录按钮时调用login()方法,方法调用授权中心后端接口,如果返回的json的code为0,则登录成功,然后跳转到授权中心后端授权接口,这里要用window.location.href跳转,而不能用js调用,否则无法跳转到客户端。





2、客户端client1前端

客户端client2的代码基本一样,在test()方法中调用客户端后端接口,如果返回的code为0则显示数据,如果返回800,是未登录然后跳转到授权中心的授权接口,这里的800返回是在授权中心后端的自定义未登录 处理UnauthorizedEntryPoint返回的,与标准oauth2流程相比,这里多了一次跳转到授权接口,在UnauthorizedEntryPoint然后重定向到授权中心的登录页。







代码已经准备好,一些细节的代码需要从码云下载了解,在文章就不展示了,接下来就是测试了。

四、测试

1、环境配置准备

1.1 配置hosts

在hosts中添加下面三个域名配置,如果都用localhost来测试的话,测试无法知道单点登录流程是否正常,因为三个项目的域名相同的话cookie可能会造成干扰。

127.0.0.1 oauth.com
127.0.0.1 client1.com
127.0.0.1 client2.com
1.2 nginx配置
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    
    server {
        listen          80;
        server_name     oauth.com;

        location /auth/ {
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://localhost:8080/auth/;
        }
        
        location ^~ /authPage {
            try_files $uri $uri/ /authPage/index.html;
        }
    }
    
    server {
        listen          80;
        server_name     client1.com;
    
        location /client1/ {
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://localhost:8081/client1/;
        }
        
        location ^~ /client1Page {
            try_files $uri $uri/ /client1Page/index.html;
        }
    }

    server {
        listen       80;
        server_name  client2.com;
        
        location /client2/ {
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://localhost:8082/client2/;
        }
    
        location ^~ /client2Page {
            try_files $uri $uri/ /client2Page/index.html;
        }
        
    
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }

}

注意:配置后端接口时要加上下面两句,不然后端重定向时域名会变成localhost,导致流程失败。

proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1.3 前端打包部署

如何在nginx下打包部署Vue项目,可以看我的这篇文章。

1.4 启动后端服务

依次启动nginx、auth、client1、client2后端服务。

2、测试

在浏览器输入http://client1.com/client1Page/home,访问客户端1的前端地址,点击显示按钮会跳转到授权中心的登录页,输入账号admin,密码123456,登录成功后会重定向到客户端1的页面,此页面地址就是client1后端的的callback接口里设置的重定向地址,然后再点击按钮下面会显示client1后端接口返回的数据。
然后浏览器再开一个标签页,输入http://client2.com/client2Page/home,访问客户端2的前端地址,点击显示按钮然后请求授权中心授权,然后不需要登录就授权成功并重定向到客户端2的页面,此页面地址就是client2后端的callback接口里设置的重定向地址,这里设置了相同的页面,所以不要错误认为没有登录成功,然后点击显示按钮下面会显示client2后端返回的数据。

第一步.png

第二步.png
第三步.png
第四步.png
第五步.png

五、流程解析

1、UML图

1.1 client1流程

此流程与标准的oauth2流程相比,多了两次授权请求,按照正常oauth2流程,在第一次请求授权时如果未登录就重定向到登录页,但用前后端分离后,返回了授权接口在前端跳转,此时多了一次授权请求,在登录成功后又再次请求授权接口,这样做的原因是登录成功后,client2再请求时无法获取到登录成功后的SSO-SESSION这个cookie,从而导致需要再登录,我认为拿不到cookie的原因是在不同域名下请求另一个域名的接口是无法取到cookie的,所以只能在浏览器上跳转,授权中心根据isRedirect这个参数来判断是重定向到登录页还是返回json未登录。


image.png
1.2 client2流程
image.png

2、源码跟踪

2.1 有时间再写吧

你可能感兴趣的:(spring oauth2实现单点登录,Vue+spring boot+oauth2前后端分离)