基於 Spring Security和 JWT的許可權系統設計
基於 Spring Security和 JWT的許可權系統設計
寫在前面
-
關於 Spring Security
Web系統的認證和許可權模組也算是一個系統的基礎設施了,幾乎任何的網際網路服務都會涉及到這方面的要求。在Java EE領域,成熟的安全框架解決方案一般有 Apache Shiro、Spring Security等兩種技術選型。Apache Shiro簡單易用也算是一大優勢,但其功能還是遠不如 Spring Security強大。Spring Security可以為 Spring 應用提供宣告式的安全訪問控制,起通過提供一系列可以在 Spring應用上下文中可配置的Bean,並利用 Spring IoC和 AOP等功能特性來為應用系統提供宣告式的安全訪問控制功能,減少了諸多重複工作。
-
關於JWT
JSON Web Token (JWT),是在網路應用間傳遞資訊的一種基於 JSON的開放標準((RFC 7519),用於作為JSON物件在不同系統之間進行安全地資訊傳輸。主要使用場景一般是用來在 身份提供者和服務提供者間傳遞被認證的使用者身份資訊。關於JWT的科普,可以看看阮一峰老師的《JSON Web Token 入門教程》。
本文則結合 Spring Security和 JWT兩大利器來打造一個簡易的許可權系統。
本文實驗環境如下:
2.0.6.RELEASE IntelliJ IDEA 2018.2.4
另外本文實驗程式碼置於文尾,需要自取。
設計使用者和角色
本文實驗為了簡化考慮,準備做如下設計:
- 設計一個最簡角色表
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中相關的安全功能。
角色實體 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
- 接下來訪問許可權測試介面
不帶 Token直接訪問需要普通角色( ROLE_NORMAL
)的介面 /normal/test
會直接提示訪問不通:
而帶 Token訪問需要普通角色( ROLE_NORMAL
)的介面 /normal/test
才會呼叫成功:
同理由於目前使用者具備管理員角色,因此訪問需要管理員角色( ROLE_ADMIN
)的介面 /admin/test
也能成功:
接下里我們從使用者-角色表裡將使用者 codesheep
的管理員許可權刪除掉,再訪問介面 /admin/test
,會發現由於沒有許可權,訪問被拒絕了:
經過一系列的實驗過程,也達到了我們的預期!
寫在最後
本文涉及的東西還是蠻多的,最後我們也將本文的實驗原始碼放在 Github上 ,需要的可以自取: 原始碼下載地址
由於能力有限,若有錯誤或者不當之處,還請大家批評指正,一起學習交流!
- My Personal Blog:CodeSheep 程式羊