计算机系统应用教程网站

网站首页 > 技术文章 正文

在实例中掌握Spring Security 6(四)

btikc 2024-09-29 09:56:00 技术文章 14 ℃ 0 评论

上一篇基于我们的博客Demo讲完了Security在带页面交互的应用中的运用。假如有个外部系统想要对接我们的博客,那么最好的方式就是我们提供一份API供外部系统调用,同时对API的调用也同样要符合安全策略。按照整体设计,我们需要实现下面的API:

API的设计是有规律的,全部以/api开头,除了四个公开的游客API,博主的API二级目录是/blogger,管理员的是/admin。API都以自定义的JSON结构为响应,JSON结构的骨架:

{"code":200,"message":"success","data":null}

为此我们创建ApiResponse来表示:

package scau.pdc.demo.security.controller;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ApiResponse<T> {

    private static final Integer SUCCESS_CODE = 200;
    private static final String SUCCESS_MESSAGE = "success";

    private Integer code;
    private String message;
    private T data;

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(SUCCESS_CODE, SUCCESS_MESSAGE, data);
    }

    public static ApiResponse<Void> failure(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }
}

API请求和页面请求还是有些区别的,API的登录所返回的token并不强制存放在cookie而是交由客户端自行保管,需要授权的API的调用需要将token携带在header的Authentication字段上,API请求不需要考虑CSRF攻击,是无状态session的方式。

我们可以在不改动原有安全配置的基础上再叠加一个API专属的安全配置,然后让Security根据请求的URL前缀来匹配到正确的配置,进入到专属的过滤器链。

SecurityConfig添加新的SecurityFilterChain配置Bean:securityFilterChainForApi,为了命名上更清晰区分,原来的securityFilterChain重命名为securityFilterChainForPagesecurityFilterChainForPage没作任何改变,但是securityFilterChainForApi有不少改变:

@Bean
@Order(1)
public SecurityFilterChain securityFilterChainForApi(HttpSecurity http) throws Exception {
    return http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth ->
                    auth.requestMatchers("/api/auth/**", "/api/article/**").permitAll()
                            .requestMatchers("/api/blogger/**").hasRole("BLOGGER")
                            .requestMatchers("/api/admin/**").hasRole("ADMIN")
                            .anyRequest().denyAll()
            )
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .addFilterBefore(apiJwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(exception -> {
                exception.authenticationEntryPoint(new ApiAuthenticationEntryPoint());
                exception.accessDeniedHandler(new ApiAccessDeniedHandler());
            })
            .requestCache(RequestCacheConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .build();
}

@Bean
@Order(2)
public SecurityFilterChain securityFilterChainForPage(HttpSecurity http) throws Exception {
    return http
            .authorizeHttpRequests(auth ->
                    auth.requestMatchers("/", "/index", "/detail", "/login", "/doLogin", "/doLogout", "/error/**").permitAll()
                            .requestMatchers("/blogger/**").hasRole("BLOGGER")
                            .requestMatchers("/admin/**").hasRole("ADMIN")
                            .anyRequest().denyAll()
            )
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .addFilterBefore(pageJwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(exception -> {
                exception.authenticationEntryPoint(new PageAuthenticationEntryPoint());
                exception.accessDeniedHandler(new PageAccessDeniedHandler());
            })
            .requestCache(RequestCacheConfigurer::disable)
            .build();
}
  • 增加@Order注解且值为1,securityFilterChainForPage也加入此注解,值为2,意思是在对请求进行匹配时,优先匹配order值小的,若匹配到,就不会继续匹配其它SecurityFilterChain了,这里我们先匹配API请求再匹配页面请求。
  • .securityMatcher("/api/**")这项表示所匹配的前置URL格式,/api开头的请求都走此SecurityFilterChain。注意与authorizeHttpRequests里的URL配置进行区分,authorizeHttpRequests的职责是对URL的权限进行配置,而securityMatcher(也包括securityMatchers)是对URL进行匹配。
  • 针对API请求的各种requestMatchers配置。
  • 针对API请求的JWT token检验过滤器ApiJwtTokenFilter。(原来的JwtTokenFilter重命名为PageJwtTokenFilter
  • exceptionHandling里ApiAuthenticationEntryPointApiAccessDeniedHandler也是新的针对API请求的新实例。(为此我们将原来针对页面请求的重命名为PageAuthenticationEntryPoint和PageAccessDeniedHandler以便区分)
  • 我们关闭了csrf并设置session为无状态。针对API请求,这种设置是合理的。

接着看回ApiAuthenticationEntryPointApiAccessDeniedHandler,这两个对象的逻辑没变,只是不再是返回页面而是返回ApiResponse的JSON序列化数据。

package scau.pdc.demo.security.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Slf4j
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        // 如果响应已经提交,则不执行后续操作
        if (response.isCommitted()) {
            log.trace("Did not write to response since already committed");
            return;
        }

        log.info("Response with status code 401");

        // 设置响应状态码为401
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        // 设置响应内容类型为json
        response.setContentType("application/json");
        // 设置响应内容编码为utf-8
        response.setCharacterEncoding("UTF-8");
        // 创建JSON字符串并写入响应输出流中
        Map<String, Object> responseMap = new HashMap<>();
        responseMap.put("code", HttpStatus.UNAUTHORIZED.value());
        responseMap.put("message", authException.getMessage());
        responseMap.put("data", null);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.writeValue(response.getWriter(), responseMap);
    }

}
package scau.pdc.demo.security.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Slf4j
public class ApiAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 如果响应已经提交,则不执行后续操作
        if (response.isCommitted()) {
            log.trace("Did not write to response since already committed");
            return;
        }

        log.info("Response with status code 403");

        // 设置响应状态码为403
        response.setStatus(HttpStatus.FORBIDDEN.value());
        // 设置响应内容类型为json
        response.setContentType("application/json");
        // 设置响应内容编码为utf-8
        response.setCharacterEncoding("UTF-8");
        // 创建JSON字符串并写入响应输出流中
        Map<String, Object> responseMap = new HashMap<>();
        responseMap.put("code", HttpStatus.FORBIDDEN.value());
        responseMap.put("message", accessDeniedException.getMessage());
        responseMap.put("data", null);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.writeValue(response.getWriter(), responseMap);
    }
}

这样401和403的异常我们就搞定了,但别忘记还有个ErrorController,当前的ErrorController是这样的:

@ExceptionHandler(Throwable.class)
public String exception(final Throwable throwable,
                        final HttpServletRequest request,
                        final HttpServletResponse response,
                        final Model model) {
    String errorMessage = (throwable != null ? throwable.getMessage() : "Unknown error");
    log.error("An error occurred:{}", errorMessage);
    if (throwable instanceof NoResourceFoundException) {
        response.setStatus(HttpStatus.NOT_FOUND.value());
    } else {
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
    }
    model.addAttribute("errorCode", response.getStatus());
    model.addAttribute("errorMessage", errorMessage);
    return "error";
}

由于ErrorController现在是兜底捕捉了一切剩余异常(除了401和403的异常),因此没太多好办法说为API请求创建个单独的ErrorController,我们只好在一个ErrorController里区分页面和API两种请求的错误响应。方法就是根据request的URL来切分。我们改造后的代码是:

@ExceptionHandler(Throwable.class)
public String exception(final Throwable throwable,
                        final HttpServletRequest request,
                        final HttpServletResponse response,
                        final Model model) {
    String errorMessage = (throwable != null ? throwable.getMessage() : "Unknown error");
    log.error("An error occurred:{}", errorMessage);
    if (throwable instanceof NoResourceFoundException) {
        response.setStatus(HttpStatus.NOT_FOUND.value());
    } else {
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
    }
    // 获取request的请求路径
    String requestUrl = request.getRequestURI();
    // 如果请求路径以"/api/"开头
    if (requestUrl.startsWith("/api/")) {
        // 设置响应内容类型为json
        response.setContentType("application/json");
        // 设置响应内容编码为utf-8
        response.setCharacterEncoding("UTF-8");
        // 创建JSON字符串并写入响应输出流中
        Map<String, Object> responseMap = new HashMap<>();
        responseMap.put("code", response.getStatus());
        responseMap.put("message", errorMessage);
        responseMap.put("data", null);
        ObjectMapper objectMapper = new ObjectMapper();
        try {
            objectMapper.writeValue(response.getWriter(), responseMap);
        } catch (IOException e) {
            return null;
        }
        return null;
    } else {
        model.addAttribute("errorCode", response.getStatus());
        model.addAttribute("errorMessage", errorMessage);
        return "error";
    }
}

最后是ApiJwtTokenFilter。原先基于页面请求的token是放在cookie里的,针对API请求要调整为从请求header里获取。

package scau.pdc.demo.security.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import scau.pdc.demo.security.service.UserService;
import scau.pdc.demo.security.util.JwtUtil;

import java.io.IOException;

@RequiredArgsConstructor
@Slf4j
@Component
public class ApiJwtTokenFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION = "Authorization";
    private static final String PREFIX = "Bearer ";

    private final UserService userService;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader(AUTHORIZATION);
        // 如果没有授权头或不是以Bearer开头,直接放行
        if (!StringUtils.hasText(authHeader) || !authHeader.startsWith(PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(PREFIX.length());
        // 从token中提取subject,这里的subject就是用户名
        String subject = JwtUtil.getSubjectFromJwtToken(token);

        // 用户名无效或安全上下文已有身份验证,则直接放行
        if (!StringUtils.hasText(subject) || SecurityContextHolder.getContext().getAuthentication() != null) {
            filterChain.doFilter(request, response);
            return;
        }

        // token校验
        UserDetails userDetails = userService.loadUserByUsername(subject);
        if (userDetails != null && JwtUtil.verifyJwtToken(token, userDetails.getUsername())) {
            // 创建并设置认证信息
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities()
            );
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 继续过滤链
        filterChain.doFilter(request, response);
    }

}

到此,配置的东西就全部搞定了。接下来就全是具体API的开发了。


游客API

创建GuestApiController处理游客的API请求。

package scau.pdc.demo.security.controller;

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import scau.pdc.demo.security.controller.request.api.LoginReq;
import scau.pdc.demo.security.controller.response.api.ArticleDetailResp;
import scau.pdc.demo.security.entity.Article;
import scau.pdc.demo.security.service.ArticleService;
import scau.pdc.demo.security.service.AuthService;
import scau.pdc.demo.security.service.UserService;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class GuestApiController {

    private final ArticleService articleService;
    private final UserService userService;
    private final AuthService authService;

    @GetMapping("/article/list")
    public ApiResponse<List<Article>> articleList() {
        List<Article> articles = articleService.findAllArticles();
        return ApiResponse.success(articles);
    }

    @GetMapping("/article/detail")
    public ApiResponse<ArticleDetailResp> articleDetail(@RequestParam Long id) throws Exception {
        Article article = articleService.findArticleById(id);
        String username = userService.findUserById(article.getUserId()).getUsername();
        ArticleDetailResp resp = ArticleDetailResp.of(article, username);
        return ApiResponse.success(resp);
    }

    @PostMapping("/auth/login")
    public ApiResponse<String> authLogin(@RequestBody LoginReq req) throws Exception {
        log.info("username: {}, password: {}", req.getUsername(), req.getPassword());
        String token = authService.apiLogin(req);
        return ApiResponse.success(token);
    }

    @PostMapping("/auth/logout")
    public ApiResponse<Void> authLogout(RedirectAttributes redirectAttributes,
                                        HttpServletResponse response) throws Exception {
        log.info("logout");
        return ApiResponse.success(null);
    }

}

这个控制器里涉及到的Service相关方法大多是复用了之前章节的方法。需要说下的是登录和登出的方法,针对API请求的登录方法是验证账号合法性后生成token并返回,而登出的方法当前什么事都没干,工作在于客户端,客户端需要清空其存储的token,如果为了加强安全性,防止token被外部盗用,一种做法是在登出接口由服务端记录token进黑名单,然后在JwtTokenFilter里总是判断客户端的token是否在黑名单里,是就拒绝访问。

AuthServiceImpl

@NonNull
@Override
public String apiLogin(@NonNull LoginReq req) {
    // 创建一个用户名密码认证令牌
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword());
    // 使用认证管理器进行认证
    Authentication authenticated = authenticationManager.authenticate(authenticationToken);
    // 将认证结果设置到安全上下文中
    SecurityContextHolder.getContext().setAuthentication(authenticated);
    // 从认证结果中获取用户信息
    User user = (User) authenticated.getPrincipal();
    // 使用用户的用户名生成JWT令牌
    return JwtUtil.generateJwtToken(user.getUsername());
}


博主API

博主的API是对文章的CRUD,除了需要调整下控制器,其它核心方法都可以复用。

BloggerApiController

package scau.pdc.demo.security.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import scau.pdc.demo.security.controller.request.api.ArticlePostOrUpdateReq;
import scau.pdc.demo.security.entity.Article;
import scau.pdc.demo.security.service.ArticleService;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/blogger")
public class BloggerApiController {

    private final ArticleService articleService;

    @PostMapping("/article/post")
    public ApiResponse<Article> articlePost(@RequestBody ArticlePostOrUpdateReq req) {
        Article article = new Article();
        article.setTitle(req.getTitle());
        article.setContent(req.getContent());
        article = articleService.saveArticleOfCurrentUser(article);
        return ApiResponse.success(article);
    }

    @GetMapping("/article/list")
    public ApiResponse<List<Article>> articleList() {
        List<Article> articles = articleService.findArticlesOfCurrentUser();
        return ApiResponse.success(articles);
    }

    @PostMapping("/article/update")
    public ApiResponse<Void> articleUpdate(@RequestBody ArticlePostOrUpdateReq req) {
        Article article = new Article();
        article.setId(req.getId());
        article.setTitle(req.getTitle());
        article.setContent(req.getContent());
        articleService.updateArticleOfCurrentUser(article);
        return ApiResponse.success(null);
    }

    @PostMapping("/article/delete")
    public ApiResponse<Void> articleDelete(@RequestParam String id) {
        articleService.deleteArticleOfCurrentUser(Long.parseLong(id));
        return ApiResponse.success(null);
    }

}


管理员API

原理逻辑同上述的博主API,创建专属的AdminApiController

package scau.pdc.demo.security.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import scau.pdc.demo.security.controller.request.api.BloggerCreateReq;
import scau.pdc.demo.security.entity.Article;
import scau.pdc.demo.security.entity.User;
import scau.pdc.demo.security.service.ArticleService;
import scau.pdc.demo.security.service.UserService;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/admin")
public class AdminApiController {

    private final UserService userService;
    private final ArticleService articleService;

    @PostMapping("/blogger/create")
    public ApiResponse<User> bloggerCreate(@RequestBody BloggerCreateReq req) {
        User user = new User();
        user.setUsername(req.getUsername());
        user.setPassword(req.getPassword());
        return ApiResponse.success(userService.saveBlogger(user));
    }

    @GetMapping("/blogger/list")
    public ApiResponse<List<User>> bloggerList() {
        return ApiResponse.success(userService.findAllBloggers());
    }

    @PostMapping("/blogger/delete")
    public ApiResponse<Void> bloggerDelete(@RequestParam String id) {
        userService.deleteBlogger(Long.parseLong(id));
        return ApiResponse.success(null);
    }

    @GetMapping("/article/list")
    public ApiResponse<List<Article>> articleList() {
        return ApiResponse.success(articleService.findAllArticles());
    }

    @PostMapping("/article/delete")
    public ApiResponse<Void> articleDelete(@RequestParam String id) {
        articleService.deleteArticle(Long.parseLong(id));
        return ApiResponse.success(null);
    }

}


尾声

终于所有的东西都开发完毕!通过本次专题教程,旨在加深对Security 6的理解,避免踩上那些过时的坑。总的来说,Security 6是一次比较清晰的改造,把旧版本那些令人困惑的地方都统一优化,而且提升了整体的扩展性和一致性。

最终的Demo工程我放在scaupdc/spring-security-6 · GitHub上了,欢迎下载,最好能给个Star哈!

原创不易,码字辛苦,欢迎一键三连![谢谢]

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表