Spring Security是Spring全家桶中基于Web Filter实现的提供安全认证服务的框架。JWT(JSON Web Token)是一个跨域身份验证解决方案,可脱离Session进行身份认证,也可以同时为多系统间提供统一身份认证。下面主要介绍如何在SpringBoot后端项目中集成JWT和Spring Security实现用户登录。
2.2.6
IntelliJ IDEA 2019.3.3 (Ultimate Edition)
如果Springboot项目未初始化,可参考<
>``
Spring Security和JWT需要引用相关包,在项目的build.gradle
文件中dependencies
节点下添加如下引用
implementation "org.springframework.boot:spring-boot-starter-security"
implementation 'io.jsonwebtoken:jjwt:0.9.1'
创建用户表h_user
,用户实体User
及实体数据持久操作类UserRepository
,具体定义可参照上一篇<
在application.properties
文件中添加以下配置信息
jwt.httpHeader
:http请求中用于传输token的头名称jwt.tokenHead
:token前缀jwt.secret
:token秘钥jwt.expiration
:token过期时间这里划分为5个类实现JWT的核心功能部分,JwtUser
、JwtTokenUtil
、JwtUserDetailsService
、JwtTokenFilter
和JwtWebSecurityConfig
。
实现于Spring Security的UserDetails
类,用于存储认证用户的基本信息。代码如下:
public class JwtUser implements UserDetails {
private final Integer id;
private final String username;
private final String password;
public JwtUser(
Integer id,
String username,
String password) {
this.id = id;
this.username = username;
this.password = password;
}
public Integer getId() {
return id;
}
@Override
public String getUsername() {
return username;
}
@JsonIgnore
@Override
public String getPassword() {
return password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
}
JWT工具类,基于全局配置,用于token的生成、刷新、过期检测等等,具体代码如下:
@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private Long expirationSeconds;
/**
* 从token获取JWT声明信息
*
* @param token
* @return
*/
private Claims getClaimsFromToken(String token) {
try {
return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody();
} catch (Exception e) {
throw new RuntimeException(String.format("Invalid Jwt Token:%s ======> %s", token, e.getMessage()));
}
}
/**
* 从token获取用户名
*
* @param token
* @return
*/
private String getUsernameFromToken(String token) {
return getClaimsFromToken(token).getSubject();
}
/**
* 从token获取过期时间
*
* @param token
* @return
*/
private Date getExpirationDateFromToken(String token) {
return getClaimsFromToken(token).getExpiration();
}
/**
* 从token获取Jwt用户对象
*
* @param token
* @return
*/
public JwtUser getJwtUserFromToken(String token) {
if (isTokenExpired(token)) {
throw new RuntimeException(String.format("user token expired:%s!", token));
}
Claims claims = getClaimsFromToken(token);
return new JwtUser(Integer.valueOf(claims.getId()), claims.getSubject(), null);
}
/**
* 检查token是否过期
*
* @param token
* @return
*/
private Boolean isTokenExpired(String token) {
return getExpirationDateFromToken(token).before(new Date());
}
/**
* 根据配置计算过期时间
*
* @return
*/
private Date genExpirationDate() {
return new Date(System.currentTimeMillis() + expirationSeconds * 1000);
}
/**
* 根据JWT声明生成token
*
* @param claims
* @return
*/
private String genToken(Map<String, Object> claims) {
return Jwts.builder().setClaims(claims).setExpiration(genExpirationDate()).signWith(SignatureAlgorithm.HS512, jwtSecret).compact();
}
/**
* 根据Jwt用户对象生成token
*
* @param user
* @return
*/
public String genToken(JwtUser user) {
return genToken(new HashMap<String, Object>() {{
put(Claims.SUBJECT, user.getUsername());
put(Claims.ID, user.getId());
}});
}
/**
* 用于当有用户操作时刷新用户token过期时间,防止用户操作过程中token过期
*
* @param token
* @return
*/
public String refreshToken(String token) {
if (isTokenExpired(token)) {
throw new RuntimeException(String.format("user token expired:%s!", token));
}
Claims claims = getClaimsFromToken(token);
return genToken(claims);
}
}
实现与Spring Security的UserDetailsService
类,用于用户的身份验证。具体代码如下:
@Service
public class JwtUserDetailsService implements UserDetailsService {
private Logger logger = LoggerFactory.getLogger(this.getClass());
private final UserRepository userRepository;
public JwtUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = this.userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException(String.format("User not exist:'%s'", username)));
logger.info("用户请求登录:{}", username);
return new JwtUser(user.getId(), user.getUsername(), user.getPassword());
}
}
Spring Security基于Web Filter,这里JwtTokenFilter
实现于OncePerRequestFilter
,用于拦截用户需要鉴权的请求并验证token有效性。具体代码如下:
@Component
// TODO: 2020/4/16 study OncePerRequestFilter
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
@Value("${jwt.httpHeader}")
private String tokenHttpHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
private final UserRepository userRepository;
public JwtTokenFilter(JwtTokenUtil jwtTokenUtil, UserRepository userRepository) {
this.jwtTokenUtil = jwtTokenUtil;
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.tokenHttpHeader);
if (authHeader != null && authHeader.startsWith(tokenHead)) {
try {
// This part after "Bearer "
final String authToken = authHeader.substring(tokenHead.length());
JwtUser jwtUser = jwtTokenUtil.getJwtUserFromToken(authToken);
//从客户端token中获取到了用户信息,但是应用上下文中不存在登录用户,根据业务逻辑决定是否需要创建登录用户
if (jwtUser != null && jwtUser.getUsername() != null && SecurityContextHolder.getContext().getAuthentication() == null) {
User user = this.userRepository.findByUsername(jwtUser.getUsername()).orElseThrow(() -> new HException(String.format("用户不存在:%s", jwtUser.getUsername())));
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities(new ArrayList<>()));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//应用上下文中设置登录用户信息,此时Authentication类型为User
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}
chain.doFilter(request, response);
}
}
实现于Spring Security的WebSecurityConfigurerAdapter
,用于Web安全全局配置,比如设置哪些需要验证权限、哪些需要放行、静态页面如何处理以及权限如何验证等等。具体实现如下:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class JwtWebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenFilter jwtAuthTokenFilter;
private final UserDetailsService userDetailsService;
private final String[] staticResPatterns = new String[]{"/", "/*.html", "/favicon.ico", "/**/*.html", "/**/*.css", "/**/*.js"};
private final String[] noneAuthReqPatterns = new String[]{"/public/**", "/demo/**", "/actuator/**", "/auth/**"};
public JwtWebSecurityConfig(@Qualifier("jwtUserDetailsService") UserDetailsService userDetailsService, JwtTokenFilter jwtAuthTokenFilter) {
this.userDetailsService = userDetailsService;
this.jwtAuthTokenFilter = jwtAuthTokenFilter;
}
@Autowired
public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
// 设置UserDetailsService并使用BCrypt进行密码的hash
authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 由于使用的是JWT不使用session,这里禁用csrf
.csrf().disable()
//禁用form登录
.formLogin().disable()
// 基于token,所以不需要session,禁用
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
// 允许所有HttpMethod.OPTIONS类型请求
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 允许对于网站静态资源的无授权访问
.antMatchers(HttpMethod.GET, this.staticResPatterns).permitAll()
// 无需验证身份的请求,如登录注册等等
.antMatchers(noneAuthReqPatterns).permitAll()
//admin开头的请求,需要admin权限
.antMatchers("/admin/**").hasAnyRole("ADMIN")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
//使用jwtAuthTokenFilter验证身份,身份验证失败返回403错误代码
.and().addFilterBefore(jwtAuthTokenFilter, UsernamePasswordAuthenticationFilter.class).exceptionHandling().authenticationEntryPoint(new Http403ForbiddenEntryPoint())
//启用跨域请求
.and().cors();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
final CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH"));
// setAllowCredentials(true) is important, otherwise:
// The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
configuration.setAllowCredentials(true);
// setAllowedHeaders is important! Without it, OPTIONS preflight request
// will fail with 403 Invalid CORS request
configuration.setAllowedHeaders(Arrays.asList("Authorization", "X-API", "Cache-Control", "Content-Type", "CurWhseId"));
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* any GenericFilterBean (OncePerRequestFilter is one) in the context will be automatically added to the filter chain. Meaning the configuration you have above will include the same filter twice.
*
* this is to fix the filter been called twice
*/
@Bean(name = "authenticationFilterRegistration")
public FilterRegistrationBean myAuthenticationFilterRegistration(final JwtTokenFilter filter) {
final FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(filter);
filterRegistrationBean.setEnabled(false);
return filterRegistrationBean;
}
}
创建AuthService
用于登录验证,AuthController
类用于用户注册和登录请求处理。代码如下:
@Service
public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtTokenUtil jwtTokenUtil;
@Autowired
public AuthService(AuthenticationManager authenticationManager,
JwtTokenUtil jwtTokenUtil) {
this.authenticationManager = authenticationManager;
this.jwtTokenUtil = jwtTokenUtil;
}
/**
* 用户名密码登录验证,获取token
* @param username 用户名
* @param password 密码
* @return
*/
public String login(String username, String password) {
UsernamePasswordAuthenticationToken upToken = new UsernamePasswordAuthenticationToken(username, password);
//该方法会去调用userDetailsService.loadUserByUsername()去验证用户名和密码,如果正确,则会存储该用户名密码到“Spring Security 的 context中”
//此时Authentication类型为JwtUser
Authentication auth = authenticationManager.authenticate(upToken);
return jwtTokenUtil.genToken((JwtUser) auth.getPrincipal());
}
}
@RestController
@RequestMapping("/auth")
public class AuthController {
private Logger logger = LoggerFactory.getLogger(this.getClass());
private final AuthService authService;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public AuthController(AuthService authService, PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.authService = authService;
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
@PostMapping("/regUser")
public HResponse regUser(String username, String password) {
if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
return HResponse.error("用户名密码不能为空!");
}
int userCount = this.userRepository.countAllByUsername(username);
if (userCount > 0) {
return HResponse.error(String.format("用户已存在:%s", userCount));
}
User user = new User();
user.setUsername(username.trim());
user.setPassword(this.passwordEncoder.encode(password));
user.setEmail("[email protected]");
user.setFlag(0);
user.setMobile("18988888888");
user.setRealName(username);
this.userRepository.save(user);
return HResponse.success();
}
@PostMapping("/login")
public HResponse login(@Param("username") String username, @Param("password") String password) {
try {
User user = this.userRepository.findByUsername(username).orElseThrow(() -> new HException("用户不存在"));
JSONObject resp = new JSONObject().fluentPut("user", user);
resp.put("token", authService.login(username, password));
logger.info("user login with password: {}", username);
return HResponse.success(resp);
} catch (HException e) {
return HResponse.error(e.getMessage());
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return HResponse.error(String.format("用户 %s 登录失败!", username));
}
}
在<common/
路径的请求,比如在未登录的情况下再执行其中的common/setRedisValue
请求,将返回403
错误,如JwtWebSecurityConfig
中配置。
在请求的header
中附带Authorization
信息,并设置为登录成功后返回的token,则请求响应成功,如下图: