【SpringSecurity OAuth2 JWT】實現SSO單點登入 第一篇

zetor_major發表於2020-12-15

一、 概述

本文使用Springsecurity、Oauth2 + JWT實現單點登入功能。

繼作者上一篇文章:Oauth2 + JWT 實現 SSO 單點登入

本文為進階篇,更細緻的實現了 Springsecurity安全框架的 各部分handler處理,讓系統執行起來更加細緻,靈活。

 

二、架構參考

  1. 使用架構

  • springboot 2.3.1
  • springSecurity 
  • oauth2 jwt
  • mybatis plus
  • ehcache
  • swagger
  • druid
  • thymelef + layui

 2. SSO 時序圖

 

三、程式碼參考

  1. Server端:

主要實現 “授權伺服器、資源伺服器、自定義登入校驗、生成token” 等,後續整合RBAC許可權管理,

閒話不多說,上程式碼:

  • 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 
  • 實現訪問攔截、security hanler定義、記住密碼等功能:
    @Autowired
    @Qualifier("resourceServerRequestMatcher")
    private RequestMatcher resources;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        RequestMatcher nonResoures = new NegatedRequestMatcher(resources);
        http.requestMatcher(nonResoures).authorizeRequests()
//        http.authorizeRequests()
                .antMatchers("/swagger-resources/**", "/PearAdmin/**", "/component/**",
                        "/admin/**", "/**/*.html", "/**/*.css", "/**/*.js", "/swagger-ui.html",
                        "/webjars/**", "/v2/**", "/druid/**").permitAll()
                .anyRequest().authenticated()   // 其他地址的訪問均需驗證許可權
                .and()
                .formLogin()
                .loginPage("/login")
                //攔截的請求
//                .loginProcessingUrl("/login")
                // 登入成功處理
                .successHandler(mySuccessHandler)
                // 登入失敗處理
                .failureHandler(myFailureHandler)
                .permitAll()
                .and()
                // 記住密碼
                .rememberMe()
                .rememberMeParameter("rememberme")
                .tokenValiditySeconds(2 * 24 * 60 * 60)
                .and()
                .logout()
                .logoutSuccessHandler(myLogoutHandler)
                .and().cors()
                .and()
                .csrf().disable()   // 防止iframe 造成跨域
                .headers()
                .frameOptions()
                .disable();
        // 禁用快取
        http.headers().cacheControl();
        // 無權訪問 JSON 格式的資料
        http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
    }

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

    /**
     * 身份認證介面
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
  • MyAuthenticationSuccessHandler 
  • 登入成功應答資訊:
@Component("MyAuthenticationSuccessHandler")
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json");
        //輸出結果
        Result result = Result.ok().message("登入成功");
        response.getWriter().write(JSON.toJSONString(result));
    }

}
  • MyAuthenticationFailureHandler 
  • 登入失敗應答資訊:
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        //修改編碼格式
        httpServletResponse.setCharacterEncoding("utf-8");
        httpServletResponse.setContentType("application/json");

        if (e instanceof BadCredentialsException){
            httpServletResponse.getWriter().write(JSON.toJSONString(Result.error().message("使用者名稱或密碼錯誤")));
        }else {
            httpServletResponse.getWriter().write(JSON.toJSONString(Result.error().message(e.getMessage())));
        }

    }
}
  • MyLogoutSuccessHandler 
  • 登出應答資訊:
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.sendRedirect(request.getHeader("referer"));
    }
}
  • RestfulAccessDeniedHandler
  • 無許可權時、應答資訊:
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException e) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json");
        response.getWriter().println(JSONUtils.toJSONString(Result.error().message(e.getMessage())));
        response.getWriter().flush();
    }
}
  • 自定義登入校驗
  • 賬號、密碼校驗、使用者許可權獲取
    @Service
    @Slf4j
    public class UserDetailsServiceImpl implements UserDetailsService {
        @Resource
        private UserService userService;
        @Resource
        private RoleUserService roleUserService;
        @Resource
        private RoleService roleService;
        @Resource
        private MenuDao menuDao;
    
        @Override
        public JwtUserDto loadUserByUsername(String userName) {
            //根據使用者名稱獲取使用者
            MyUser user = userService.getUserByName(userName);
            if (user == null) {
                throw new BadCredentialsException("使用者名稱或密碼錯誤");
            } else if (user.getStatus().equals(MyUser.Status.LOCKED)) {
                throw new LockedException("使用者被鎖定,請聯絡管理員解鎖");
            }
            List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            List<MenuIndexDto> list = menuDao.listByUserId(user.getUserId());
            List<String> collect = list.stream().map(MenuIndexDto::getPermission).collect(Collectors.toList());
            for (String authority : collect) {
                if (!("").equals(authority) & authority != null) {
                    GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(authority);
                    grantedAuthorities.add(grantedAuthority);
                }
            }
            //將使用者所擁有的許可權加入GrantedAuthority集合中
            JwtUserDto loginUser = new JwtUserDto(user, grantedAuthorities);
            return loginUser;
        }
    
    }
    

 

  • 認證伺服器
@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,如圖

注:賬套為本文自定義資訊,可酌情擴充套件,也可刪除。

使用者名稱:admin   、密碼:123456

登入成功:

以上,登入成功。

注:如果使用前後端分離系統,請在配置中增加前端首頁地址,如圖

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()

詳細處理方式,可參考文末原始碼。



本文原始碼地址:

https://gitee.com/zetor2020/ym-paas-sso-oauth2.git

下載程式碼的朋友點下star,多謝支援

相關文章