基於Spring Security和 JWT的權限系統設計


寫在前面

  • 關於 Spring Security Web系統的認證和權限模塊也算是一個系統的基礎設施了,幾乎任何的互聯網服務都會涉及到這方面的要求。在Java EE領域,成熟的安全框架解決方案通常有 Apache Shiro、Spring Security等兩種技術選型。Apache Shiro簡單易用也算是一大優點,但其功能仍是遠不如 Spring Security強大。Spring Security能夠爲 Spring 應用提供聲明式的安全訪問控制,起經過提供一系列能夠在 Spring應用上下文中可配置的Bean,並利用 Spring IoC和 AOP等功能特性來爲應用系統提供聲明式的安全訪問控制功能,減小了諸多重複工做。java

  • 關於JWT JSON Web Token (JWT),是在網絡應用間傳遞信息的一種基於 JSON的開放標準((RFC 7519),用於做爲JSON對象在不一樣系統之間進行安全地信息傳輸。主要使用場景通常是用來在 身份提供者和服務提供者間傳遞被認證的用戶身份信息。關於JWT的科普,能夠看看阮一峯老師的《JSON Web Token 入門教程》。mysql

本文則結合 Spring Security和 JWT兩大利器來打造一個簡易的權限系統。git

本文實驗環境以下:github

  • Spring Boot版本:2.0.6.RELEASE
  • IDE:IntelliJ IDEA 2018.2.4

另外本文實驗代碼置於文尾,須要自取。web

長按掃描 下面的 當心心 來訂閱做者公衆號 CodeSheep,獲取更多 務實、能看懂、可復現的 原創文 ↓↓↓spring

CodeSheep · 程序羊


設計用戶和角色

本文實驗爲了簡化考慮,準備作以下設計:sql

  • 設計一個最簡角色表role,包括角色ID角色名稱

角色表

  • 設計一個最簡用戶表user,包括用戶ID用戶名密碼

用戶表

  • 再設計一個用戶和角色一對多的關聯表user_roles 一個用戶能夠擁有多個角色
    用戶-角色對應表

建立 Spring Security和 JWT加持的 Web工程

  • pom.xml 中引入 Spring Security和 JWT所必需的依賴
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.0</version>
</dependency>
複製代碼
  • 項目配置文件中加入數據庫和 JPA等須要的配置
server.port=9991

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://121.196.XXX.XXX:3306/spring_security_jwt?useUnicode=true&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=XXXXXX

logging.level.org.springframework.security=info

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jackson.serialization.indent_output=true
複製代碼
  • 建立用戶、角色實體

用戶實體 User數據庫

/** * @ www.codesheep.cn * 20190312 */
@Entity
public class User implements UserDetails {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    private String password;

    @ManyToMany(cascade = {CascadeType.REFRESH},fetch = FetchType.EAGER)
    private List<Role> roles;

    ...

    // 下面爲實現UserDetails而須要的重寫方法!
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
            authorities.add( new SimpleGrantedAuthority( role.getName() ) );
        }
        return authorities;
    }
    
    ...
}
複製代碼

此處所建立的 User類繼承了 Spring Security的 UserDetails接口,從而成爲了一個符合 Security安全的用戶,即經過繼承 UserDetails,便可實現 Security中相關的安全功能。json

角色實體 Role:安全

/** * @ www.codesheep.cn * 20190312 */
@Entity
public class Role {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
  
    ... // 省略 getter和 setter
}
複製代碼
  • 建立JWT工具類

主要用於對 JWT Token進行各項操做,好比生成Token、驗證Token、刷新Token等

/** * @ www.codesheep.cn * 20190312 */
@Component
public class JwtTokenUtil implements Serializable {

    private static final long serialVersionUID = -5625635588908941275L;

    private static final String CLAIM_KEY_USERNAME = "sub";
    private static final String CLAIM_KEY_CREATED = "created";

    public String generateToken(UserDetails userDetails) {
        ...
    }

    String generateToken(Map<String, Object> claims) {
        ...
    }

    public String refreshToken(String token) {
        ...
    }

    public Boolean validateToken(String token, UserDetails userDetails) {
        ...
    }

    ... // 省略部分工具函數
}
複製代碼
  • 建立Token過濾器,用於每次外部對接口請求時的Token處理
/** * @ www.codesheep.cn * 20190312 */
@Component
public class JwtTokenFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal ( HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {

        String authHeader = request.getHeader( Const.HEADER_STRING );
        if (authHeader != null && authHeader.startsWith( Const.TOKEN_PREFIX )) {
            final String authToken = authHeader.substring( Const.TOKEN_PREFIX.length() );
            String username = jwtTokenUtil.getUsernameFromToken(authToken);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                	if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                                userDetails, null, userDetails.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(
                                request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
            }
        }
        chain.doFilter(request, response);
    }
}
複製代碼
  • Service業務編寫

主要包括用戶登陸和註冊兩個主要的業務

public interface AuthService {
    User register( User userToAdd );
    String login( String username, String password );
}
複製代碼
/** * @ www.codesheep.cn * 20190312 */
@Service
public class AuthServiceImpl implements AuthService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Autowired
    private UserRepository userRepository;

    // 登陸
    @Override
    public String login( String username, String password ) {
        UsernamePasswordAuthenticationToken upToken = new UsernamePasswordAuthenticationToken( username, password );
        final Authentication authentication = authenticationManager.authenticate(upToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        final UserDetails userDetails = userDetailsService.loadUserByUsername( username );
        final String token = jwtTokenUtil.generateToken(userDetails);
        return token;
    }

    // 註冊
    @Override
    public User register( User userToAdd ) {
        final String username = userToAdd.getUsername();
        if( userRepository.findByUsername(username)!=null ) {
            return null;
        }
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        final String rawPassword = userToAdd.getPassword();
        userToAdd.setPassword( encoder.encode(rawPassword) );
        return userRepository.save(userToAdd);
    }
}
複製代碼
  • Spring Security配置類編寫(很是重要)

這是一個高度綜合的配置類,主要是經過重寫 WebSecurityConfigurerAdapter 的部分 configure配置,來實現用戶自定義的部分。

/** * @ www.codesheep.cn * 20190312 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserService userService;

    @Bean
    public JwtTokenFilter authenticationTokenFilterBean() throws Exception {
        return new JwtTokenFilter();
    }

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

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

    @Override
    protected void configure( HttpSecurity httpSecurity ) throws Exception {
        httpSecurity.csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS請求所有放行
                .antMatchers(HttpMethod.POST, "/authentication/**").permitAll()  //登陸和註冊的接口放行,其餘接口所有接受驗證
                .antMatchers(HttpMethod.POST).authenticated()
                .antMatchers(HttpMethod.PUT).authenticated()
                .antMatchers(HttpMethod.DELETE).authenticated()
                .antMatchers(HttpMethod.GET).authenticated();

        // 使用前文自定義的 Token過濾器
        httpSecurity
                .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class);

        httpSecurity.headers().cacheControl();
    }
}
複製代碼
  • 編寫測試 Controller

登陸和註冊的 Controller:

/**
 * @ www.codesheep.cn
 * 20190312
 */
@RestController
public class JwtAuthController {
    @Autowired
    private AuthService authService;

    // 登陸
    @RequestMapping(value = "/authentication/login", method = RequestMethod.POST)
    public String createToken( String username,String password ) throws AuthenticationException {
        return authService.login( username, password ); // 登陸成功會返回JWT Token給用戶
    }

    // 註冊
    @RequestMapping(value = "/authentication/register", method = RequestMethod.POST)
    public User register( @RequestBody User addedUser ) throws AuthenticationException {
        return authService.register(addedUser);
    }
}
複製代碼

再編寫一個測試權限的 Controller:

/**
 * @ www.codesheep.cn
 * 20190312
 */
@RestController
public class TestController {

    // 測試普通權限
    @PreAuthorize("hasAuthority('ROLE_NORMAL')")
    @RequestMapping( value="/normal/test", method = RequestMethod.GET )
    public String test1() {
        return "ROLE_NORMAL /normal/test接口調用成功!";
    }

    // 測試管理員權限
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    @RequestMapping( value = "/admin/test", method = RequestMethod.GET )
    public String test2() {
        return "ROLE_ADMIN /admin/test接口調用成功!";
    }
}
複製代碼

這裏給出兩個測試接口用於測試權限相關問題,其中接口 /normal/test須要用戶具有普通角色(ROLE_NORMAL)便可訪問,而接口/admin/test則須要用戶具有管理員角色(ROLE_ADMIN)才能夠訪問。

接下來啓動工程,實驗測試看看效果


實驗驗證

  • 在文章開頭咱們即在用戶表 user中插入了一條用戶名爲 codesheep的記錄,並在用戶-角色表 user_roles中給用戶 codesheep分配了普通角色(ROLE_NORMAL)和管理員角色(ROLE_ADMIN

  • 接下來進行用戶登陸,並得到後臺向用戶頒發的JWT Token

用戶登陸並得到JWT Token

  • 接下來訪問權限測試接口

不帶 Token直接訪問須要普通角色(ROLE_NORMAL)的接口 /normal/test會直接提示訪問不通:

不帶token訪問是不通的

而帶 Token訪問須要普通角色(ROLE_NORMAL)的接口 /normal/test纔會調用成功:

帶token訪問OK

同理因爲目前用戶具有管理員角色,所以訪問須要管理員角色(ROLE_ADMIN)的接口 /admin/test也能成功:

訪問須要管理員角色的接口OK

接下里咱們從用戶-角色表裏將用戶codesheep的管理員權限刪除掉,再訪問接口 /admin/test,會發現因爲沒有權限,訪問被拒絕了:

因爲權限不夠而被拒絕

通過一系列的實驗過程,也達到了咱們的預期!


寫在最後

本文涉及的東西仍是蠻多的,最後咱們也將本文的實驗源碼放在 Github上,須要的能夠自取:源碼下載地址

因爲能力有限,如有錯誤或者不當之處,還請你們批評指正,一塊兒學習交流!

相關文章
相關標籤/搜索