1. 程式人生 > >Spring Boot + Spring Cloud 實現許可權管理系統 後端篇(二十五):Spring Security 版本

Spring Boot + Spring Cloud 實現許可權管理系統 後端篇(二十五):Spring Security 版本

線上演示

使用者名稱:admin 密碼:admin

技術背景

到目前為止,我們使用的許可權認證框架是 Shiro,雖然 Shiro 也足夠好用並且簡單,但對於 Spring 官方主推的安全框架 Spring Security,使用者群也是甚大的,所以我們這裡把當前的程式碼切分出一個 shiro-cloud 分支,作為 Shiro + Spring Cloud 技術的分支程式碼,dev 和 master 分支將替換為 Spring Security + Spring Cloud 的技術棧,並在後續計劃中整合 Spring Security OAuth2 實現單點登入功能。

程式碼實現

Maven依賴

移除shiro依賴,新增Spring Scurity和JWT依賴包,jwt目前的最新版本是0.9.1。

<!-- spring security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>${jwt.version}</version>
</dependency>

許可權註解

替換Shiro的許可權註解為Spring Security的許可權註解。

格式如下:

@PreAuthorize("hasAuthority('sys:menu:view')")

SysMenuController.java

package com.louis.kitty.admin.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.louis.kitty.admin.model.SysMenu;
import com.louis.kitty.admin.sevice.SysMenuService;
import com.louis.kitty.core.http.HttpResult;

/** * 選單控制器 * @author Louis * @date Oct 29, 2018 */ @RestController @RequestMapping("menu") public class SysMenuController { @Autowired private SysMenuService sysMenuService; @PreAuthorize("hasAuthority('sys:menu:add') AND hasAuthority('sys:menu:edit')") @PostMapping(value="/save") public HttpResult save(@RequestBody SysMenu record) { return HttpResult.ok(sysMenuService.save(record)); } @PreAuthorize("hasAuthority('sys:menu:delete')") @PostMapping(value="/delete") public HttpResult delete(@RequestBody List<SysMenu> records) { return HttpResult.ok(sysMenuService.delete(records)); } @PreAuthorize("hasAuthority('sys:menu:view')") @GetMapping(value="/findNavTree") public HttpResult findNavTree(@RequestParam String userName) { return HttpResult.ok(sysMenuService.findTree(userName, 1)); } @PreAuthorize("hasAuthority('sys:menu:view')") @GetMapping(value="/findMenuTree") public HttpResult findMenuTree() { return HttpResult.ok(sysMenuService.findTree(null, 0)); } }

Spring Security註解預設是關閉的,可以通過在配置類新增以下註解開啟。

@EnableGlobalMethodSecurity(prePostEnabled = true)

安全配置

新增安全配置類, 繼承 WebSecurityConfigurerAdapter,配置URL驗證策略和相關過濾器以及自定義的登入驗證元件。

WebSecurityConfig.java

package com.louis.kitty.admin.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;

import com.louis.kitty.admin.security.JwtAuthenticationFilter;
import com.louis.kitty.admin.security.JwtAuthenticationProvider;

/**
 * Spring Security Config
 * @author Louis
 * @date Nov 20, 2018
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
    
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定義身份驗證元件
        auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用 csrf, 由於使用的是JWT,我們這裡不需要csrf
        http.cors().and().csrf().disable()
            .authorizeRequests()
            // 跨域預檢請求
            .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
            // web jars
            .antMatchers("/webjars/**").permitAll()
            // 檢視SQL監控(druid)
            .antMatchers("/druid/**").permitAll()
            // 首頁和登入頁面
            .antMatchers("/").permitAll()
            .antMatchers("/login").permitAll()
            // swagger
            .antMatchers("/swagger-ui.html").permitAll()
            .antMatchers("/swagger-resources").permitAll()
            .antMatchers("/v2/api-docs").permitAll()
            .antMatchers("/webjars/springfox-swagger-ui/**").permitAll()
            // 驗證碼
            .antMatchers("/captcha.jpg**").permitAll()
            // 服務監控
            .antMatchers("/actuator/**").permitAll()
            // 其他所有請求需要身份認證
            .anyRequest().authenticated();
        // 退出登入處理器
        http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
        // 登入認證過濾器
        http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
    }

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

登入驗證元件

繼承 DaoAuthenticationProvider, 實現自定義的登入驗證元件,覆寫密碼驗證邏輯。

JwtAuthenticationProvider.java

package com.louis.kitty.admin.security;

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import com.louis.kitty.admin.util.PasswordEncoder;

/**
 * 身份驗證提供者
 * @author Louis
 * @date Nov 20, 2018
 */
public class JwtAuthenticationProvider extends DaoAuthenticationProvider {

    public JwtAuthenticationProvider(UserDetailsService userDetailsService) {
        setUserDetailsService(userDetailsService);
    }

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        if (authentication.getCredentials() == null) {
            logger.debug("Authentication failed: no credentials provided");
            throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }

        String presentedPassword = authentication.getCredentials().toString();
        String salt = ((JwtUserDetails) userDetails).getSalt();
        // 覆寫密碼驗證邏輯
        if (!new PasswordEncoder(salt).matches(userDetails.getPassword(), presentedPassword)) {
            logger.debug("Authentication failed: password does not match stored value");
            throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }

}

使用者認證資訊查詢元件

實現 UserDetailsService 介面,定義使用者認證資訊查詢元件,用於獲取認證所需的使用者資訊和授權資訊。

UserDetailsServiceImpl.java

package com.louis.kitty.admin.security;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.louis.kitty.admin.model.SysUser;
import com.louis.kitty.admin.sevice.SysUserService;

/**
 * 使用者登入認證資訊查詢
 * @author Louis
 * @date Nov 20, 2018
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = sysUserService.findByName(username);
        if (user == null) {
            throw new UsernameNotFoundException("該使用者不存在");
        }
        // 使用者許可權列表,根據使用者擁有的許可權標識與如 @PreAuthorize("hasAuthority('sys:menu:view')") 標註的介面對比,決定是否可以呼叫介面
        Set<String> permissions = sysUserService.findPermissions(user.getName());
        List<GrantedAuthority> grantedAuthorities = permissions.stream().map(GrantedAuthorityImpl::new).collect(Collectors.toList());
        return new JwtUserDetails(user.getName(), user.getPassword(), user.getSalt(), grantedAuthorities);
    }
}

使用者認證資訊封裝

上面 UserDetailsService 查詢的資訊需要封裝到實現 UserDetails 介面的封裝物件裡。

JwtUserDetails.java

package com.louis.kitty.admin.security;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.fasterxml.jackson.annotation.JsonIgnore;

/**
 * 安全使用者模型
 * @author Louis
 * @date Nov 20, 2018
 */
public class JwtUserDetails implements UserDetails {

    private static final long serialVersionUID = 1L;
    
    private String username;
    private String password;
    private String salt;
    private Collection<? extends GrantedAuthority> authorities;

    JwtUserDetails(String username, String password, String salt, Collection<? extends GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.salt = salt;
        this.authorities = authorities;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }

    public String getSalt() {
        return salt;
    }
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }

}

登入介面

因為我們沒有使用內建的 formLogin 登入處理過濾器,所以需要呼叫登入認證流程,修改登入介面,加入系統登入認證呼叫。

SysLoginController.java

   /**
     * 登入介面
     */
    @PostMapping(value = "/login")
    public HttpResult login(@RequestBody LoginBean loginBean, HttpServletRequest request) throws IOException {
        String username = loginBean.getAccount();
        String password = loginBean.getPassword();
        String captcha = loginBean.getCaptcha();...
     // 系統登入認證 JwtAuthenticatioToken token = SecurityUtils.login(request, username, password, authenticationManager); return HttpResult.ok(token); }

Spring Security 的登入認證過程是通過呼叫 AuthenticationManager 的 authenticate(token) 方法實現的。

登入流程中主要是返回一個認證好的 Authentication 物件,然後儲存到上下文供後續進行授權的時候使用。

登入認證成功之後,會利用JWT生成 token 返回給客戶端,後續的訪問都需要攜帶此 token 來進行認證。

SecurityUtils.java

    /**
     * 系統登入認證
     * @param request
     * @param username
     * @param password
     * @param authenticationManager
     * @return
     */
    public static JwtAuthenticatioToken login(HttpServletRequest request, String username, String password, AuthenticationManager authenticationManager) {
        JwtAuthenticatioToken token = new JwtAuthenticatioToken(username, password);
        token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        // 執行登入認證過程
        Authentication authentication = authenticationManager.authenticate(token);
        // 認證成功儲存認證資訊到上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 生成令牌並返回給客戶端
        token.setToken(JwtTokenUtils.generateToken(authentication));
        return token;
    }

令牌生成器

令牌生成器主要是利用JWT生成所需的令牌,部分程式碼如下。

JwtTokenUtils.java

/**
 * JWT工具類
 * @author Louis
 * @date Nov 20, 2018
 */
public class JwtTokenUtils implements Serializable {

    /**
     * 生成令牌
     * @param userDetails 使用者
     * @return 令牌
     */
    public static String generateToken(Authentication authentication) {
        Map<String, Object> claims = new HashMap<>(3);
        claims.put(USERNAME, SecurityUtils.getUsername(authentication));
        claims.put(CREATED, new Date());
        claims.put(AUTHORITIES, authentication.getAuthorities());
        return generateToken(claims);
    }

    /**
     * 從資料宣告生成令牌
     * @param claims 資料宣告
     * @return 令牌
     */
    private static String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, SECRET).compact();
    }
}

登入認證過濾器

登入認證過濾器繼承 BasicAuthenticationFilter,在訪問任何URL的時候會被此過濾器攔截,通過呼叫 SecurityUtils.checkAuthentication(request) 檢查登入狀態。

JwtAuthenticationFilter.java

package com.louis.kitty.admin.security;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import com.louis.kitty.admin.util.SecurityUtils;

/**
 * 登入認證過濾器
 * @author Louis
 * @date Nov 20, 2018
 */
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

    
    @Autowired
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 獲取token, 並檢查登入狀態
        SecurityUtils.checkAuthentication(request);
        chain.doFilter(request, response);
    }
    
}

登入認證檢查

登入驗證檢查是通過 SecurityUtils.checkAuthentication(request) 來完成的。

SecurityUtils.java

    /**
     * 獲取令牌進行認證
     * @param request
     */
    public static void checkAuthentication(HttpServletRequest request) {
        // 獲取令牌並根據令牌獲取登入認證資訊
        Authentication authentication = JwtTokenUtils.getAuthenticationeFromToken(request);
        // 設定登入認證資訊到上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

上面的登入驗證是通過 JwtTokenUtils.getAuthenticationeFromToken(request),來驗證令牌並返回登入資訊的。

JwtTokenUtils.java

    /**
     * 根據請求令牌獲取登入認證資訊
     * @param token 令牌
     * @return 使用者名稱
     */
    public static Authentication getAuthenticationeFromToken(HttpServletRequest request) {
        Authentication authentication = null;
        // 獲取請求攜帶的令牌
        String token = JwtTokenUtils.getToken(request);
        if(token != null) {
            // 請求令牌不能為空
            if(SecurityUtils.getAuthentication() == null) {
                // 上下文中Authentication為空
                Claims claims = getClaimsFromToken(token);
                if(claims == null) {
                    return null;
                }
                String username = claims.getSubject();
                if(username == null) {
                    return null;
                }
                if(isTokenExpired(token)) {
                    return null;
                }
                Object authors = claims.get(AUTHORITIES);
                List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
                if (authors != null && authors instanceof List) {
                    for (Object object : (List) authors) {
                        authorities.add(new GrantedAuthorityImpl((String) ((Map) object).get("authority")));
                    }
                }
                authentication = new JwtAuthenticatioToken(username, null, authorities, token);
            } else {
                if(validateToken(token, SecurityUtils.getUsername())) {
                    // 如果上下文中Authentication非空,且請求令牌合法,直接返回當前登入認證資訊
                    authentication = SecurityUtils.getAuthentication();
                }
            }
        }
        return authentication;
    }

清除Shiro配置

清除掉 config 包下的 ShiroConfig 配置類。

清除 oautho2 包下有關 Shiro 的相關程式碼。

清除掉 sys_token 表和相關操作程式碼。

原始碼下載