一、 概述

本文使用Springsecurity、Oauth2实现单点登录功能,支持JWT,支持前后端分离。

【SSO】(SingleSignOn),就是通过用户的一次性鉴别登录。

【OAuth2】开放授权,是一个开放标准,允许用户授权第三方移动应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。

【Springsecurity】 Spring 家族中的安全管理框架、集成Oauth2认证功能

以上详细概念请自行百度。

二、架构参考

  1. 使用架构

  • springboot 2.1
  • JPA (mysql)
  • thymeleaf(登录页)

 2. 架构图

springboot3 security oauth2单点登录 spring security jwt 单点登录_User

三、代码参考

主要实现 “授权服务器、资源服务器、自定义登录校验、JWT生成token”

  1. Server端:授权服务器,登录校验

  • pom.xml
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>2.1.3.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
  • WebSecurityConfig.java 
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private LoginValidateAuthenticationProvider loginValidateAuthenticationProvider;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        //这里要设置自定义认证
        auth.authenticationProvider(loginValidateAuthenticationProvider);
    }

//    @Override
//    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//        auth.userDetailsService(userDetailsService).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.authorizeRequests().antMatchers("/oauth/**", "/login/**", "/logout").permitAll()
                .anyRequest().authenticated()   // 其他地址的访问均需验证权限
                .and()
                .formLogin()
                .loginPage("/login")
                .and()
                .logout().logoutSuccessUrl("/")
                .and().csrf().disable().cors();
    }

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

}
  • 自定义登录校验
@Component
public class LoginValidateAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private PermissionService permissionService;

    @Autowired
    private SysUserRepository sysUserRepository;

    private static final String SPLIT_STR = "&&&";

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //获取输入的用户名
        String username = authentication.getName();
        //获取输入的明文
        String rawPassword = (String) authentication.getCredentials();
        //查询用户是否存在
        SysUser sysUser = sysUserRepository.qryUser(username, Integer.parseInt(usernameSob[1]));
        if (null == sysUser) {
            throw new BadCredentialsException("用户不存在");
        }
        // 自定义用户信息
        SysUserDto sysUserDto = new SysUserDto();
        sysUserDto.setUsername(sysUser.getUsername());
        sysUserDto.setRealName(sysUser.getRealName());
        sysUserDto.setSob(usernameSob[1]);
        String userJson = JSON.toJSONString(sysUserDto);
        //验证密码
        if (!Md5Util.isMatchPassword(rawPassword, sysUser.getPassword())) {
            throw new BadCredentialsException("输入密码错误");
        }
        List<SysPermission> permissionList = permissionService.findByAccount(sysUser.getUsername());
        List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
        if (!CollectionUtils.isEmpty(permissionList)) {
            for (SysPermission sysPermission : permissionList) {
                authorityList.add(new SimpleGrantedAuthority(sysPermission.getCode()));
            }
        }
        return new UsernamePasswordAuthenticationToken(userJson, rawPassword, authorityList);
    }
  • 认证服务器
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

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

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }

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

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

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey("zetor");   //  Sets the JWT signing key
        return jwtAccessTokenConverter;
    }

}
  • 登录页面
<form th:action="@{/login}" method="post">
                    <p style="color: red" id="security-message" th:if="${param.error}"
                       th:text="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}">
                    <div class="form-group">
                        <label>用户名</label>
                        <input type="text" class="form-control" id="uname" placeholder="Username">
                        <input type="text" class="form-control" value="admin" style="display:none" id="username" name="username">
                    </div>
                    <div class="form-group">
                        <label>密码</label>
                        <input type="password" class="form-control" name="password" placeholder="Password">
                    </div>
                    <div class="form-group">
                        <label>账套</label>
                        <select class="form-control input-s-sm inline" name="sobSel" id="sobSel">
                            <option value="" selected>请选择</option>
                            <option th:each="ss:${sobs}" th:value="${ss.sobCode}" th:text="${ss.sobName}"></option>
                        </select>
                    </div>
                    <div class="checkbox">
                        <label>
                            <input type="checkbox"> 记住密码
                        </label>
                        <label class="pull-right">
                            <a href="#">忘记密码?</a>
                        </label>
                    </div>
                    <button type="submit" class="btn btn-success btn-flat m-b-30 m-t-30" style="font-size: 18px;">登录
                    </button>
                </form>

   2. Client端 :

  • pom.xml
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>2.1.3.RELEASE</version>
        </dependency>
  • 拦截器
@EnableOAuth2Sso
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${exit_url}")
    private String exit_url;

    @Autowired
    @Qualifier("resourceServerRequestMatcher")
    private RequestMatcher resources;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        RequestMatcher nonResoures = new NegatedRequestMatcher(resources);
        http.requestMatcher(nonResoures).authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .logout().logoutUrl("/logout").logoutSuccessUrl(exit_url)
                .and()
                .cors()
                .and()
                .csrf().disable();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        //允许带凭证
        config.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        //对所有URL生效
        source.registerCorsConfiguration("/**", config);
        return source;
    }

}
  • 资源服务器(校验token)
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Bean("resourceServerRequestMatcher")
    public RequestMatcher resources() {
        return new AntPathRequestMatcher("/api/**");
    }


    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.requestMatcher(resources()).authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .cors()
                .and()
                .csrf().disable();

    }


    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        super.configure(resources);
        resources.tokenServices(tokenServices());
    }

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

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("zetor");
        return converter;
    }

    /**
     * resourceServerTokenServices 类的实例,用来实现令牌服务。
     *
     * @return
     */
    @Bean
    @Primary
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(jwtTokenStore());
        return defaultTokenServices;
    }
}
  • Client Main 启动调用
@Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oauth2ClientContext,
                                                 OAuth2ProtectedResourceDetails details) {
        return new OAuth2RestTemplate(details, oauth2ClientContext);
    }
  • 首页controller(此处支持前后端分离)
@RequestMapping("/")
    public String index(Authentication authentication, Model model) {
        OAuth2AuthenticationDetails detail = (OAuth2AuthenticationDetails) authentication.getDetails();
        log.info("【登录成功】username:{}, sessonId:{}, {}", authentication.getPrincipal(), detail.getSessionId(), detail.getTokenValue());
        if (front_flag) {
            return "redirect:" + front_url + PaasConstant.PRE_FIX + detail.getTokenValue();
        } else {
            model.addAttribute("token", detail.getTokenValue());
            return "index";
        }
    }

四、启动程序

1. 启动mysql:创建数据库,运行脚本。

注:脚本参考文末代码

2. 启动程序. Oauth2ServerApp -> ClientApp

3. 调用客户端

在浏览器地址栏输入:http://127.0.0.1:8081/client

统一跳转到认证服务器 http://127.0.0.1:8086/sso,如图

springboot3 security oauth2单点登录 spring security jwt 单点登录_ide_02

注:账套为本文自定义信息,可酌情扩展,也可删除。

用户名:admin   、密码:123456

登录成功:

springboot3 security oauth2单点登录 spring security jwt 单点登录_jwt_03

以上,登录成功。

注:如果使用前后端分离系统,请在配置中增加前端首页地址,如图

FRONT_URL: http://10.0.0.32:9527/#/

登录成功后,将token返回前端即可。

五、坑

俗话说 “人生到处都是坑,前人栽树后人乘凉”, 这里说一下以下几处问题:

1. 登录页面,自定属性如何获取?

网上有很多实现的例子,比如使用自定义校验类继承 WebAuthenticationDetails 等...... 但是此类方法依赖架构较多,对代码侵入较大。

本文采用取巧方式,即采用与username拼串方式带回后台,处理较简单,如上面图中的账套信息。

2. 客户端如何实现jwt校验?

Client如何校验自己的token有效性,此处要转换一下概念,客户端提供的接口,也是一种资源,所以要将Client端当做resourceServer来处理,这样一切就合理了。

3. WebSecurityConfigurerAdapter与ResourceServerConfigurerAdapter过滤优先级?

此问题网上回答较多,比如改order(100)顺序等,但实现并不完善。

本文采用国外网友的做法对两个拦截器过滤进行互斥处理,参考如下

@Override
    protected void configure(HttpSecurity http) throws Exception {
        RequestMatcher nonResoures = new NegatedRequestMatcher(resources);
        http.requestMatcher(nonResoures).authorizeRequests()
                .anyRequest().authenticated()

详细处理方式,可参考文末源码。


后续文章会进阶介绍 handler处理等功能,请移步《第二篇》。

本文源码地址:

ym-paas-sso-oauth2: 基于SpringSecurity、oauth2实现sso单点登录系统。