SpringSecurity构建基于JWT的登录认证实现
作者:locotor在掘金 时间:2023-06-14 10:49:02
目录
目标功能点
准备工作
引入 Maven 依赖
配置 DAO 数据层
创建 JWT 工具类
登录
LoginFilter
LoginSuccessHandler
LoginFailureHandler
验证
JwtAuthenticationFilter
AuthenticationEntryPoint
集中配置
最近项目的登录验证部分,采用了 JWT 验证的方式。并且既然采用了 Spring Boot 框架,验证和权限管理这部分,就自然用了 Spring Security。这里记录一下具体实现。
在项目采用 JWT 方案前,有必要先了解它的特性和适用场景,毕竟软件工程里,没有银弹。只有合适的场景,没有万精油的方案。
一言以蔽之,JWT 可以携带非敏感信息,并具有不可篡改性。可以通过验证是否被篡改,以及读取信息内容,完成网络认证的三个问题:“你是谁”、“你有哪些权限”、“是不是冒充的”。
为了安全,使用它需要采用 Https 协议,并且一定要小心防止用于加密的密钥泄露。
采用 JWT 的认证方式下,服务端并不存储用户状态信息,有效期内无法废弃,有效期到期后,需要重新创建一个新的来替换。
所以它并不适合做长期状态保持,不适合需要用户踢下线的场景,不适合需要频繁修改用户信息的场景。因为要解决这些问题,总是需要额外查询数据库或者缓存,或者反复加密解密,强扭的瓜不甜,不如直接使用 Session。不过作为服务间的短时效切换,还是非常合适的,就比如 OAuth 之类的。
目标功能点
通过填写用户名和密码登录。
验证成功后, 服务端生成 JWT 认证 token, 并返回给客户端。
验证失败后返回错误信息。
客户端在每次请求中携带 JWT 来访问权限内的接口。
每次请求验证 token 有效性和权限,在无有效 token 时抛出 401 未授权错误。
当发现请求带着的 token 有效期快到了的时候,返回特定状态码,重新请求一个新 token。
准备工作
引入 Maven 依赖
针对这个登录验证的实现,需要引入 Spring Security、jackson、java-jwt 三个包。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.12.1</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.12.1</version>
</dependency>
配置 DAO 数据层
要验证用户前,自然是先要创建用户实体对象,以及获取用户的服务类。不同的是,这两个类需要实现 Spring Security 的接口,以便将它们集成到验证框架中。
User
用户实体类需要实现 ”UserDetails“ 接口,这个接口要求实现 getUsername、getPassword、getAuthorities 三个方法,用以获取用户名、密码和权限。以及 isAccountNonExpired```isAccountNonLocked、isCredentialsNonExpired、isEnabled 这四个判断是否是有效用户的方法,因为和验证无关,所以先都返回 true。这里图方便,用了 lombok。
@Data
public class User implements UserDetails {
private static final long serialVersionUID = 1L;
private String username;
private String password;
private Collection<? extends GrantedAuthority> authorities;
...
}
UserService
用户服务类需要实现 “UserDetailsService” 接口,这个接口非常简单,只需要实现 loadUserByUsername(String username) 这么一个方法。这里使用了 MyBatis 来连接数据库获取用户信息。
@Service
public class UserService implements UserDetailsService {
@Autowired
UserMapper userMapper;
@Override
@Transactional
public User loadUserByUsername(String username) {
return userMapper.getByUsername(username);
}
...
}
创建 JWT 工具类
这个工具类主要负责 token 的生成,验证,从中取值。
@Component
public class JwtTokenProvider {
private static final long JWT_EXPIRATION = 5 * 60 * 1000L; // 五分钟过期
public static final String TOKEN_PREFIX = "Bearer "; // token 的开头字符串
private String jwtSecret = "XXX 密钥,打死也不能告诉别人";
...
}
生成 JWT:从以通过验证的认证对象中,获取用户信息,然后用指定加密方式,以及过期时间生成 token。这里简单的只加了用户名这一个信息到 token 中:
public String generateToken(Authentication authentication) {
User userPrincipal = (User) authentication.getPrincipal(); // 获取用户对象
Date expireDate = new Date(System.currentTimeMillis() + JWT_EXPIRATION); // 设置过期时间
try {
Algorithm algorithm = Algorithm.HMAC256(jwtSecret); // 指定加密方式
return JWT.create().withExpiresAt(expireDate).withClaim("username", userPrincipal.getUsername())
.sign(algorithm); // 签发 JWT
} catch (JWTCreationException jwtCreationException) {
return null;
}
}
验证 JWT:指定和签发相同的加密方式,验证这个 token 是否是本服务器签发,是否篡改,或者已过期。
public boolean validateToken(String authToken) {
try {
Algorithm algorithm = Algorithm.HMAC256(jwtSecret); // 和签发保持一致
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(authToken);
return true;
} catch (JWTVerificationException jwtVerificationException) {
return false;
}
}
获取荷载信息:从 token 的荷载部分里解析用户名信息,这部分是 md5 编码的,属于公开信息。
public String getUsernameFromJWT(String authToken) {
try {
DecodedJWT jwt = JWT.decode(authToken);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException jwtDecodeException) {
return null;
}
}
登录
登录部分需要创建三个文件:负责登录接口处理的 * ,登陆成功或者失败的处理类。
LoginFilter
Spring Security 默认自带表单登录,负责处理这个登录验证过程的过滤器叫“UsernamePasswordAuthenticationFilter”,不过它只支持表单传值,这里用自定义的类继承它,使其能够支持 JSON 传值,负责登录验证接口。
这个 * 只需要负责从请求中取值即可,验证工作 Spring Security 会帮我们处理好。
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("登录接口方法不支持: " + request.getMethod());
}
if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
Map<String, String> loginData = new HashMap<>();
try {
loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
} catch (IOException e) {
}
String username = loginData.get(getUsernameParameter());
String password = loginData.get(getPasswordParameter());
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username,
password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} else {
return super.attemptAuthentication(request, response);
}
}
}
LoginSuccessHandler
负责在登录成功后,生成 JWT 给前端。
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
ResponseData responseData = new ResponseData();
String token = jwtTokenProvider.generateToken(authentication);
responseData.setData(JwtTokenProvider.TOKEN_PREFIX + token);
response.setContentType("application/json;charset=utf-8");
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getWriter(), responseData);
}
}
LoginFailureHandler
验证失败后,返回错误信息。
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
ResponseData respBean = setResponseData(exception);
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getWriter(), respBean);
}
private ResponseData setResponseData(AuthenticationException exception) {
if (exception instanceof LockedException) {
return ResponseData.build("用户已被锁定");
} else if (exception instanceof CredentialsExpiredException) {
return ResponseData.build("密码已过期");
} else if (exception instanceof AccountExpiredException) {
return ResponseData.build("用户名已过期");
} else if (exception instanceof DisabledException) {
return ResponseData.build("账户不可用");
} else if (exception instanceof BadCredentialsException) {
return ResponseData.build("验证失败");
}
return ResponseData.build("登录失败,请联系管理员");
}
}
验证
在成功登陆后,前端在每次发起请求时携带签发的 JWT,让服务端能识别这是已登录的用户。
同时,如果未携带 JWT,或携带的 token 过期,或者非法,用单独的处理类返回错误信息。
JwtAuthenticationFilter
负责在每次请求中,解析请求头中的 JWT,从中取得用户信息,生成验证对象传递给下一个过滤器。
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
UsernamePasswordAuthenticationToken authentication = verifyToken(jwt);
if (authentication != null) {
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
}
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
logger.error("无法给 Security 上下文设置用户验证对象", e);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken == null || !bearerToken.startsWith(JwtTokenProvider.TOKEN_PREFIX)) {
logger.info("请求头不含 JWT token,调用下个过滤器");
return null;
}
return bearerToken.split(" ")[1].trim();
}
// 验证token,并生成认证后的token
private UsernamePasswordAuthenticationToken verifyToken(String token) {
if (token == null) {
return null;
}
// 认证失败,返回null
if (!jwtProvider.validateToken(token)) {
return null;
}
// 提取用户名
String username = jwtProvider.getUsernameFromJWT(token);
UserDetails userDetails = new User(username);
// 构建认证过的token
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
AuthenticationEntryPoint
这个类就比较简单,只是在验证不通过后,返回 401 响应,并记录错误信息。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
logger.error("验证为通过. 提示信息 - {}", authException.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}
集中配置
Spring Security 的功能是通过一系列的过滤器链实现的,而配置整个 Spring Security,只需要统一在一个类中配置即可。
现在咱们就创建这个类,继承自 “WebSecurityConfigurerAdapter”,把上面准备好的各种文件,一一配置进去。
首先是通过注解,设置打开全局的 Spring Security 功能,并通过依赖注入,引入刚刚创建的类。
@Configuration
@EnableWebSecurity
public class KanpmSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserDetailsService userDetailsService;
@Autowired
private JwtAuthenticationEntryPoint unauthorizedHandler;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public LoginFilter loginFilter(LoginSuccessHandler loginSuccessHandler, LoginFailureHandler loginFailureHandler)
throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationSuccessHandler(loginSuccessHandler);
loginFilter.setAuthenticationFailureHandler(loginFailureHandler);
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/auth/login");
return loginFilter;
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
...
}
接着,再把用户获取服务类和加密方式,配置到 Spring Security 中去,让它知道如何去验证登录。
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
最后,将JWT过滤器放入过滤器链中,用自定义的登录过滤器替代默认的 “UsernamePasswordAuthenticationFilter”,完成功能。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().anyRequest().authenticated().and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAt(loginFilter(new LoginSuccessHandler(), new LoginFailureHandler()),
UsernamePasswordAuthenticationFilter.class);
}
来源:https://juejin.cn/post/6925991649860386824