环境说明:
- JDK:17
- Spring Boot: 3.4.0
- Spring Security: 6.4
0X00 概要
目标:
- 实现一个登录接口且配置该接口不需要认证;
- 实现 JWT 的认证;
- 自定义认证失败;
0x01 基础配置
密码编码格式的设置
java
// cn.keeploving.demo.config.SecurityConfig
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
实现 UserDetailsService,该接口仅有一个方法:loadUserByUsername,是根据登录的凭证(如:用户名、手机号、邮箱等)获取用信息的。
java
// cn.keeploving.demo.config.SecurityConfig
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
// 这里使用 memory的,也可以自己继承进行实现
// 这里就意味着有一个账号test,密码是 abc@123
return new InMemoryUserDetailsManager(
new User("test",
passwordEncoder.encode("abc@123"),
new ArrayList<>()
)
);
}
0x02 登录接口的实现
基本的登录接口的需要实现一下功能:
- 验证登录输入的验证码(如果有);
- 检查账号是否存在、密码是否正确,不存在(正确)则返回提示信息,如:用户名或密码错误;
- 检查账号的其他状态,返回对应的提示信息,如:账号已经锁定;
- 验证成功返回 JWT。
使用 Spring security 认证时需要先注入 AuthenticationManager,代码如下:
java
// cn.keeploving.demo.config.SecurityConfig
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
然后使用 AuthenticationManager 的 authenticate 方法进行认证是会抛出异常,如下:
- BadCredentialsException:当登录凭证(如密码)错误时抛出的异常
- UsernameNotFoundException:未找到账号时抛出异常
- DisabledException:账号被禁用时抛出的异常
- LockedException:账号被锁定时抛出的异常
还有更多可能排除的异常,但是这几个异常是和账号信息有关的,它们都是 AccountStatusException 的子类,所以登录时可以捕获它们来返回登录失败信息,具体的代码如下:
java
// cn.keeploving.demo.controller.AuthController
@RestController
@RequestMapping("auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@PostMapping("/login")
public String login(@RequestBody LoginDto loginDto) {
// TODO:验证验证码
// 登录认证
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
try {
Authentication authenticate = authenticationManager.authenticate(token);
SecurityContextHolder.getContext().setAuthentication(authenticate);
// 生成JWT并返回
Date now = new Date();
return Jwts.builder().signWith(ValuePool.KEY)
// token 携带的信息
.claim("username", loginDto.getUsername())
// 设置过期时间
.setExpiration(new Date(now.getTime() + 8 * 60 * 60 * 1000))
.compact();
} catch (BadCredentialsException | UsernameNotFoundException exp) {
return "用户名或密码错误";
} catch (LockedException | AccountExpiredException exp) {
return "账号信息异常,请联系管理员处理";
}
}
}
此时请求 /auth/login
接口会返回 401。Spring security 默认会拒绝非 /login
的请求并跳转到 /login
页面,可以在该页面使用配置的 test
账号进行登录;
前后端分离需要关闭 form 登录并且开放 /auth/login
接口,配置如下:
java
// cn.keeploving.demo.config.SecurityConfig
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(registry -> {
// 允许 /auth/login 接口访问
registry.requestMatchers("/auth/login").permitAll();
// 其他的需要认证
registry.anyRequest().authenticated();
})
// 关闭 form
.formLogin(AbstractHttpConfigurer::disable)
.build();
}
配置 /auth/login
不需要认证就可以访问,其他的请求需要认证才可以访问
0x03 认证 JWT
通过 Filter 对请求进行拦截获取 JWT,获取到 JWT 后要对 JWT 进行解析、判断,符合要求后就要生成 Spring Security 需要的 Token 信息。具体实现的代码如下:
java
// cn.keeploving.demo.filter.JwtFilter
public class JwtFilter extends HttpFilter {
private final JwtParser build = Jwts.parserBuilder().setSigningKey(ValuePool.KEY).build();
private static final Logger LOG = LoggerFactory.getLogger(JwtFilter.class);
private final UserDetailsService userDetailsService;
public JwtFilter(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = doGetJwtToken(request);
if (token == null) {
// 交给后续处理
chain.doFilter(request, response);
return;
}
try {
// 解析 TOKEN
Jws<Claims> claimsJws = build.parseClaimsJws(token);
String username = claimsJws.getBody().get("username", String.class);
// 检验 TOKEN
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails == null) {
throw new BadCredentialsException("异常的认证信息");
}
// 生成上下文使用的 TOKEN
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, "", userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} catch (Exception exp) {
LOG.error("JWT解析失败: ", exp);
}
chain.doFilter(request, response);
}
private String doGetJwtToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (authorization == null) {
return null;
}
return authorization.replace("Bearer ", "");
}
}
开发完 Filter 后还需要配置 Filter 的顺序,具体配置如下:
java
// cn.keeploving.demo.config.SecurityConfig
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, UserDetailsService service) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(registry -> {
registry.requestMatchers("/auth/login").permitAll();
registry.anyRequest().authenticated();
})
.formLogin(AbstractHttpConfigurer::disable)
// 添加 Filter
.addFilterBefore(new JwtFilter(service), UsernamePasswordAuthenticationFilter.class)
.build();
}
开发一个 self 接口获取用户登录成功之后的信息:
java
// cn.keeploving.demo.controller.AuthController
@GetMapping("/self")
public Object self() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication.getPrincipal();
return principal;
}
携带登录成功后乡响应的 jwt 即可拿到登录用户的 username。
0x04 认证失败的处理
默认认证失败后响应空白内容并且将响应状态设置为 403,部分项目会有自己的定制化需求,可以配置 exceptionHandling
进行设置。
实现 AccessDeniedHandler 和 AuthenticationEntryPoint 就可以实现请求被拒绝、认证异常的响应,具体代码如下:
java
public class CustomAccessDeniedHandler implements AccessDeniedHandler, AuthenticationEntryPoint {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
doErrorResp(response);
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
doErrorResp(response);
}
private static void doErrorResp(HttpServletResponse response) throws IOException {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json");
response.setHeader("content-type", "application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("拒绝访问");
response.getWriter().flush();
}
配置如下:
java
// cn.keeploving.demo.config.SecurityConfig
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, UserDetailsService service) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(registry -> {
registry.requestMatchers("/auth/login").permitAll();
registry.anyRequest().authenticated();
})
.exceptionHandling(config -> {
CustomAccessDeniedHandler handler = new CustomAccessDeniedHandler();
config.accessDeniedHandler(handler);
config.authenticationEntryPoint(handler);
})
.formLogin(AbstractHttpConfigurer::disable)
.addFilterBefore(new JwtFilter(service), UsernamePasswordAuthenticationFilter.class)
.build();
}
请求 /auth/self
不携带 JWT 或者携带错误的 JWT 都会响应:拒绝访问