前言
微服務架構,前后端分離目前已成為互聯網項目開發的業界標准,其核心思想就是前端(APP、小程序、H5頁面等)通過調用后端的API接口,提交及返回JSON數據進行交互。
在前后端分離項目中,首先要解決的就是登錄及授權的問題。微服務架構下,傳統的session認證限制了應用的擴展能力,無狀態的JWT認證方法應運而生,該認證機制特別適用於分布式站點的單點登錄(SSO)場景
目錄
該文會通過創建SpringBoot項目整合SpringSecurity,實現完整的JWT認證機制,主要步驟如下:
- 創建SpringBoot工程
- 導入SpringSecurity與JWT的相關依賴
- 定義SpringSecurity需要的基礎處理類
- 構建JWT token工具類
- 實現token驗證的過濾器
- SpringSecurity的關鍵配置
- 編寫Controller進行測試
1、創建SpringBoot工程
2、導入SpringSecurity與JWT的相關依賴
pom文件加入以下依賴
<!--Security框架-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
...
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.6</version>
</dependency>
3.定義SpringSecurity需要的基礎處理類
application.yml配置中加入jwt配置信息:
#jwt
jwt:
header: Authorization
# 令牌前綴
token-start-with: Bearer
# 使用Base64對該令牌進行編碼
base64-secret: XXXXXXXXXXXXXXXX(制定您的密鑰)
# 令牌過期時間 此處單位/毫秒
token-validity-in-seconds: 14400000
創建一個jwt的配置類,並注入Spring,便於程序中靈活調用
@Data
@Configuration
@ConfigurationProperties(prefix = "jwt")
public class JwtSecurityProperties {
/** Request Headers : Authorization */
private String header;
/** 令牌前綴,最后留個空格 Bearer */
private String tokenStartWith;
/** Base64對該令牌進行編碼 */
private String base64Secret;
/** 令牌過期時間 此處單位/毫秒 */
private Long tokenValidityInSeconds;
/**返回令牌前綴 */
public String getTokenStartWith() {
return tokenStartWith + " ";
}
}
定義無權限訪問類
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
}
}
定義認證失敗處理類
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException==null?"Unauthorized":authException.getMessage());
}
}
4. 構建JWT token工具類
工具類實現創建token與校驗token功能
@Slf4j
@Component
public class JwtTokenUtils implements InitializingBean {
private final JwtSecurityProperties jwtSecurityProperties;
private static final String AUTHORITIES_KEY = "auth";
private Key key;
public JwtTokenUtils(JwtSecurityProperties jwtSecurityProperties) {
this.jwtSecurityProperties = jwtSecurityProperties;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(jwtSecurityProperties.getBase64Secret());
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createToken (Map<String, Object> claims) {
return Jwts.builder()
.claim(AUTHORITIES_KEY, claims)
.setId(UUID.randomUUID().toString())
.setIssuedAt(new Date())
.setExpiration(new Date((new Date()).getTime() + jwtSecurityProperties.getTokenValidityInSeconds()))
.compressWith(CompressionCodecs.DEFLATE)
.signWith(key,SignatureAlgorithm.HS512)
.compact();
}
public Date getExpirationDateFromToken(String token) {
Date expiration;
try {
final Claims claims = getClaimsFromToken(token);
expiration = claims.getExpiration();
} catch (Exception e) {
expiration = null;
}
return expiration;
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
HashMap map =(HashMap) claims.get("auth");
User principal = new User(map.get("user").toString(), map.get("password").toString(), authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
public boolean validateToken(String authToken) {
try {
Jwts.parser().setSigningKey(key).parseClaimsJws(authToken);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT signature.");
e.printStackTrace();
} catch (ExpiredJwtException e) {
log.info("Expired JWT token.");
e.printStackTrace();
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token.");
e.printStackTrace();
} catch (IllegalArgumentException e) {
log.info("JWT token compact of handler are invalid.");
e.printStackTrace();
}
return false;
}
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
}
5.實現token驗證的過濾器
該類繼承OncePerRequestFilter,顧名思義,它能夠確保在一次請求中只通過一次filter
該類使用JwtTokenUtils工具類進行token校驗
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private JwtTokenUtils jwtTokenUtils;
public JwtAuthenticationTokenFilter(JwtTokenUtils jwtTokenUtils) {
this.jwtTokenUtils = jwtTokenUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
JwtSecurityProperties jwtSecurityProperties = SpringContextHolder.getBean(JwtSecurityProperties.class);
String requestRri = httpServletRequest.getRequestURI();
//獲取request token
String token = null;
String bearerToken = httpServletRequest.getHeader(jwtSecurityProperties.getHeader());
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(jwtSecurityProperties.getTokenStartWith())) {
token = bearerToken.substring(jwtSecurityProperties.getTokenStartWith().length());
}
if (StringUtils.hasText(token) && jwtTokenUtils.validateToken(token)) {
Authentication authentication = jwtTokenUtils.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("set Authentication to security context for '{}', uri: {}", authentication.getName(), requestRri);
} else {
log.debug("no valid JWT token found, uri: {}", requestRri);
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}
根據SpringBoot官方讓重復執行的filter實現一次執行過程的解決方案,參見官網地址:https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-disable-registration-of-a-servlet-or-filter
在SpringBoot啟動類中,加入以下代碼:
@Bean
public FilterRegistrationBean registration(JwtAuthenticationTokenFilter filter) {
FilterRegistrationBean registration = new FilterRegistrationBean(filter);
registration.setEnabled(false);
return registration;
}
6. SpringSecurity的關鍵配置
SpringBoot推薦使用配置類來代替xml配置,該類中涉及了以上幾個bean來供security使用
- JwtAccessDeniedHandler :無權限訪問
- jwtAuthenticationEntryPoint :認證失敗處理
- jwtAuthenticationTokenFilter :token驗證的過濾器
package com.zhuhuix.startup.security.config;
import com.fasterxml.jackson.core.filter.TokenFilter;
import com.zhuhuix.startup.security.security.JwtAccessDeniedHandler;
import com.zhuhuix.startup.security.security.JwtAuthenticationEntryPoint;
import com.zhuhuix.startup.security.security.JwtAuthenticationTokenFilter;
import com.zhuhuix.startup.security.utils.JwtTokenUtils;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
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.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security配置類
*
* @author zhuhuix
* @date 2020-03-25
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtTokenUtils jwtTokenUtils;
public WebSecurityConfig(JwtAccessDeniedHandler jwtAccessDeniedHandler, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtTokenUtils jwtTokenUtils) {
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtTokenUtils = jwtTokenUtils;
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 禁用 CSRF
.csrf().disable()
// 授權異常
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// 防止iframe 造成跨域
.and()
.headers()
.frameOptions()
.disable()
// 不創建會話
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 放行靜態資源
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/webSocket/**"
).permitAll()
// 放行swagger
.antMatchers("/swagger-ui.html").permitAll()
.antMatchers("/swagger-resources/**").permitAll()
.antMatchers("/webjars/**").permitAll()
.antMatchers("/*/api-docs").permitAll()
// 放行文件訪問
.antMatchers("/file/**").permitAll()
// 放行druid
.antMatchers("/druid/**").permitAll()
// 放行OPTIONS請求
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
//允許匿名及登錄用戶訪問
.antMatchers("/api/auth/**", "/error/**").permitAll()
// 所有請求都需要認證
.anyRequest().authenticated();
// 禁用緩存
httpSecurity.headers().cacheControl();
// 添加JWT filter
httpSecurity
.apply(new TokenConfigurer(jwtTokenUtils));
}
public class TokenConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final JwtTokenUtils jwtTokenUtils;
public TokenConfigurer(JwtTokenUtils jwtTokenUtils){
this.jwtTokenUtils = jwtTokenUtils;
}
@Override
public void configure(HttpSecurity http) {
JwtAuthenticationTokenFilter customFilter = new JwtAuthenticationTokenFilter(jwtTokenUtils);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
}
7. 編寫Controller進行測試
登錄邏輯:傳遞user與password參數,返回token
@Slf4j
@RestController
@RequestMapping("/api/auth")
@Api(tags = "系統授權接口")
public class AuthController {
private final JwtTokenUtils jwtTokenUtils;
public AuthController(JwtTokenUtils jwtTokenUtils) {
this.jwtTokenUtils = jwtTokenUtils;
}
@ApiOperation("登錄授權")
@GetMapping(value = "/login")
public String login(String user,String password){
Map map = new HashMap();
map.put("user",user);
map.put("password",password);
return jwtTokenUtils.createToken(map);
}
}
使用IDEA Rest Client測試如下:
驗證邏輯:傳遞token,驗證成功后返回用戶信息
token驗證錯誤返回401: