“手上过”是方言,跟那句“Talk is cheap. Show me the code.”的意思差不多。
本文尝试着用Spring Boot 3.0搭建了一个基本的单体应用,Spring Security结合Json Web Token进行用户认证和授权,Caffeine缓存结合OncePerRequestFilter过滤器来记录访问日志,另外统一了Controller的返回和全局异常处理,并给出了一些扩展点的思路。
希望在后续的学习中可以用这个项目来做验证和测试,也可以作为一些简单项目的脚手架,稍微扩展一下就可以把重点放在业务实现上。
已开源: GitHub-Practice Spring Boot 3.0-Single 上一篇: Spring Boot 3.0 手上过 - GraalVM原生镜像
从配置下手 笔者个人的经验,要搞清楚一个Spring Boot项目,最好的方式就是从配置下手去了解项目的整体架构。
pom.xml Maven项目的依赖配置在pom.xml文件,在 start.spring.io 上初始化时,选择的Spring Boot版本和依赖如下:
后来增加了Caffeine缓存和JWT(Json Web Token)的依赖:
pom.xml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-cache</artifactId > </dependency > <dependency > <groupId > com.github.ben-manes.caffeine</groupId > <artifactId > caffeine</artifactId > </dependency > <dependency > <groupId > com.auth0</groupId > <artifactId > java-jwt</artifactId > <version > 4.3.0</version > </dependency >
在pom.xml里把Initializr添加的hibernate-enhance-maven-plugin给注释掉了,解决下述异常:
1 2 3 4 5 6 [ERROR] Failed to execute goal org.hibernate.orm.tooling:hibernate-enhance-maven-plugin:6.1.6.Final:enhance (enhance) on project single: Unable to enhance class: SimpleUser.class: Failed to enhance class top.xiaoboey.practice.spring .boot3.single.entity.SimpleUser: A wildcard does not represent an erasable type: ? extends org.springframework.security.core.GrantedAuthority -> [Help 1] [ERROR] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. [ERROR] Re-run Maven using the -X switch to enable full debug logging. [ERROR]
这个问题没去深究,简化处理。
application.yml Spring Boot配置文件不单是application.yml,但application.yml是配置的入口,也是主战场。resources文件夹下,差不多都是配置相关的文件。
端口和路径 application.yml 1 2 3 4 server: port: 8080 servlet: context-path: /single
用8080端口提供服务,应用的上下文路径(url的前缀)为single,所以请求Controller里的方法时,URL地址的前缀都是http://localhost:8080/single/。
Actuator application.yml 1 2 3 4 5 6 7 8 9 10 server.shutdown: graceful management: server: port: 7080 endpoints: web.exposure.include: ["info" ,"health" ,"metrics" ,"shutdown" ] enabled-by-default: true endpoint: shutdown.enabled: true
把Actuator的端口另外配置为7080,跟应用端口分开,这样就可以不对外开放,只限制在本机访问,提高安全性。shutdown: graceful
结合shutdown.enabled: true
来实现优雅下线,向 http://localhost:7080/actuator/shutdown
发送POST请求即可。
JPA application.yml 1 2 3 4 5 6 7 8 9 spring: jpa: hibernate: ddl-auto: update show-sql: false database: h2 open-in-view: false properties: jakarta.persistence.sharedCache.mode: UNSPECIFIED
database: h2
,使用文件数据库h2。ddl-auto: update
,Hibernate会根据项目里的实体,自动建表或者修改表结构。生产环境建议使用验证模式,ddl-auto: validate
,进行表结构的核实验证,在不匹配的时候停止运行,而不是自动去修改调整。生产环境的升级,应该有专门的数据迁移转换方案。jakarta.persistence.sharedCache.mode: UNSPECIFIED
,解决Hibernate的过时警告: Encountered deprecated setting [javax.persistence.sharedCache.mode], use [jakarta.persistence.sharedCache.mode] instead
数据库h2 为了把代码Clone下来之后快速运行这个Single项目,我们选择文件数据库H2,相关参数放在application-datasource.yml里,并在application.yml里通过 spring.profiles.include: datasource
把数据库的配置包含进来。
application-datasource.yml 1 2 3 4 spring: datasource: driver-class-name: org.h2.Driver url: jdbc:h2:file:D:\\temp\\local-db\\h2-single\\testdb
修改一下application-datasource.yml就可以跑起来了。
初始化数据 使用@PostConstruct
注解,在应用启动后进行数据库的初始化,向用户表(simple_user)和权限表(simple_authority)里添加了用户及权限:
账号
密码
角色
权限
admin
admin
ROLE_ADMIN
worker
worker
ROLE_WORKER
DIGGING
有ROLE_前缀的是角色,其他为权限。
SingleApplication.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @PostConstruct public void initialize () { final long adminRoleId = 1L ; final long workerRoleId = 2L ; final long diggingAuthorityId = 200L ; if (!simpleAuthorityRepository.findById(adminRoleId).isPresent()) { simpleAuthorityRepository.save(new SimpleAuthority (adminRoleId, "ROLE_ADMIN" )); simpleAuthorityRepository.save(new SimpleAuthority (workerRoleId, "ROLE_WORKER" )); simpleAuthorityRepository.save(new SimpleAuthority (diggingAuthorityId, "DIGGING" )); } SimpleUser admin = simpleUserRepository.findByUsername("admin" ); if (admin == null ) { admin = new SimpleUser (adminRoleId, "admin" , passwordEncoder.encode("admin" )); admin.setAuthorities(simpleAuthorityRepository.findAllById(Arrays.asList(adminRoleId))); simpleUserRepository.save(admin); SimpleUser worker = new SimpleUser (workerRoleId, "worker" , passwordEncoder.encode("worker" )); worker.setAuthorities(simpleAuthorityRepository.findAllById(Arrays.asList(workerRoleId, diggingAuthorityId))); simpleUserRepository.save(worker); } }
用户和权限的关联是在SimpleUser里,用注解的方式配置了一个多对多的关系,存放在user_authority
表里:
SimpleUser.java 1 2 3 4 5 6 7 8 9 10 @Entity @Table(name = "simple_user") public class SimpleUser implements UserDetails , Serializable { ... @ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) @JoinTable(name = "user_authority", joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "authority_id", referencedColumnName = "id", table = "simple_authority")) private Collection<SimpleAuthority> authorities; ... }
Caffeine缓存 使用Spring Boot内置的缓存:Caffeine,配置用户信息的缓存名称为simple_user,加速Security在授权时对用户信息的访问(非常频繁):
application.yml 1 2 3 4 5 6 spring: cache: type: caffeine cache-names: simple_user caffeine: spec: maximumSize=2000,expireAfterAccess=10m,recordStats
application.yml是通用配置,个性化配置在CaffeineConfiguration里实现:
CaffeineConfiguration.java 1 2 caches.add(new CaffeineCache(...)); cacheManager.setCaches(caches);
RSA密钥 资源文件夹(resources)下的jwt_rsa.key和jwt_rsa.pub是用openssl生成的RSA密钥,用来做JWT的签名和验证,生成脚本如下:
使用openssl生成RSA密钥 1 2 3 4 5 6 7 8 $ openssl genrsa -out single_jwt_key.pem 2048 $ openssl pkcs8 -topk8 -inform PEM -outform DER -in single_jwt_key.pem -out jwt_rsa.key -nocrypt $ penssl rsa -in single_jwt_key.pem -pubout -outform DER -out jwt_rsa.pub
config Java包top.xiaoboey.practice.single.config
里的类,主要是本项目的一些个性化配置。
Spring Security 增加Spring Security的依赖之后,Spring Boot就会自动配置并生效,拦截所有访问。这里把应用监控的/actuator和用户登录的/user路径下的访问都放开。
SecurityConfig.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Bean public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { ... http .csrf().disable() .formLogin().disable() .logout().disable() .authorizeHttpRequests() .requestMatchers(HttpMethod.OPTIONS).permitAll() .requestMatchers("/actuator/**" ).permitAll() .requestMatchers("/user/**" ).permitAll() .anyRequest().authenticated() ...; return http.build(); }
其他配置 缓存CaffeineConfiguration.java
在前面已经讲过了,其他都是用户权限和访问日志的配置,另外开章节来详述。
Security和JWT 虽然Single项目我们的定位是单体应用,但是使用JWT(Json Web Token),结合Spring Security的认证和授权机制,可以让Single进行分布式部署,跑多个实例。
JWT的核心是认证方在核实用户身份后(本项目是用账号和密码),使用私钥签发一个包含用户信息的JSON令牌,然后在需要授权的地方用公钥验证令牌,获取用户信息并进行授权。
这里把本项目JWT和Spring Security进行用户认证授权的时序图画一下,方便后续代码的理解:
Request AuthAndLogFilter Spring Security Controller Request Service that Permit All Request Service that authentication required POST without token: /user/login No token, skip authentication No AuthenticationToken Authorization Permit All Requests: /user/** UserController.login() token POST with token: /business/someInfo Verify token, complete authentication With AuthenticationToken Authorization Allow access: /business/someInfo BusinessController.someInfo() Response of /business/someInfo Request AuthAndLogFilter Spring Security Controller
加载密钥 密钥的读取加载,以私钥为例:
JsonWebTokenConfig.java 1 2 3 4 5 6 7 8 9 10 @Bean public RSAPrivateKey jwtPrivateKey () throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { ClassPathResource resource = new ClassPathResource ("jwt_rsa.key" ); byte [] keyBytes; try (InputStream inputStream = resource.getInputStream()) { keyBytes = inputStream.readAllBytes(); } PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec (keyBytes); return (RSAPrivateKey) KeyFactory.getInstance("RSA" ).generatePrivate(spec); }
生成令牌 登录时验证用户名和密码之后,生成包含用户信息(主要是权限)的JWT:
SimpleUserService.java 1 2 3 4 5 6 7 8 String token = JWT.create() .withIssuer(issuer) .withSubject(user.getUsername()) .withJWTId(UUID.randomUUID().toString()) .withClaim(JsonWebTokenConfig.CLAIM_AUTHORITIES, authorityList) .withIssuedAt(Date.from(Instant.now())) .withExpiresAt(Date.from(expiresAt)) .sign(Algorithm.RSA256(null , rsaPrivateKey));
JWTId使用随机UUID来标记同一个账号的不同会话,Claim是用KeyValue的方式来包含自定义的数据,IssuedAt表示发布时间,ExpiresAt是过期时间,然后整个Json格式的Token用私钥签名。
验证和解码令牌 自定义过滤器从Header里获取令牌,验证其有效性,解码后生成UsernamePasswordAuthenticationToken供后续的授权用。
AuthAndLogFilter.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private UsernamePasswordAuthenticationToken getAuthentication (HttpServletRequest request) { String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith("Bearer " )) { String token = authorizationHeader.substring(7 ); DecodedJWT jwt = jwtVerifier.verify(token); String jwtId = jwt.getClaim(JsonWebTokenConfig.CLAIM_JTI).asString(); String[] authorities = jwt.getClaim(JsonWebTokenConfig.CLAIM_AUTHORITIES).asArray(String.class); List<String> authorityList = new ArrayList <>(); authorityList.addAll(Arrays.asList(authorities)); String username = jwt.getSubject(); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken ( username, jwtId, authorityList.stream().map(authority -> new SimpleGrantedAuthority (authority)).collect(Collectors.toList()) ); return authentication; } return null ; }
AuthAndLogFilter
这个过滤器是扩展的OncePerRequestFilter,就如OncePerRequestFilter名称的字面意思一样,这个过滤器在每个请求周期里都会执行一次。本项目利用它来完成JWT的处理和访问日志记录。
在Security的配置里,把自定义的AuthAndLogFilter过滤器设置为先于Security执行:
SecurityConfig.java 1 2 3 4 @Bean public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { http.addFilterBefore(authAndLogFilter, UsernamePasswordAuthenticationFilter.class); ...
在AuthAndLogFilter完成了令牌的验证和解码,并将认证信息放入Security Context,供后续的Security授权使用
AuthAndLogFilter.java 1 2 3 4 5 6 7 ... UsernamePasswordAuthenticationToken authentication = getAuthentication(request);if (authentication != null ) { SecurityContextHolder.getContext().setAuthentication(authentication); ... } ...
记录日志 应用的访问日志,有很多方式可以记录,像nginx这类网关类应用,在流量入口记录一些基本的access log,而Spring Boot的Actuator也提供了接口可以记录。本项目结合Filter和AOP(面向切面编程),自定义实现了访问日志的记录,主要是为了记录更详细的业务处理信息。
还是先上个图,看着有点复杂。主要是因为日志记录时有两个位置的异常处理,直接跳出了请求链路,一个是SecurityCofnig里的异常处理,另外一个是Controller的全局异常处理。
Request AuthAndLogFilter Spring Security Controller alt [is Handle OK:] [is Handle FAIL:] alt [is Authorization Success:] [is Authorization Exception:] Call Some API Initialize SimpleLog and Cache filterChain.doFilter() Authorization is Success? Permit Handle Request is OK? ApiResult(OK) Save SimpleLog to Database ApiResult(OK) GlobalExceptionController.exceptionHandler() Save SimpleLog to Database ApiResult(FAIL) Record Exception SimpleLog Save SimpleLog to Database ApiResult with Exception Info Request AuthAndLogFilter Spring Security Controller
AuthAndLogFilter 在AuthAndLogFilter里,除了JWT令牌的验证和认证,也包含了访问日志的自定义处理。
基于OncePerRequestFilter的特性,以及AuthAndLogFilter先于Security执行,所以非常适合在这里进行访问日志的初始化。每个请求进来后,都默认记录了HttpServletRequest的id,uri和参数等,然后放入缓存,待后续处理完成后再计算执行时间,写入数据库。
SimpleLog.java 1 2 3 4 5 6 public SimpleLog (HttpServletRequest request) { this .setRequestId(request.getRequestId()); this .setUri(request.getRequestURI()); this .setAccessTime(Timestamp.from(Instant.now())); this .setDurationMs(0L ); }
SimpleLogService.java 1 2 3 4 5 6 7 8 9 10 11 12 @CacheEvict(key = "#simpleLog.getRequestId()") @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class) public void saveThenClean (SimpleLog simpleLog) { Instant now = Instant.now(); Duration duration = Duration.between(simpleLog.getAccessTime().toInstant(), now); simpleLog.setDurationMs(duration.toMillis()); simpleLog.setLogTime(Timestamp.from(now)); SimpleLog target = new SimpleLog (); BeanUtils.copyProperties(simpleLog, target); simpleLogRepository.save(target); }
AOP记录业务日志 自定义注解OperationLog:
OperationLog.java 1 2 3 4 5 6 7 8 9 10 11 12 13 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface OperationLog { @AliasFor("operation") String value () default "" ; @AliasFor("value") String operation () default "" ; boolean ignoreParams () default false ; boolean ignoreResult () default true ; }
利用AOP面向切面编程机制,收集方法执行的相关信息并进行记录:
OperationLogAspect.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Aspect @Component public class OperationLogAspect { ... @Before(value = "operateLogPointCut()") public void handleBefore (JoinPoint joinPoint) { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method method = methodSignature.getMethod(); OperationLog operationLog = method.getAnnotation(OperationLog.class); if (operationLog != null ) { HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest(); String requestId = request.getRequestId(); SimpleLog dto = simpleLogService.getFromCache(requestId); String operation = operationLog.operation(); if (StringUtils.hasText(operation)) { dto.setOperation(operation); } if (operationLog.ignoreParams()) { dto.setQueryParams(null ); } dto.setStatusCode(HttpServletResponse.SC_OK); simpleLogService.saveToCache(dto); } } ... }
可以拿到返回数据ApiResult进行记录:
OperationLogAspect.java 1 2 3 4 5 6 ... @AfterReturning(value = "operateLogPointCut()", returning = "jsonResult") public void handleReturning (JoinPoint joinPoint, ApiResult jsonResult) { ... } ...
如果返回结果不是ApiResult类型,则不能使用@OperationLog来记录日志。 记录ApiResult到数据库,建议用json类型的字段,本项目Hibernate自动在h2数据库里创建的表simple_log,无法记录,在 ignoreResult=true
时会导致异常。
WEB测试 Single项目用MockMvc进行了Controller的测试,确保代码调整后可以快速验证:
用户登录及拼凑Bearer令牌:
SingleApplicationTests.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @SpringBootTest @AutoConfigureMockMvc class SingleApplicationTests { ... String acquireBearerToken (MockMvc mvc, String name, String pwd) throws Exception { ObjectMapper objectMapper = new ObjectMapper (); HashMap<String, Object> map = new HashMap <>(1 ); mvc.perform(MockMvcRequestBuilders.post("/user/login" ) .contentType(MediaType.APPLICATION_JSON) .param("name" , name) .param("pwd" , pwd)) .andExpect(status().isOk()) .andExpect(result -> { String content = result.getResponse().getContentAsString(); ApiResult<String> apiResult = objectMapper.readValue(content, new TypeReference <>() { }); if (apiResult.getCode() == HttpServletResponse.SC_OK) { map.put("token" , apiResult.getContent()); } }); String token = (String) map.getOrDefault("token" , null ); assertThat(token).isNotEmpty(); return "Bearer " + token; } ... }
使用Bearer令牌测试Controller:
SingleApplicationTests.java 1 2 3 4 5 6 7 8 String adminToken = acquireBearerToken(mvc, "admin" , "admin" );String urlTemplate = "/business/someInfo" ;mvc.perform(MockMvcRequestBuilders.get(urlTemplate) .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, adminToken)) .andExpect(status().isOk()) .andExpect(content().json("{\"code\":200,\"content\":\"Some Info Found null!\"}" ));
另外也可以用Postman来测试:
扩展点(优化完善) 用户认证安全性差 Single项目的用户认证代码非常简单,即直接验证密码是否正确,没有记录用户的重试次数和截断,所以很容易被暴力破解。另外也没有第三方认证,比如短信验证码、图形验证码等,所以安全性很差。
如果应用部署在公网上,建议加上短信验证码,或者微信登录这类第三方认证。
减少JWT令牌大小 授权这里是JWT方案,所以为了减少令牌在网络上传输消耗的带宽和流量,应该尽量控制JWT令牌里包含的信息,建议是只包含角色信息。
如果是有非常多的Authority,即使用@PreAuthorize("hasAuthority('DIGGING')")
这种方式鉴权,在业务复杂的时候,会导致令牌非常大。可以考虑JWT令牌里只包含用户ID,权限信息在验证JWT之后,再去数据库里查询获取,毕竟是单体应用嘛,再加上缓存机制,算是一个不错的方案。
自定义ApiResult的返回码 Single项目只是利用了HttpStatus(枚举)或者HttpServletResponse(常量)里定义的值,可以参考这两个类,定义自己的业务代码。