概述
越來越多的網站開發都采用前後端分離架構。典型的像React\vue等。本篇文章會介紹下基于SpringBoot架構後端的實作。
JWT
JWT原理
JWT通訊原理
具體認證流程:
1、輸入使用者/密碼,服務端認證成功後。會傳回用戶端一個JWT Token。
格式:
IUzUxMiJ9.eyJzdWIiOiJoMS.PqZg8jCah0hgj
2、用戶端将token儲存到本地(可以放LocalStorage,也可以放Cookie)
3、目前端通路一個受保護的路由或資源,需要在HTTP請求頭的Authorization字段中使用Bearer模式添加JWT。格式如下:Authorization: Bearer
4、服務端利用Secrity過濾器,擷取HTTP頭裡面的這個token值,解析是否為有效的Token。如果是則放行。
Spring Security架構
SpringSecurity 采用的是責任鍊的設計模式,是一堆過濾器鍊的組合,它有一條很長的過濾器鍊。簡化一下:
登入流程過濾器鍊
示例代碼目錄:
示例工程
跟Security相關的幾個檔案代碼如下:
WebSecurityConfig.java 配置檔案
package com.aliyun.agp.webcommon.security;
import com.aliyun.agp.webcommon.security.jwt.JwtAuthenticationEntryPoint;
import com.aliyun.agp.webcommon.security.jwt.JwtRequestFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Configurable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configurable
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private UserDetailsService jwtUserDetailsService;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Value("${jwt.get.token.uri}")
private String authenticationPath;
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(jwtUserDetailsService)
.passwordEncoder(passwordEncoderBean());
}
@Bean
public PasswordEncoder passwordEncoderBean() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.authorizeRequests()
// 指定的接口直接放行
.antMatchers(HttpMethod.POST,"/api/login").permitAll()
.antMatchers(HttpMethod.GET,"/api/refresh").permitAll()
// 其他接口需要認證才能請求
.anyRequest().authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.and()
// 不需要建立session,前後端分離
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
}
這個檔案裡面要注意配置哪些URI是不需要認證的。沒有認證的會到哪個類處理。以及自定義過濾器。
JwtAuthenticationEntryPoint.java這個類的作用就是沒有登入的處理邏輯。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
AgpWebResult agpWebResult = AgpWebResult.buildFailWithErrCode(HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized");
httpServletResponse.getWriter().write(AgpUtils.toJsonString(agpWebResult));
httpServletResponse.flushBuffer();
}
}
再有一個就是過濾器了。這塊跟采用JWT還是用其他協定有關。需要拿到前端送出過來的請求資訊。
JwtRequestFilter.java自定義過濾器
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(LoggerConstants.AGP_APPEND);
@Autowired
private UserDetailsService jwtUserDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER_STRING = "Authorization";
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
final String requestTokenHeader = httpServletRequest.getHeader(HEADER_STRING);
String username = null;
String jwtToken = null;
if (requestTokenHeader != null && requestTokenHeader.startsWith(TOKEN_PREFIX)) {
jwtToken = requestTokenHeader.replace(TOKEN_PREFIX,"");
try {
username = jwtTokenUtil.getUsernameFromToken(jwtToken);
}catch (IllegalArgumentException e) {
logger.error("JWT_TOKEN_UNABLE_TO_GET_USERNAME", e);
} catch (ExpiredJwtException e) {
logger.warn("the token is expired and not valid anymore", e);
} catch (SignatureException e) {
logger.error("Authentication Failed. Username or Password not valid.");
} catch (MalformedJwtException exception) {
logger.warn("Request to parse invalid JWT : failed : {}", exception.getMessage());
}
} else {
logger.warn("JWT_TOKEN_DOES_NOT_START_WITH_BEARER_STRING");
}
logger.debug("JWT_TOKEN_USERNAME_VALUE '{}'", username);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = jwtUserDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails,null,userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
logger.info("authenticated user " + username + ", setting security context");
// 設定成全局ThreadLocal級别的上下文,用于擷取目前登入使用者
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
使用者密碼認證實作。Security是将使用者的密碼與輸入的密碼在記憶體中比較是否Match的。
JwtUserDetailsService.java提供loadUserByUsername方法
@Service
public class JwtUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userService.findByUsername(s).orElseThrow(() -> new UsernameNotFoundException("user not found with username:" + s));
User jwtUser = new User();
jwtUser.setId(user.getId());
jwtUser.setUserName(user.getUserName());
jwtUser.setPassword(passwordEncoder.encode(user.getPassword()));
jwtUser.setEmail(user.getEmail());
jwtUser.setRoles(user.getRoles());
return JwtUserDetails.build(jwtUser);
}
}
JwtUserDetails.java實作UserDetails接口
public class JwtUserDetails implements UserDetails {
private Long id;
private String username;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
public JwtUserDetails(Long id, String username, String email, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.email = email;
this.password = password;
this.authorities = authorities;
}
public static JwtUserDetails build(User user) {
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name()))
.collect(Collectors.toList());
return new JwtUserDetails(
new Long(user.getId()),
user.getUserName(),
user.getEmail(),
user.getPassword(),
authorities);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@JsonIgnore
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
JwtUserDetails user = (JwtUserDetails) o;
return Objects.equals(id, user.id);
}
@JsonIgnore
public Long getId() {
return id;
}
}
跟JWT 其實就一個工具類,用于生成token,校驗token的。
import io.jsonwebtoken.Clock;
import io.jsonwebtoken.impl.DefaultClock;
import org.springframework.beans.factory.annotation.Value;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.cglib.core.internal.Function;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtTokenUtil implements Serializable {
@Value("${app.jwtSecret}")
private String secret;
@Value("${jwt.token.expiration.in.seconds}")
private Long expiration;
private Clock clock = DefaultClock.INSTANCE;
/***
* 從token中擷取使用者名
* @param token
* @return
*/
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
/**
* 擷取過期時間
*
* @param token
* @return
*/
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
public Boolean canTokenBeRefreshed(String token) {
return (!isTokenExpired(token));
}
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
/**
* 驗證token是否有效
*
* @param token
* @param userDetails
* @return
*/
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
/***
* 給使用者生成新的token
* @param authentication
* @return
*/
public String generateToken(Authentication authentication) {
Map<String, Object> claims = new HashMap<>();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String username = userDetails.getUsername();
return doGenerateToken(claims, username);
}
/***
* 當token過期之後需要重新整理token
* @param token
* @return
*/
public String refreshToken(String token) {
final Date createdDate = clock.now();
final Date expirationDate = calculateExpirationDate(createdDate);
final Claims claims = getAllClaimsFromToken(token);
claims.setIssuedAt(createdDate);
claims.setExpiration(expirationDate);
return Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();
}
private Date calculateExpirationDate(Date createdDate) {
return new Date(createdDate.getTime() + expiration * 1000);
}
/***
* 生成一個新token,順序:
* 1. Define claims of the token, like Issuer, Expiration, Subject, and the ID
* 2. Sign the JWT using the HS512 algorithm and secret key.
* 3. According to JWS Compact
* @param claims
* @param subject 以使用者名為subject進行生成token
* @return
*/
private String doGenerateToken(Map<String, Object> claims, String subject) {
final Date createdDate = clock.now();
final Date expirationDate = calculateExpirationDate(createdDate);
return Jwts.builder().setClaims(claims).setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret).compact();
}
/***
* 判斷目前token是否過期
* @param token
* @return
*/
private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(secret)
.parseClaimsJws(token).getBody();
}
}
入口控制器
@RestController
@CrossOrigin
public class JwtAuthenticationController extends BaseController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.http.request.header}")
private String tokenHeader;
@Autowired
private JwtUserDetailsService userDetailsService;
@RequestMapping(value = "/api/login", method = RequestMethod.POST)
public AgpWebResult createAuthenticationToken(@RequestBody JwtRequest authenticationRequest) {
AgpWebResult agpWebResult = null;
try {
// 先利用Security架構校驗使用者名、密碼是否對的
Authentication authentication = authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword());
// 利用JWT工具生成token,用戶端需要寫到浏覽器
final String token = jwtTokenUtil.generateToken(authentication);
agpWebResult = AgpWebResult.buildSuccess(token);
} catch (Exception e) {
agpWebResult = AgpWebResult.buildFailWithErrCode(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
}
return agpWebResult;
}
}
Postman效果
非登入态,通路需要授權API:
{
"data": null,
"errorCode": 401,
"errorMsg": "Unauthorized",
"statusCode": "401",
"success": false
}
登入接口,傳回值:
{
"data": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIifQ.gztc6-yNTmKiQ---bIsVoSa",
"success": true,
"errorMsg": null,
"statusCode": null,
"errorCode": null
}
如果輸入的使用者名或密碼不對,傳回:
{
"data": null,
"success": false,
"errorMsg": "INVALID_CREDENTIALS",
"statusCode": "401",
"errorCode": 401
}
為了統一後端傳回值格式,定義了一個統一的JSON結果。與前端限制好,傳回的JSON格式,先看success是否為true,如果不對,再看失敗的原因。