1. 简要
之前学习的认证方式,在服务器验证通过后,会在当前对话session中保存数据,然后向客户端返回一个session_id存在客户端Cookie中,但是这种模式会存在问题就是扩展性不好,对于单机还好,如果是服务器集群,就需要实现session共享,保证每台服务器都能读取session。
而今天要学的JWT(JSON Web Token)是目前比较留校的一种跨域认证方案,在前后端分离项目中应用的比较多。具体JWT的概念可以学习阮一峰大神的这篇文章:[JSON Web Token 入门教程 - 阮一峰](https://link.juejin.cn/?target=http%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2018%2F07%2Fjson_web_token-tutorial.html)
俺今天是要使用SpringSecurity实现JWT的认证,前后端分离,使用JSON交互。
2. 设计思路
首先是登录认证:
- 前端POST请求,将用户名和密码以JSON的形式发送请求
/jwt/login
- 自定义一个
JWTAuthenticationFilter
,进行提取request中的参数,封装为一个UsernampasswordAuthenticationToken
给AuthenticationManager
进行认证 -
AuthenticationManager
从自定义的Service中查找用户信息,判断账号密码是否正确 - 认证成功则生成JWT Token给客户端
- 认证失败则返回错误
然后是请求时的Token校验:
因为使用JWT,所以不需要用到session,每一个请求都是需要携带Token,服务器根据Token进行验证,因此这里我们还需要一个自定义的Filter,用来拦截任意请求
- 一个请求发起
- 被自定义的Filter拦截,进行token验证
- 将验证成功的token生成
Authentication
对象存入SecurityContext
上下文,表示验证完成 - 后续再进行权限的验证
3. 环境搭建
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-web
io.jsonwebtoken
jjwt
0.9.1
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.2.2
mysql
mysql-connector-java
5.1.48
runtime
javax.xml.bind
jaxb-api
2.3.0
org.springframework.boot
spring-boot-starter-test
test
org.springframework.security
spring-security-test
test
org.projectlombok
lombok
这里需要注意的一个包是:jaxb-api
导入这个包是因为使用JJWT这个包时会报一个异常:
java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter at java.base/jdk.internal.loader
导入这个包就可以解决了。
Springboot配置mysql和mybatis
spring:
datasource:
username: root
password: root
url: jdbc:mysql://localhost:3306/security
driver-class-name: com.mysql.jdbc.Driver
mybatis:
mapper-locations:
- classpath:mapper/*.xml
type-aliases-package: com.zzy.jwt.pojo
4. 源码实现
-
自定义
JWTAuthenticationFilter
public class JWTAuthenticationFilter extends AbstractAuthenticationProcessingFilter { /** * 通过构造器注入拦截的Url,请求方法没有限制 * @param antPathRequestUrl url */ public JWTAuthenticationFilter(String antPathRequestUrl) { super(new AntPathRequestMatcher(antPathRequestUrl, "POST")); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { // 从JSON中读取请求参数 User user = new ObjectMapper().readValue(request.getInputStream(), User.class); // 获取用户名和密码 String username = user.getUsername(); String password = user.getPassword(); // 构造Token,使用UsernamepasswordAuthenticationToken UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password); // 设置一些客户端IP等信息 setDetails(request, token); // 交给AuthenticationManager进行认证 return this.getAuthenticationManager().authenticate(token); } protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { ObjectMapper objectMapper = new ObjectMapper(); Response myResponse = new Response(); // 设置header response.setHeader("Content-Type", "application/json;charset=utf-8"); User user = (User) authResult.getPrincipal(); // 生成Token返回 String token = JWTUtil.generate(user); myResponse.setStatusCode(HttpStatus.OK.value()); myResponse.setMsg("登录成功!"); myResponse.setData("Bearer " + token); response.setStatus(HttpStatus.OK.value()); response.getWriter().write(objectMapper.writeValueAsString(myResponse)); } /** * 重写认证失败后的处理方法 * @param request * @param response * @param failed * @throws IOException * @throws ServletException */ protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { ObjectMapper objectMapper = new ObjectMapper(); Response myResponse = new Response(); myResponse.setStatusCode(HttpStatus.UNAUTHORIZED.value()); if (failed instanceof LockedException) { myResponse.setMsg("账号被锁定!"); } else if (failed instanceof CredentialsExpiredException) { myResponse.setMsg("用户密码过期!"); } else if (failed instanceof AccountExpiredException) { myResponse.setMsg("用户账号过期!"); } else if (failed instanceof DisabledException) { myResponse.setMsg("用户账号被禁用!"); } else if (failed instanceof BadCredentialsException) { myResponse.setMsg("用户账号或密码错误!"); } response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write(objectMapper.writeValueAsString(myResponse)); }
自定义登录认证过滤器,这次拦截的url由构造器进行设置,重写
attemptAuthentication
方法逻辑与之前的自定义认证一样,都是从请求中获取用户名和密码等参数,封装为一个Token,我这里直接使用了官方的UsernamepasswordAuthenticationToken,然后交给AuthenticationManager
去认证。这里的认证成功与失败没有使用自定义类的形式实现,而是以重写
successfulAuthentication
方法和unsuccessfulAuthentication
方法实现,认证成功后生成一个JWT Token返回,这里直接将user对象作为JWT的playload保存,实际中应以实际情况写入的。认证失败则返回JSON字符串错误。需要与之前的验证码认证区分开的是,这里前后端是以JSON传递的,所以request接收到的是一个json字符串的数据,返回时也是json数据,相同的地方是Provider还是调用UserDetailsService的loadUserByUsername方法去验证用户。
在这里也是自定义了一个Response类,用来构造返回信息,主要就是设置状态,返回信息,以及数据。
-
定义UserDetail类和UserDetailsService
UserDetail类:
@Data @JsonIgnoreProperties(ignoreUnknown = true) public class User implements UserDetails, Serializable { private static final long serialVersionUID = -1384206000980290583L; private int id; private String username; private String password; private String email; private boolean enabled; @Override public String toString() { return "User{" + "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + ", email='" + email + '\'' + '}'; } @Override @JsonDeserialize(using = CustomAuthorityDeserializer.class) public Collection extends GrantedAuthority> getAuthorities() { List
authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_ROOT")); return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return enabled; } } User类没有太多变动,和之前一样,因为还没涉及到授权部分,这里暂时先写死权限位
ROLE_ROOT
.有两个错误可能会出现:
-
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "accountNonExpired" (class com.zzy.jwt.pojo.User), not marked as ignorable
这是因为我们user类中一些字段在数据库中是没有的,需要在类上加上一个注解:
@JsonIgnoreProperties(ignoreUnknown = true)
解决方案来源于这个博客:jackson json转bean忽略没有的字段 not marked as ignorable
-
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of org.springframework.security.core.GrantedAuthority
这是因为我们在构造JWT Token时候是将整个user对象写入的,user类中getAuthorities方法导致的,需要在getAuthorities方法上加上一个注解
@JsonDeserialize(using = CustomAuthorityDeserializer.class)
并定义一个类:CustomAuthorityDeserializer
具体解决方式可以参考这个博文:Cannot construct instance of org.springframework.security.core.GrantedAuthority的错误解决
UserDetailsService:
@Service public class UserService implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.findUserByUsername(username); if (user == null) { throw new UsernameNotFoundException("User: " + username + " not exist!"); } return user; } }
Provider通过调用UserService的方法loadUserByUsername去认证用户,这里的方法里通过用户名查询数据库。
当然这个UserService是我们自定义的实现UserDetailsService的,在后面我们的Security配置中要设置为这个Service
数据库操作相关的比较简单,具体再看gitee上的源码吧,哈哈。
-
-
JWT工具类
public class JWTUtil { // 密钥 private final static String secretKey = "Lucas"; // 过期时间设置为5min private final static Duration expiration = Duration.ofMinutes(10); /** * 生成Token * @param user * @return */ public static String generate(User user) { // 过期时间 Date expiryDate = new Date(System.currentTimeMillis() + expiration.toMillis()); try { return Jwts.builder() .setSubject(new ObjectMapper().writeValueAsString(user)) // 将username放进JWT .setIssuedAt(new Date()) // 设置jwt签发时间 .setExpiration(expiryDate) // 设置jwt过期时间 .signWith(SignatureAlgorithm.HS512, secretKey) // 设置加密算法和密钥 .compact(); } catch (Exception e) { return null; } } /** * 解析Token * @param token * @return */ public static Claims parse(String token) { try { return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); } catch (Exception e) { return null; } } }
这里只是简单的构造,一个生成Token的方法,一个解析Token的方法,密钥设置为字符串"Lucas", 过期时间设置为10分钟。
更加细致的工具方法暂未去实现,先凑合着用着。
其实,到了这里我们就差将我们自定义的Filter和Provider添加到SpringSecurity的主配置中,就实现了认证了,但我们这次用的是JWT方式,服务器端并没有记录登录用户的信息,因此我们需要多一个过滤器,用来拦截请求,验证Token的,如果请求携带了Token,并且token里的信息是正确的,我们才将Authentication设置已认证,让SpringSecurity过滤器链去做后续的鉴权啥的。
-
自定义Token拦截器
SpringSecurity默认的基础配置中没有提供对
Bearer Authentication
处理的过滤器,但是提供了处理Basic Authentication
的过滤器BasicAuthenticationFilter
,我们还是可以模仿它实现自己的Filterpublic class JWTAuthenticationRequestFilter extends OncePerRequestFilter { private UserDetailsService userDetailsService; public JWTAuthenticationRequestFilter(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 校验Token Authentication authentication = getAuthentication(request); if (authentication == null) { filterChain.doFilter(request, response); return; } // 认证成功, 保存到SecurityContext上下文中 SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } private Authentication getAuthentication(HttpServletRequest request) throws JsonProcessingException { // 判断是否有Token,拿到Token String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { return null; } String token = header.split(" ")[1]; System.out.println("token = " + token); // 通过Token解析 Claims claims = JWTUtil.parse(token); if (claims == null) { return null; } User user = (User) new ObjectMapper().readValue(claims.getSubject(), User.class); // 开始验证 // 通过用户名查找数据库,确认是否存在该用户 UserDetails userDetails = null; try { userDetails = userDetailsService.loadUserByUsername(user.getUsername()); } catch (UsernameNotFoundException e) { return null; } // 校验Token的密码与数据库中用户密码是否一致 if (!userDetails.getPassword().equals(user.getPassword())) { return null; } // 构造Authentication对象 return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities()); } }
简单说明下这里的逻辑:
- 对于任意的请求,都会被该过滤器拦截,首先会判断是否携带了token,这里设置是请求头
Authentication
中放置Token - 接着对Token进行解析,拿出我们存放进去的user类对象
- 调用userService调用loadUserByUsername去查询数据库,确定是否存在该用户
- 校验数据库中的用户密码是否与Token中的用户密码信息一致,如果一致则构造一个可信的
Authentication
对象,这里是UsernamepasswordAuthenticationToken
;如果不一致,则返回null - 若Token校验不通过,则放行,SpringSecurity后续的过滤器会处理掉他的。
- 若Token校验成功,则将校验对象
Authentication
放入SpringContext上下文中
- 对于任意的请求,都会被该过滤器拦截,首先会判断是否携带了token,这里设置是请求头
-
自定义SpringSecurity中的异常
前面一些处理报异常时并没有返回到客户端,我们还需要自定义SpringSecurity的全局异常处理,返回JSON数据给到客户端。
具体的操作可以学习小胖哥这篇博文:Spring Security 实战干货:自定义异常处理
权限相关异常处理:
public class SimpleAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { Response myResponse = new Response(); myResponse.setStatusCode(HttpServletResponse.SC_FORBIDDEN); myResponse.setMsg("没有权限!"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setCharacterEncoding("utf-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse)); } }
认证相关异常处理:
public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { Response myResponse = new Response(); myResponse.setStatusCode(HttpServletResponse.SC_UNAUTHORIZED); myResponse.setMsg(authException.getMessage()); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setCharacterEncoding("utf-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse)); } }
-
配置
我们还是自定义一个配置类继承
SecurityConfigurerAdapter
,配置我们自定义的Filter@Component public class JWTLoginConfig extends SecurityConfigurerAdapter
{ @Autowired private UserService userService; @Override public void configure(HttpSecurity http) throws Exception { // 配置Filter JWTAuthenticationFilter jwtAuthenticationFilter = new JWTAuthenticationFilter("/jwt/login"); JWTAuthenticationRequestFilter jwtAuthenticationRequestFilter = new JWTAuthenticationRequestFilter(userService); // 配置AuthenticationManager jwtAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); // 添加Filter http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(jwtAuthenticationRequestFilter, UsernamePasswordAuthenticationFilter.class); } } 注意的点:
- 这里虽说是一个配置类,但是最后是要并入SpringSecurity的主配置的,这里用的注解是
@Component
,能让我们在主配置类中进行注入。 - 自定义的认证Filter一定要设置
AuthenticationManager
,否则找不到对应的Provider来处理。
主配置类:
@Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JWTLoginConfig jwtLoginConfig; @Autowired private UserService userService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(AuthenticationManagerBuilder builder) throws Exception { builder.userDetailsService(userService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/hello") .hasRole("ROOT") .anyRequest() .authenticated() .and() .apply(jwtLoginConfig) .and() .csrf() .disable(); // 自定义异常处理 http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint()); // 前后端分离是STATELESS,session使用该策略 http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } }
这里我们自定义了UserDetailService方法,因此需要配置上
builder.userDetailsService(userService).passwordEncoder(passwordEncoder());
,密码编码器不可少。然后自定义异常的话就得使用
http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());
将我们自定义的类传入最后是将session策略设置为了STATELESS.
- 这里虽说是一个配置类,但是最后是要并入SpringSecurity的主配置的,这里用的注解是
5. 测试
我简单写了一个接口/hello
@RestController
public class UserController {
@GetMapping("/hello")
public String hello() {
return "hello jwt!";
}
}
通过Postman进行测试验证:
-
访问
/jwt/login
登录返回了一个Token,拿着这个Token去做请求
-
访问
/hello
在发起请求时,在header部分添加一个key为Authentication,value为登录请求时返回的Token,才能正常访问,获得回复。
-
当Token过期时,再去访问
/hello
返回了401的错误,这是咱们自定义的异常处理器
SimpleAuthenticationEntryPoint
返回了信息 -
我们在定义User类时默认写死了权限为
ROLE_ROOT
,那当我们将/hello
的访问权限修改为ROLE_USER
时去访问http.authorizeRequests().antMatchers("/hello").hasRole("USER")
返回了403错误,这是自定义异常处理器SimpleAccessDeniedHandler
返回的信息
6. JWT存在的问题
- 续签问题,传统的cookie+session的方案天然支持续签,但是jwt由于服务端不保存用户状态了,因此很难完美解决续签问题
- 注销问题,因为服务器不保存用户信息,所以一般可以通过修改secret来实现注销,但会有个问题,服务端的secret修改后,已经颁发的还没过期的token就会认证失败。
- 一般建议不同用户取不同的secret