前后端分离中 Spring Security 3.0 的基本使用

环境说明:

  1. JDK:17
  2. Spring Boot: 3.4.0
  3. Spring Security: 6.4

0X00 概要

目标:

  1. 实现一个登录接口且配置该接口不需要认证;
  2. 实现 JWT 的认证;
  3. 自定义认证失败;

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 登录接口的实现

基本的登录接口的需要实现一下功能:

  1. 验证登录输入的验证码(如果有);
  2. 检查账号是否存在、密码是否正确,不存在(正确)则返回提示信息,如:用户名或密码错误;
  3. 检查账号的其他状态,返回对应的提示信息,如:账号已经锁定;
  4. 验证成功返回 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 都会响应:拒绝访问

相关推荐
hshpy1 小时前
why spring boot not load NacosConfigBootstrapConfiguration class
java·spring boot·后端
顽疲3 小时前
从零用java实现 小红书 springboot vue uniapp (3)详情页优化
java·vue.js·spring boot·uniapp
忘却的纪念3 小时前
基于SpringBoot的嗨玩旅游网站:一站式旅游信息服务平台的设计与实现
java·开发语言·spring boot·后端·毕业设计·旅游
开心工作室_kaic3 小时前
springboot422甘肃旅游服务平台代码-(论文+源码)_kaic
前端·spring boot·旅游
键盘不能没有CV键3 小时前
【AI】⭐️搭建一个简单的个人问答网页
前端·spring boot
武昌库里写JAVA4 小时前
Java面试之单例模式浅谈
数据结构·vue.js·spring boot·算法·课程设计
顽疲4 小时前
从零用java实现 小红书 springboot vue uniapp (2)主页优化
java·vue.js·spring boot
尘浮生5 小时前
Java项目实战II基于Java+Spring Boot+MySQL的社区帮扶对象管理系统的设计与实现(开发文档+数据库+源码)
java·开发语言·数据库·spring boot·mysql·微信小程序·小程序
测试工程师成长之路5 小时前
用Maven奏响Spring Boot项目开发乐章
java·spring boot·maven