寫在前面
關於 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兩大利器來打造一個簡易的許可權系統。
本文實驗環境如下:
- Spring Boot版本:
2.0.6.RELEASE
- IDE:
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
,會發現由於沒有許可權,訪問被拒絕了:
經過一系列的實驗過程,也達到了我們的預期!
覺得不錯請點贊支援,歡迎留言或進我的個人群855801563領取【架構資料專題目合集90期】、【BATJTMD大廠JAVA面試真題1000+】,本群專用於學習交流技術、分享面試機會,拒絕廣告,我也會在群內不定期答題、探討。