网站首页 > 技术文章 正文
上一篇基于我们的博客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重命名为securityFilterChainForPage。securityFilterChainForPage没作任何改变,但是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里ApiAuthenticationEntryPoint和ApiAccessDeniedHandler也是新的针对API请求的新实例。(为此我们将原来针对页面请求的重命名为PageAuthenticationEntryPoint和PageAccessDeniedHandler以便区分)
- 我们关闭了csrf并设置session为无状态。针对API请求,这种设置是合理的。
接着看回ApiAuthenticationEntryPoint和ApiAccessDeniedHandler,这两个对象的逻辑没变,只是不再是返回页面而是返回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哈!
原创不易,码字辛苦,欢迎一键三连![谢谢]
- 上一篇: 权限想要细化到按钮,怎么做? 权限要点
- 下一篇: 终于有篇文章把后管权限系统设计讲清楚了
猜你喜欢
- 2024-09-29 Spring Security 自定义登录过程(非前后端分离版本)
- 2024-09-29 基于spring-security图形验证码、token验证
- 2024-09-29 10分钟上手SpringSecurity 框架(3)
- 2024-09-29 SpringBoot 实现自动登录时的安全风险控制
- 2024-09-29 springboot+security框架整合 springboot security详解
- 2024-09-29 时序图说明JWT用户认证及接口鉴权的细节
- 2024-09-29 Spring Security 整合OAuth2 springsecurity整合oauth2+jwt+vue
- 2024-09-29 有关springboot + spring security的思考
- 2024-09-29 SpringSecurity之自定义用户权限信息的存取
- 2024-09-29 你还不了解SpringSecurity吗?快来看看SpringSecurity实战总结
你 发表评论:
欢迎- 最近发表
- 标签列表
-
- oraclesql优化 (66)
- 类的加载机制 (75)
- feignclient (62)
- 一致性hash算法 (71)
- dockfile (66)
- 锁机制 (57)
- javaresponse (60)
- 查看hive版本 (59)
- phpworkerman (57)
- spark算子 (58)
- vue双向绑定的原理 (68)
- springbootget请求 (58)
- docker网络三种模式 (67)
- spring控制反转 (71)
- data:image/jpeg (69)
- base64 (69)
- java分页 (64)
- kibanadocker (60)
- qabstracttablemodel (62)
- java生成pdf文件 (69)
- deletelater (62)
- com.aspose.words (58)
- android.mk (62)
- qopengl (73)
- epoch_millis (61)
本文暂时没有评论,来添加一个吧(●'◡'●)