前后端分离中 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 都会响应:拒绝访问

相关推荐
摇滚侠1 小时前
Spring Boot 3零基础教程,整合Redis,笔记12
spring boot·redis·笔记
荣淘淘1 小时前
互联网大厂Java求职面试全景实战解析(涵盖Spring Boot、微服务及云原生技术)
java·spring boot·redis·jwt·cloud native·microservices·interview
吃饭最爱1 小时前
spring高级知识概览
spring boot
舒克日记1 小时前
基于springboot针对老年人的景区订票系统
java·spring boot·后端
沐雨橙风ιε2 小时前
Spring Boot整合Apache Shiro权限认证框架(实战篇)
java·spring boot·后端·apache shiro
一线大码2 小时前
开发 Java 项目时的命名规范
java·spring boot·后端
本就一无所有 何惧重新开始3 小时前
Redis技术应用
java·数据库·spring boot·redis·后端·缓存
低音钢琴3 小时前
【SpringBoot从初学者到专家的成长11】Spring Boot中的application.properties与application.yml详解
java·spring boot·后端
不太会写3 小时前
又开始了 小程序定制
vue.js·spring boot·python·小程序