SpringSecurity-14-SpringSecurity结合JWT实现前后端分离的后端授权。
什么是JWT
JWT是JSON WEB TOKEN的缩写,它是基于RFC 7519标准定义的一种可以安全传输的JSON对象,因为使用了数字签名,所以可以信任。
JWT的组成
- JWT token的格式:header.payload.signature。
- header中用于存放签名的生成算法{"alg": "HS512"}。
- payload用于存放用户名、token的生成时间和过期时间{"sub":"admin","created":1489079981393,"exp":1489684781}。
- signature为以header和payload生成的签名,一旦header和payload被篡改,验证将失败//secret为加密算法的密钥。
- String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)。
JWT实例
这是一个JWT的字符串。
eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2NDg5ODg1MjAsInN1YiI6ImFkbWluIiwiY3JlYXRlZCI6MTY0ODk4NDkyMDQyNX0.P8YJ5AhcKATEpUmdtSmzGXcdDacESZ2jqU20JpjCqZOqy5AEE2uelYtay--Kg2wRWFx3bBhf9A5Jbv2S8fbs_A
可以在该网站上获得解析结果:https://jwt.io/。
编码实现
环境准备工作
- 建立Spring Boot项目并集成了Spring Security,项目可以正常启动。
- 通过controller写一个HTTP的GET方法服务接口,比如:“/student/selectall”。
- 实现最基本的动态数据验证及权限分配,即实现UserDetailsService接口。这两个接口都是向Spring Security提供用户、角色、权限等校验信息的接口。
- 如果你学习过Spring Security的formLogin登录模式,请将HttpSecurity配置中的formLogin()配置段全部去掉。因为JWT完全使用JSON接口,没有from表单提交。
- HttpSecurity配置中一定要加上csrf().disable(),即暂时关掉跨站攻击CSRF的防御。这样是不安全的,我们后续章节再做处理。
以上实现可以去查看我的专题SpringBoot和SpringSecurity进行查看。
在pom.xml中添加项目依赖
<dependencies>
<!--springsecurity-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--jwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.5.7</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
在application.yml中加入如下自定义一些关于JWT的配置
jwt:
header: JWTName
secret: springkhbd
expiration: 360
- jwt.header的value是Http的header中存储JWT的名称,名字可读性越差越安全。
- jwt.secret用来对JWT基础信息进行加密和解密的密匙。
- jwt.expiration用来设置JWT令牌的有效时间。
添加JWT token的工具类JwtTokenUtil
JwtTokenUtil用于生成和解析JWT token的工具类。
主要方法:
- generateToken(UserDetails userDetails):根据用户信息生成token令牌。
- getUserNameFromToken(String token):根据token令牌获取用户名。
- validateToken(String token, UserDetails userDetails):判断用户是否过期。
- refreshToken(String token):根据token属性token的过期时间。
package com.security.learn.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Data
@Slf4j
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtTokenUtil {
private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_CREATED = "created";
private String secret;
private Long expiration;
private String header;
/**
* 生成token令牌
*
* @param userDetails 用户
* @return 令token牌
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>(2);
claims.put(CLAIM_KEY_CREATED, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
//生成Token
return generateToken(claims);
}
/**
* 从claims生成令牌
* @param claims
* @return
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从Token中获取用户名称
* @param token
* @return
*/
public String getUserNameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 从令牌中获取数据声明,如果看不懂就看谁调用它
*
* @param token 令牌
* @return 数据声明
*/
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} catch (Exception e) {
log.info("JWT格式验证失败:{}",token);
claims = null;
}
return claims;
}
/**
* 生成token的过期时间
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}
/**
* 根据token过去过期时间
* @param token
* @return
*/
private Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getExpiration();
}
/**
*
* 验证Token是否过期
* @param token
* @param userDetails
* @return true表示没有过期,false表示过期
*/
public boolean validateToken(String token, UserDetails userDetails) {
String username = getUserNameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 判断令牌是否过期
* @param token
* @return
*/
public Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return false;
}
}
/**
* 判断token是否可以刷新
* @param token
* @return
*/
public boolean canRefresh(String token) {
return !isTokenExpired(token);
}
/**
* 刷新token
*/
public String refreshToken(String token) {
Claims claims = getClaimsFromToken(token);
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
}
UserDetailsService接口的实现
@Component("myUserDetailsService")
@Slf4j
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private AuthoritiesMapper authoritiesMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("认证请求: "+ username);
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.eq("username",username);
List<UserEntity> userEntities = userMapper.selectList(wrapper);
if (userEntities.size()>0){
QueryWrapper<AuthoritiesEntity> wrapper1 = new QueryWrapper<>();
wrapper.eq("userId", userEntities.get(0).getId());
List<AuthoritiesEntity> authorities = authoritiesMapper.selectList(wrapper1);
return new User(username, userEntities.get(0).getPassword(), AuthorityUtils.createAuthorityList(authorities.toString()));
}
return null;
}
}
开发登录接口(获取Token的接口)
JwtAdminService接口
public interface JwtAdminService {
/**
* 登录功能
* @param username 用户名
* @param password 密码
* @return 生成的JWT的token
*/
String login(String username, String password);
/**
* 刷新Token
* @param oldToken
* @return
*/
String refreshToken(String oldToken);
}
JwtAdminService接口实现
@AllArgsConstructor
@Slf4j
@Service
public class JwtAdminServiceImpl implements JwtAdminService {
private final UserDetailsService customUserDetailsService;
private final JwtTokenUtil jwtTokenUtill;
private final PasswordEncoder passwordEncoder;
/**
* 根据用户名密码登录时生成Token
* @param username 用户名
* @param password 密码
* @return
*/
@Override
public String login(String username, String password) {
try{
//根据用户名获取 用户信息
UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
if(!passwordEncoder.matches(password,userDetails.getPassword())){
throw new BadCredentialsException("密码不正确");
}
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
SecurityContextHolder.getContext().setAuthentication(token);
}catch (AuthenticationException e){
log.error("用户名或者密码不正确");
}
//生成JWT
UserDetails userDetails = customUserDetailsService.loadUserByUsername( username );
return jwtTokenUtill.generateToken(userDetails);
}
@Override
public String refreshToken(String oldToken) {
if (!jwtTokenUtill.isTokenExpired(oldToken)) {
return jwtTokenUtill.refreshToken(oldToken);
}
return null;
}
}
JwtAuthController的实现
- "/login"接口用于登录验证,并且生成JWT返回给客户端。
- "/refreshtoken"接口用于刷新JWT,更新JWT令牌的有效期。
@RestController
public class JwtAuthController {
@Resource
private JwtAdminService jwtAuthService;
@PostMapping(value = "/login")
public Result login(@RequestBody Map<String, String> map) throws Exception {
String username = map.get("username");
String password = map.get("password");
if (StrUtil.isEmpty(username) || (StrUtil.isEmpty(password))) {
return Result.fail("用户名密码不能为空");
}
try{
return Result.data( jwtAuthService.login(username, password));
}catch(Exception e){
return Result.fail(e.getMessage());
}
}
@PostMapping(value = "/refreshtoken")
public String refresh(@RequestHeader("${jwt.header}") String token) {
return jwtAuthService.refreshToken(token);
}
}
添加SpringSecurity的配置类LearnSrpingSecurity
import com.security.learn.filter.JwtAuthenticationTokenFilter;
import com.security.learn.handler.RestAuthenticationEntryPoint;
import com.security.learn.handler.RestfulAccessDeniedHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* 安全配置类
*/
@EnableWebSecurity
public class LearnSrpingSecurity extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService myUserDetailsService;
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
/**
* 认证管理器
* 1.认证信息提供方式(用户名、密码、当前用户的资源权限)
* 2.可采用内存存储方式,也可能采用数据库方式等
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService);
}
/**
* 资源权限配置(过滤器链):
* 1、被拦截的资源
* 2、资源所对应的角色权限
* 3、定义认证方式:httpBasic 、httpForm
* 4、定制登录页面、登录请求地址、错误处理方式
* 5、自定义 spring security 过滤器
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() //禁用跨站csrf攻击防御,后面的章节会专门讲解
.sessionManagement()// 基于token,所以不需要session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/login").permitAll()//不需要通过登录验证就可以被访问的资源路径
.anyRequest().authenticated();
//添加自定义未授权和未登录结果返回
http.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthenticationEntryPoint);
}
}
相关依赖以及方法说明
- configure(HttpSecurity http):资源权限配置(过滤器链)、jwt过滤器及出异常后的处理器。
- configure(AuthenticationManagerBuilder auth):用于配置UserDetailsService及PasswordEncoder。
- RestfulAccessDeniedHandler:当用户没有访问权限时的处理器,用于返回JSON格式的处理结果。
- RestAuthenticationEntryPoint:当未登录或token失效时,返回JSON格式的结果。
- UserDetailsService:SpringSecurity定义的核心接口,用于根据用户名获取用户信息,需要自行实现。
- JwtAuthenticationTokenFilter:在用户名和密码校验前添加的过滤器,如果有jwt的token,会自行根据token信息进行登录。
- configure(HttpSecurity http),主要配置:
- 将我们的自定义jwtAuthenticationTokenFilter,加载到UsernamePasswordAuthenticationFilter的前面。
- 因为我们使用了JWT,表明了我们的应用是一个前后端分离的应用,所以我们可以开启STATELESS禁止使用session。
添加RestfulAccessDeniedHandler
当访问接口没有权限时,自定义的返回结果。
/**
* 当访问接口没有权限时,自定义的返回结果
*/
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(Result.fail(e.getMessage())));
response.getWriter().flush();
}
}
添加RestAuthenticationEntryPoint
当用户未登录或者token失效访问接口时,自定义的返回结果。
/**
* 当未登录或者token失效访问接口时,自定义的返回结果
*/
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(Result.fail(authException.getMessage())));
response.getWriter().flush();
}
}
添加JwtAuthenticationTokenFilter
在用户名和密码校验前添加的过滤器,如果请求中有jwt的token且有效,会取出token中的用户名,然后调用SpringSecurity的API进行登录操作。
@Slf4j
@Component
@AllArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final UserDetailsService myUserDetailsService;
private final JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = request.getHeader(jwtTokenUtil.getHeader());
if(!StrUtil.isEmpty(jwt)){
//根据jwt获取用户名
String username = jwtTokenUtil.getUserNameFromToken(jwt);
log.info("校验username:{}",username);
//如果可以正确从JWT中提取用户信息,并且该用户未被授权
if(!StrUtil.isEmpty(username) && SecurityContextHolder.getContext().getAuthentication()==null){
UserDetails userDetails = this.myUserDetailsService.loadUserByUsername(username);
if(jwtTokenUtil.validateToken(jwt,userDetails)){
//给使用该JWT令牌的用户进行授权
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(userDetails,null,
userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
filterChain.doFilter(request, response);
}
}
测试
测试登录接口,即:获取token的接口。输入正确的用户名、密码即可获取token。
使用不带token,但是不传递JWT令牌,结果是禁止访问。
使用不带token,携带JWT令牌。