封面图片

编程

SpringBoot 集成 Security 和 JWT 安全认证教程

JWT是Json Web Token的简称,是目前最流行的跨域认证解决方案。


在nodejs、react、微服务等技术和框架出现之前,主流的安全认证都是基于cookie和session来做的。新技术的应用导致cookie和session的应用不够灵活和优雅。所以,基于token的认证慢慢成为了web安全认证的主流。

相关概念

  • 资源(Resource)

资源就是被访问的对象,web应用上可以用URL来指代。安全认证需要做的就是URL不被非法访问。

  • 认证(Authentication)

验证用户的身份。通过账号密码登录成功后,就认为你通过了认证。未认证去访问受保护的资源会返回http状态码401。

  • 授权(Authorization)

有些资源需要特定角色的用户才能访问,比如管理员角色可以对系统菜单进行修改,而普通用户是没权限这么做的。又或者用户授权第三方应用访问用户的某些资源等。当没有该资源的访问权限时会返回http状态码403。

  • 凭证(Credentials)

凭证就是用户的身份标识。通过它,系统就可以判定用户是否是经过认证和有权访问资源的。这里的凭证指的就是登录成功后发送给客户端的token。

相关依赖

spring security是一个安全框架,同时我们需要引入一个可以生成jwt的组件。这里是一些核心依赖。

1<dependency> 2 <groupId>org.springframework.boot</groupId> 3 <artifactId>spring-boot-starter-web</artifactId> 4</dependency> 5<dependency> 6 <groupId>org.springframework.boot</groupId> 7 <artifactId>spring-boot-starter-security</artifactId> 8</dependency> 9<dependency> 10 <groupId>org.springframework.security</groupId> 11 <artifactId>spring-security-jwt</artifactId> 12 <version>1.0.9.RELEASE</version> 13</dependency> 14<dependency> 15 <groupId>io.jsonwebtoken</groupId> 16 <artifactId>jjwt</artifactId> 17 <version>0.9.0</version> 18</dependency> 19<dependency> 20 <groupId>org.projectlombok</groupId> 21 <artifactId>lombok</artifactId> 22 <optional>true</optional> 23 <version>1.18.16</version> 24</dependency>

主要代码

配置文件

application.yml

1jwt: 2 header: Authorization 3 refresh_token_header: RefreshToken 4 secret: mySecret 5 # 过期时间单位秒 6 access_token_expiration: 600 7 refresh_token_expiration: 864000

在该文件中增加jwt相关配置。header指定token的头名称。refresh_token_header是refresh_token的头名称,secret是秘钥,用来对token进行编码。最后2个是token和refreshtoken的过期时间,一般token过期时间较短,refreshtoken长。

用户相关

UserDetail.java

1/** 2 * @author : JoeTao 3 * createAt: 2018/9/14 4 */ 5@Builder 6@Data 7public class UserDetail implements UserDetails { 8 private Long id; 9 private String username; 10 private String name; 11 private String password; 12 private boolean enabled; 13 private Set<GrantedAuthority> authorities; 14 15 @Override 16 public Collection<? extends GrantedAuthority> getAuthorities() { 17 return this.authorities; 18 } 19 20 @Override 21 public boolean isAccountNonExpired() { 22 return true; 23 } 24 25 @Override 26 public boolean isAccountNonLocked() { 27 return true; 28 } 29 30 @Override 31 public boolean isCredentialsNonExpired() { 32 return true; 33 } 34 35 @Override 36 public boolean isEnabled() { 37 return this.enabled; 38 } 39}

CustomUserDetailsServices.java

这个类的主要方法loadUserByUsername通过查询数据库获取用户名和密码,然后security会去验证账号密码是否匹配,如果通过该方法会返回用户相关信息。不通过则会跳到未认证的类进行处理。

1/** 2 * 登陆身份认证 3 * @author: JoeTao 4 * createAt: 2018/9/14 5 */ 6@Component(value="CustomUserDetailsService") 7public class CustomUserDetailsServiceImpl implements UserDetailsService { 8 @Resource 9 private IUserDao userDao; 10 @Resource 11 private IRoleDao roleDao; 12 @Resource 13 private IUserRoleDao userRoleDao; 14 @Override 15 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 16 Optional<UserDO> opt = userDao.findByUsername(username); 17 if (!opt.isPresent()){ 18 throw new UsernameNotFoundException("用户不存在"); 19 } 20 UserDO user = opt.get(); 21 22 Set<GrantedAuthority> grantedAuthorities = userRoleDao.findByUserId(user.getId()).stream() 23 .map(role -> new SimpleGrantedAuthority(roleDao.findById(role.getRoleId()).orElse(new RoleDO(0L, SecurityProps.ROLE_ANONYMOUS, "匿名用户")).getRoleName())) 24 .collect(Collectors.toSet()); 25 26 return UserDetail.builder() 27 .id(user.getId()) 28 .username(user.getUsername()) 29 .name(user.getNickname()) 30 .password(user.getPassword()) 31 .enabled(user.getState() == 1) 32 .authorities(Collections.unmodifiableSet(grantedAuthorities)) 33 .build(); 34 } 35}

jwt相关

JwtUtils.java

1/** 2 * @author Joetao 3 * @time 2021/1/25 2:58 下午 4 * @Email cutesimba@163.com 5 */ 6@Component 7public class JwtUtils { 8 private static final String CLAIM_KEY_USER_ID = "userId"; 9 private static final String CLAIM_KEY_USER_NAME = "username"; 10 private static final String CLAIM_KEY_NAME = "name"; 11 private static final String CLAIM_KEY_AUTHORITIES = "authorities"; 12 13 @Value("${jwt.secret}") 14 private String secret; 15 16 @Value("${jwt.access_token_expiration}") 17 private Long accessTokenExpiration; 18 19 @Value("${jwt.refresh_token_expiration}") 20 private Long refreshTokenExpiration; 21 22 private final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256; 23 24 public UserDetail getUserDetailFromToken(String token) { 25 final Claims claims = getClaimsFromToken(token); 26 if (claims == null) { 27 return null; 28 } 29 String username = claims.getSubject(); 30 String userId = claims.get(CLAIM_KEY_USER_ID).toString(); 31 String authorities = claims.get(CLAIM_KEY_AUTHORITIES).toString(); 32 String name = claims.get(CLAIM_KEY_NAME).toString(); 33 Set<GrantedAuthority> grantedAuthorities = Arrays.stream(authorities.split(",")) 34 .map(SimpleGrantedAuthority::new) 35 .collect(Collectors.toSet()); 36 return UserDetail.builder().id(Long.parseLong(userId)).username(username).name(name).authorities(grantedAuthorities).build(); 37 } 38 39 public String generateAccessToken(UserDetail userDetail) { 40 return generateToken(userDetail, accessTokenExpiration); 41 } 42 43 public String generateRefreshToken(UserDetail userDetail) { 44 return generateToken(userDetail, refreshTokenExpiration); 45 } 46 47 public Date getExpirationDateFromToken(String token) { 48 Date expiration; 49 try { 50 final Claims claims = getClaimsFromToken(token); 51 expiration = claims.getExpiration(); 52 } catch (Exception e) { 53 expiration = null; 54 } 55 return expiration; 56 } 57 58 private Claims getClaimsFromToken(String token) { 59 Claims claims; 60 try { 61 claims = Jwts.parser() 62 .setSigningKey(secret) 63 .parseClaimsJws(token) 64 .getBody(); 65 } catch (Exception e) { 66 claims = null; 67 } 68 return claims; 69 } 70 71 private Date generateExpirationDate(long expiration) { 72 return new Date(System.currentTimeMillis() + expiration * 1000); 73 } 74 75 public Boolean isRefreshTokenExpired(String refreshToken) { 76 Date expiration; 77 try { 78 expiration = Jwts.parser().setSigningKey(secret).parseClaimsJws(refreshToken).getBody().getExpiration(); 79 } catch (ExpiredJwtException e) { 80 return true; 81 } 82 return expiration.before(new Date()); 83 } 84 85 public String refreshToken(String refreshToken) { 86 if (isRefreshTokenExpired(refreshToken)) { 87 return ""; 88 } 89 String token; 90 synchronized (this) { 91 final UserDetail userDetail = getUserDetailFromToken(refreshToken); 92 if (userDetail == null) { 93 return null; 94 } 95 token = generateAccessToken(userDetail); 96 } 97 return token; 98 } 99 100 private Map<String, Object> generateClaims(UserDetail userDetail) { 101 Map<String, Object> claims = new HashMap<>(8); 102 claims.put(CLAIM_KEY_USER_ID, String.valueOf(userDetail.getId())); 103 claims.put(CLAIM_KEY_NAME, String.valueOf(userDetail.getName())); 104 claims.put(CLAIM_KEY_USER_NAME, String.valueOf(userDetail.getUsername())); 105 claims.put(CLAIM_KEY_USER_ID, String.valueOf(userDetail.getId())); 106 claims.put(CLAIM_KEY_AUTHORITIES, userDetail.getAuthorities() 107 .stream() 108 .map(GrantedAuthority::getAuthority) 109 .collect(Collectors.joining(","))); 110 return claims; 111 } 112 113 private String generateToken(UserDetail userDetail, long expiration) { 114 Map<String, Object> claims = generateClaims(userDetail); 115 String subject = userDetail.getUsername(); 116 String userId = String.valueOf(userDetail.getId()); 117 return Jwts.builder() 118 .setClaims(claims) 119 .setSubject(subject) 120 .setId(userId) 121 .setIssuedAt(new Date()) 122 .setExpiration(generateExpirationDate(expiration)) 123 .compressWith(CompressionCodecs.DEFLATE) 124 .signWith(SIGNATURE_ALGORITHM, secret) 125 .compact(); 126 } 127}

这个类是对token的相关操作。主要有将用户不敏感的信息编入token的方法;有通过token解析出用户信息的方法;有查看token是否过期的方法等等。

封装的返回对象

ResultCode.java

1 2public enum ResultCode { 3 /* 4 请求返回状态码和说明信息 5 */ 6 SUCCESS(200, "成功"), 7 BAD_REQUEST(400, "参数或者语法不对"), 8 UNAUTHORIZED(401, "token无效"), 9 FORBIDDEN(403, "禁止访问"), 10 NOT_FOUND(404, "请求的资源不存在"), 11 SERVER_ERROR(500, "服务器内部错误") 12; 13 private final int code; 14 private final String msg; 15 16 ResultCode(int code, String msg) { 17 this.code = code; 18 this.msg = msg; 19 } 20 21 public int getCode() { 22 return code; 23 } 24 25 public String getMsg() { 26 return msg; 27 } 28 29}

ResultJson.java

1@Data 2public class ResultJson<T> implements Serializable { 3 private static final long serialVersionUID = 783015033603078674L; 4 private int code; 5 private String msg; 6 private String requestId; 7 private T data; 8 9 public static ResultJson<Object> ok() { 10 return ok(new HashMap<>(1)); 11 } 12 13 public static <T> ResultJson<T> ok(T data) { 14 return new ResultJson<>(ResultCode.SUCCESS, data); 15 } 16 17 public static <T> ResultJson<T> ok(T data, String requestId) { 18 return new ResultJson<>(ResultCode.SUCCESS, data, requestId); 19 } 20 21 public static <T> ResultJson<T> failure(ResultCode code) { 22 return failure(code, null); 23 } 24 25 public static <T> ResultJson<T> failure(ResultCode code, T o) { 26 return new ResultJson<>(code, o); 27 } 28 29 public static <T> ResultJson<T> failure(ResultCode code, T o, String requestId) { 30 return new ResultJson<>(code, o, requestId); 31 } 32 33 public static <T> ResultJson<T> failure(int code, String msg) { 34 return new ResultJson<>(code, msg); 35 } 36 37 public ResultJson(ResultCode resultCode) { 38 setResultCode(resultCode); 39 } 40 41 public ResultJson(int code, String msg) { 42 this.code = code; 43 this.msg = msg; 44 } 45 46 public ResultJson(ResultCode resultCode, T data) { 47 setResultCode(resultCode); 48 this.data = data; 49 } 50 51 public ResultJson(ResultCode resultCode, T data, String requestId) { 52 setResultCode(resultCode); 53 this.data = data; 54 this.requestId = requestId; 55 } 56 57 public void setResultCode(ResultCode resultCode) { 58 this.code = resultCode.getCode(); 59 this.msg = resultCode.getMsg(); 60 } 61 62 @Override 63 public String toString() { 64 return "{" + 65 "\"code\":" + code + 66 ", \"msg\":\"" + msg + '\"' + 67 ", \"data\":\"" + data + '\"' + 68 '}'; 69 } 70}

security相关

AuthCheck.java

认证类,对账号密码进行校验,并将校验通过信息存储到context中。校验不通过,则跳到认证失败处理类。

1/** 2 * @author Joetao 3 * @date 2023/2/27 4 */ 5@Component 6@Slf4j 7public class AuthCheck { 8 private final AuthenticationManager authenticationManager; 9 10 public AuthCheck(AuthenticationManager authenticationManager) { 11 this.authenticationManager = authenticationManager; 12 } 13 14 public Authentication authenticate(String username, String password) { 15 try { 16 //该方法会去调用userDetailsService.loadUserByUsername()去验证用户名和密码,如果正确,则存储该用户名密码到“security 的 context中” 17 return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password)); 18 } catch (DisabledException | BadCredentialsException e) { 19 log.error("登录异常,{}", e.getLocalizedMessage()); 20 throw new CustomException(ResultJson.failure(ResultCode.UNAUTHORIZED, "用户名或密码无效")); 21 } 22 } 23}

JwtAuthenticationEntryPoint.java

认证失败处理类

这里的http状态返回的还是200,说明接口是通的,调用没问题。内部返回的json数据中通过自定义的code为401来标识是认证失败。

1@Component 2public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable { 3 4 private static final long serialVersionUID = -8970718410437077606L; 5 6 @Override 7 public void commence(HttpServletRequest request, 8 HttpServletResponse response, 9 AuthenticationException authException) throws IOException { 10 //验证为未登陆状态会进入此方法,认证失败 11 response.setStatus(HttpServletResponse.SC_OK); 12 response.setCharacterEncoding("UTF-8"); 13 response.setContentType("application/json; charset=utf-8"); 14 PrintWriter printWriter = response.getWriter(); 15 String body = ResultJson.failure(ResultCode.UNAUTHORIZED, "请先登录").toString(); 16 printWriter.write(body); 17 printWriter.flush(); 18 } 19}

RestAuthenticationAccessDeniedHandler.java

权限不足处理类

同上,http状态码返回200,接口返回对象的code返回403。

1@Component("RestAuthenticationAccessDeniedHandler") 2@Slf4j 3public class RestAuthenticationAccessDeniedHandler implements AccessDeniedHandler { 4 @Override 5 public void handle(HttpServletRequest httpServletRequest, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { 6 //登陆状态下,权限不足执行该方法 7 log.error("权限不足:{}", e.getMessage()); 8 response.setStatus(HttpStatus.OK.value()); 9 response.setCharacterEncoding("UTF-8"); 10 response.setContentType("application/json; charset=utf-8"); 11 PrintWriter printWriter = response.getWriter(); 12 String body = ResultJson.failure(ResultCode.FORBIDDEN, e.getMessage()).toString(); 13 printWriter.write(body); 14 printWriter.flush(); 15 } 16}

WebSecurityConfig.java

security配置类,用来装配上面的各个类,让security来进行调度。

1@Configuration 2@EnableWebSecurity 3@EnableGlobalMethodSecurity(prePostEnabled = true) 4public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 5 6 private final JwtAuthenticationEntryPoint unauthorizedHandler; 7 8 private final UserDetailsService customUserDetailsService; 9 10 private final JwtAuthenticationTokenFilter authenticationTokenFilter; 11 12 private final AccessDeniedHandler accessDeniedHandler; 13 14 @Autowired 15 public WebSecurityConfig(JwtAuthenticationEntryPoint unauthorizedHandler, 16 @Qualifier("CustomUserDetailsService") UserDetailsService customUserDetailsService, 17 JwtAuthenticationTokenFilter authenticationTokenFilter, AccessDeniedHandler accessDeniedHandler) { 18 this.unauthorizedHandler = unauthorizedHandler; 19 this.customUserDetailsService = customUserDetailsService; 20 this.authenticationTokenFilter = authenticationTokenFilter; 21 this.accessDeniedHandler = accessDeniedHandler; 22 } 23 24 @Autowired 25 public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { 26 authenticationManagerBuilder 27 // 设置UserDetailsService 28 .userDetailsService(this.customUserDetailsService) 29 // 使用BCrypt进行密码的hash 30 .passwordEncoder(passwordEncoder()); 31 } 32 33 /** 34 * 装载BCrypt密码编码器 35 * @return PasswordEncoder 36 */ 37 @Bean 38 public PasswordEncoder passwordEncoder() { 39 return new BCryptPasswordEncoder(); 40 } 41 42 @Override 43 protected void configure(HttpSecurity httpSecurity) throws Exception { 44 httpSecurity 45 .authorizeRequests() 46 .antMatchers("/api/v1/login", "/api/v1/open/**", "/error/**", "/swagger-ui.html").permitAll() 47 .and() 48 // 由于使用的是JWT,我们这里不需要csrf 49 .csrf().disable() 50 .exceptionHandling().accessDeniedHandler(accessDeniedHandler).and() 51 .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() 52 // 基于token,所以不需要session 53 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() 54 .authorizeRequests() 55 // 除上面外的所有请求全部需要鉴权认证 56 .anyRequest().authenticated(); 57 58 // 禁用缓存 59 httpSecurity.headers().cacheControl(); 60 61 // 添加JWT filter 62 httpSecurity 63 .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); 64 } 65 @Override 66 public void configure(WebSecurity web) { 67 web.ignoring() 68 .antMatchers( 69 "swagger-ui.html", 70 "**/swagger-ui.html", 71 "/favicon.ico", 72 "/**/*.css", 73 "/**/*.js", 74 "/**/*.png", 75 "/**/*.gif", 76 "/swagger-resources/**", 77 "/v2/**", 78 "/**/*.ttf" 79 ); 80 } 81 @Bean 82 @Override 83 public AuthenticationManager authenticationManagerBean() throws Exception { 84 return super.authenticationManagerBean(); 85 } 86}

万事俱备,最后写一个登录类来验证一下。

LoginUserDTO.java

1@Data 2@Builder 3@AllArgsConstructor 4@NoArgsConstructor 5public class LoginUserDTO { 6 @NonNull 7 private String username; 8 @NonNull 9 private String password; 10}

LoginServiceImpl.java

1@Service 2@Slf4j 3public class LoginServiceImpl implements LoginService { 4 private final IUserDao userDao; 5 private final JwtUtils jwtTokenUtil; 6 private final AuthCheck authCheck; 7 8 public LoginServiceImpl(IUserDao userDao, JwtUtils jwtTokenUtil, AuthCheck authCheck) { 9 this.userDao = userDao; 10 this.jwtTokenUtil = jwtTokenUtil; 11 this.authCheck = authCheck; 12 } 13 14 @Override 15 public UserVO login(String userName, String password) { 16 //用户验证 17 final Authentication authentication = authCheck.authenticate(userName, password); 18 //存储认证信息 19 SecurityContextHolder.getContext().setAuthentication(authentication); 20 21 final UserDetail userDetail = (UserDetail) authentication.getPrincipal(); 22 UserDO userDO = userDao.findById(userDetail.getId()).orElseThrow(() -> new CustomException(ResultJson.failure(ResultCode.BAD_REQUEST))); 23 24 //生成token 25 final String token = jwtTokenUtil.generateAccessToken(userDetail); 26 final String refreshToken = jwtTokenUtil.generateRefreshToken(userDetail); 27 return UserVO.builder() 28 .id(userDetail.getId()) 29 .username(userDetail.getUsername()) 30 .name(userDetail.getName()) 31 .isDark(userDO.getIsDark()) 32 .roles(userDetail.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())) 33 .token(token) 34 .expireAt(jwtTokenUtil.getExpirationDateFromToken(token).getTime()) 35 .refreshToken(refreshToken) 36 .auth(true) 37 .build(); 38 } 39 40}

LoginController.java

1@RestController 2@RequestMapping("/api/v1") 3public class LoginController { 4 private final LoginService loginService; 5 6 public LoginController(LoginService loginService) { 7 this.loginService = loginService; 8 } 9 10 @PostMapping(value = "/login") 11 public ResultJson<UserVO> login(@Valid @RequestBody LoginUserDTO user) { 12 UserVO userVO = loginService.login(user.getUsername(), password); 13 return ResultJson.ok(userVO); 14 } 15}

通过postman测试登录

测试之前需要在数据库中的user表、role表和user_role表中生成一些数据。数据库中的密码是用PasswordEncoder加密存储的。可以先通过main方法去生成一个加密的密码存到数据库进行测试。之后可以通过一个增加用户的接口去添加用户。

测试登录

https://ppsummer.com/blog/preview/57130000login.png

设置token Header访问URL

https://ppsummer.com/blog/preview/E732C06Fuse.png

最后

这篇文章介绍了如何使用SpringBoot集成Security和JWT实现安全认证。其中包括了认证类、认证失败处理类、权限不足处理类和security配置类等内容。最后还提供了一个登录类的示例代码,并通过postman进行了测试。关于刷新token,需要配合前端来做。这个后续会单独出一期来讲。

完整代码已上传github。demo

2023年04月02日
在初学者眼中,世界充满了可能;专家眼中,世界大都已经既定。--铃木俊隆