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<String, String[]> 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<UserDetails> users = buildUsers();

        return new InMemoryUserDetailsManager(users);
    }

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

        List<UserDetails> 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调用,否则无法跳转到客户端。

<template>
  <div>
    <p>账号:</p>
    <input type="text" v-model="loginForm.username">
    <p>密码:</p>
    <input type="password" v-model="loginForm.password">
    <p></p>
    <button v-on:click="login">登录</button>
  </div>
</template>

<script>
  import {postRequest} from "../utils/api";

  export default {
    name: 'Login',
    data() {
      return {
        loginForm: {
          username: '',
          password: ''
        }
      }
    },
    methods: {
      login() {
        postRequest('/auth/login', this.loginForm).then(resp => {
          if (resp.data.code === 0) {
            var pageUrl = window.location.href
            var param = pageUrl.split('?')[1]
            window.location.href = '/auth/oauth/authorize?'+param
          } else {
            console.log('登录失败:'+resp.data.msg)
          }
        })
      }
    }
  }
</script>
<style scoped>
</style>

2、客户端client1前端

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

<template>
  <div>
    <button v-on:click="test">显示</button>
    <p>client1显示结果:{{msg}}</p>
  </div>
</template>

<script>
  import {getRequest} from "../utils/api";

  export default {
  name: 'Home',
  data () {
    return {
      msg: ''
    }
  },
  methods: {
    test() {
      getRequest('/client1/test').then(resp=>{
        if (resp.data.code === 0) {
          this.msg = resp.data.data
        }else if (resp.data.code === 800) {
          window.location.href = resp.data.data
        } else {
          console.log('失败:'+resp.data)
        }
      })
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

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

四、测试

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 有时间再写吧
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容