# springboot+日志+aop+swagger+拦截器+jwt登录 # 1 日志门面和日志的实现 ## 1.1日志门面 ### 什么是日志门面 类比:JDBC(Java Database Connectivity) 日志门面就像 JDBC,它只是一个接口(Interface),定义了一组日志操作的方法(如 info、debug、error 等),但不负责具体实现。 它的作用是屏蔽底层日志实现的差异,让开发者面向接口编程,而不是具体的实现。 ### 有哪些日志门面 1 JCL(\*\*Jakarta Commons Logging\*\* / \*\*Apache Commons Logging\*\*):Spring Framework 早期版本(如 Spring 3.x、4.x)默认使用 2 SLF4j(Simple Logging Facade for Java): 目前springboot默认使用 3 jboss-logging :hibernate在使用 ## 1.2日志的实现 ### 什么是日志的实现 类比:MySQL、Oracle、SQL Server 等数据库驱动 日志实现就是真正执行日志记录功能的组件,它实现了日志门面定义的接口。 它负责将日志信息输出到控制台、文件、网络等目标。 ### 有哪些日志实现 1 Logback:Spring Boot 默认使用的日志实现,性能好、功能强大 2 Log4j(已不推荐): Apache 的老牌日志框架 3 Log4j2 :Log4j 的升级版,性能更好,支持异步日志 4 JUL(JDK Logging-java.util.logging)Java 自带的日志实现,功能较弱 # 2 为什么需要门面 + 实现的结构? 1. 解耦合 项目代码只依赖日志门面(如 SLF4J),不需要关心底层使用的是 Logback 还是 Log4j2。 后期如果想更换日志实现,只需替换依赖,无需修改代码。 2. 避免日志冲突 项目中可能引入多个第三方库,它们可能使用不同的日志实现(比如有的用 Logback,有的用 Log4j)。 使用日志门面可以统一日志输出方式,避免多个日志框架共存带来的冲突问题。 # 3 springboot的日志门面+实现组合 springboot选用SLF4J(门面)和logback(实现) # 4 日志级别 级别 说明 1 TRACE 最详细的日志信息,通常用于调试 2 DEBUG 调试信息,用于开发阶段 3 INFO 普通信息,用于运行时状态输出 ---用于生产阶段 4 WARN 警告信息,可能存在问题但不影响运行 5 ERROR 错误信息,发生异常或错误操作 日志级别为1\~5,只会输出\>=指定级别的日志: 比如设置级别为INFO,则会输出\>=INFO级别的日志,也就是info、warn、error # 5 日志的使用 ## 代码中打印日志 注意包不要导错了! import org.slf4j.Logger; import org.slf4j.LoggerFactory; \`\`\`java private static final Logger log = LoggerFactory.getLogger(xxxx.class); @GetMapping("/hello") public String sayHello() { log.trace("trace /hello 接口"); log.debug("debug /hello 接口"); log.info("info /hello 接口"); log.warn("warn /hello 接口"); log.error("error /hello 接口"); return "Hello, World!"; } \`\`\` 日志只会打印info、warn、error,说明默认为info ## 1日志的级别配置 \`\`\`yml # 配置指定路径(到包即可)的日志级别 logging: level: cn.woniu.controller: DEBUG \`\`\` ## 2日志的输出位置配置 默认日志只能输出到控制台(开发够用),但很多时候我们需要把日志输出到磁盘中(生产场景必须配置)--日志文件只有开服才会产生 \`\`\`yml logging: level: cn.woniu.controller: DEBUG file: # 指定日志文件的路径和名称 name: C:/Users/ThinkPad/IdeaProjects/springboot_02/myapp.log \`\`\` ## 3日志输出格式配置 \`\`\`yml logging: level: cn.woniu.controller: DEBUG file: # 指定日志文件的路径和名称 name: C:/Users/ThinkPad/IdeaProjects/springboot_02/myapp.log pattern: # 控制台输出格式 console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" # 文件输出格式 file: "%d{yyyy-MM-dd HH:mm:ss} \[%thread\] %-5level %logger{36} - %msg%n" \`\`\` %d{HH: mm:ss.SSS}------时间戳,精确到毫秒 %thread------当前线程名 %-5level------日志级别(INFO/WARN/ERROR 等),左对齐,占 5 个字符宽度 %logger{36}------日志记录器名称(类名),最多显示 36 个字符 %msg------日志消息 %n------换行符 ## 4 进阶配置:\`logback-spring.xml\` \`application.yml\` 中的配置有一定局限性,无法实现日志归档、异步输出、多环境配置等高级功能。 这时就需要使用 Logback 的官方XML配置文件,推荐使用 \`logback-spring.xml\`,它可以充分利用 Spring Boot 提供的扩展特性 你只需在 \`src/main/resources\` 目录下创建 \`logback-spring.xml\` 文件,Spring Boot 会自动加载 log.properties: 统一管理日志输出路径、级别、格式等变量,方便在logback-spring.xml引用 logback-spring.xml:logback核心配置文件 # 6 完整的logback日志配置文件: ## 完整的log.properties \`\`\`properties # 定义日志输出格式 log.pattern=%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n # 定义日志文件输出路径 log.path=C:/wyc/logstest2 # 定义日志级别 log.level=DEBUG # 控制日志是否向上传递给父级 log.additivity=false \`\`\` ## 完整的logback-spring.xml \`\`\`xml \*\* ${log.pattern} ${log.path}/debug.log ${log.path}/debug.%d{yyyy-MM-dd}.%i.log.gz 30 100MB ${log.pattern} DEBUG ${log.path}/info.log ${log.path}/info.%d{yyyy-MM-dd}.%i.log.gz 30 100MB ${log.pattern} INFO ${log.path}/error.log ${log.path}/error.%d{yyyy-MM-dd}.%i.log.gz 30 100MB ${log.pattern} 100MB ERROR \`\`\` ### 开发配置 可以通过logger+additivity="false"来禁止某些日志的输出,达到避免干扰的目的,比如连接池相关 ### 线上配置 需要注释掉,也就是说线上不会打印debug级别, 因为mybaits的sql日志官方设置的默认级别为DEBUG,避免业务中有大量的sql执行导致日志大小暴增,最终导致服务器磁盘占满,而磁盘满了的后果就是没空间了,将来所有的请求都会报错,因为日志写不下了 # 7logback开服后自动创建文件的前提 必须通过 或 标签并且配置了appender-ref才能自动创建文件 一般情况配置root即可,logger只有特殊需求才配置 \`\`\`xml \`\`\` # 8.springboot集成AOP \`\`\`xml org.springframework.boot spring-boot-starter-aop \`\`\` # 9.springboot集成swagger Swagger 可以帮助你快速实现 API 文档的自动生成和可视化测试。 springboot2.x建议:springfox-swagger2 + springfox-swagger-ui springboot3.x建议:springdoc-openapi-starter-webmvc-ui 两者的使用差不多,区别是: 1 使用的注解、属性有差异 2 swaggerUI界面有差异 ## 1.导包 \`\`\`xml org.springdoc springdoc-openapi-starter-webmvc-ui 2.1.0 \`\`\` ## 2.创建配置文件 \`\`\`java @Configuration public class SwaggerConfig { @Bean public GroupedOpenApi publicApi() { return GroupedOpenApi.builder() .group("public") .packagesToScan("com.woniu.controller") // 指定扫描的包 .build(); } //可以配置多个分组 扫描不同的包 @Bean public GroupedOpenApi publicApi() { return GroupedOpenApi.builder() .group("public2") .packagesToScan("com.woniu.controller2") // 指定扫描的包 .build(); } } \`\`\` ## 3.测试访问 http://localhost:8080/swagger-ui/index.html 默认地址 如果你添加了 \`\`\`yml server: servlet: context-path: /springboot \`\`\` http://localhost:8080/springboot/swagger-ui/index.html ## 4.使用 ### 4.1在controller类上 \`\`\`java @RestController @RequestMapping("/test") @Tag(name = "用户管理", description = "用户相关的CRUD操作") public class TestController { \`\`\` ### 4.2 在方法上面 \`\`\`java @GetMapping("/login") @Operation(summary = "登录",description = "用户登录") public String login(@Parameter(description = "账号", required = true)String username, @Parameter(description = "密码", required = true) String password) { System.out.println("username = " + username); System.out.println("password = " + password); return "hello"; } //注意:需要通过@RequestParam 告知swagger,否则swagger默认是json //如果不加@RequestParam,则swagger如下,提价方式为json,会导致后端接受的参数为null @PostMapping("/login") @Operation(summary = "登录",description = "用户登录") public String login(@Parameter(description = "账号", required = true) @RequestParam String username, @Parameter(description = "密码", required = true) @RequestParam String password) { System.out.println("username = " + username); System.out.println("password = " + password); return "hello"; } //后端参数如果是对象,并且前面不加@RequestBody也代表接受表单提交 @PostMapping("/login") @Operation(summary = "登录",description = "用户登录") public String login(@RequestBody UserDto userDto) { System.out.println("username = " + userDto.getUsername()); System.out.println("password = " + userDto.getPassword()); return "hello"; } \`\`\` ### 4.3在实体对象 \`\`\`java @Data @Schema(description = "用户登录请求参数") public class UserDto extends PageParam { @Schema(description = "用户名", example = "admin", required = true) private String username; @Schema(description = "用户密码", example = "123456") private String password; } \`\`\` ## 5.另一种UI:knife4j springboot3.x官方推荐的是springdoc-openapi,但UI不好看,用起不方便 也可以使用国内开发者维护的swagger平替:knife4j ### 5.1特点 1 在springdoc基础上提供了更丰富的界面和功能 2 支持更多的自定义配置和美化 3 注解兼容性: knife4j完全兼容springdoc-openapi的注解体系 4 核心注解: 都使用相同的OpenAPI 3.0注解,如@Tag、@Operation等 5 配置方式: 基本配置项和使用方式一致 ### 5.2依赖 \`\`\`xml com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.4.0 \`\`\` yml配置 \`\`\`yml knife4j: setting: #语言设置 (zh-CN: 中文, en-US: 英文) language: zh_cn # 是否显示 footer enable-footer: false # 是否显示顶部搜索框 enable-search: true # 是否显示分组 enable-group: true #是否开启 knife4j 功能 enable: true \`\`\` knife4j本地访问地址 \[http://localhost:8080/doc.html\](http://localhost:8080/doc.html) 分组(可选)--效果等同于openapi的配置类 \`\`\`yml springdoc: group-configs: - group: default packages-to-scan: cn.woniu.controller # 指定特定包 paths-to-match: /\*\* # 指定特定路径 - group: default2 packages-to-scan: cn.woniu.controller2 # 指定特定包 paths-to-match: /\*\* # 指定特定路径 \`\`\` ## 6.bug和解决 在sprinbgoot3.x版本中,无论是openapi还是knife4j 的swagger,都会和全局异常处理器起冲突,只要定义了全局处理,则swagger则无法显示。 原因::Swagger会自动扫描项目中的所有@RestControllerAdvice类,错误地将GlobalExceptionHandler类识别为一个普通的控制器(Controller),这会导致Swagger试图为异常处理器中的每个@ExceptionHandler方法生成API文档条目,从而造成界面混乱或显示异常 解决方法:在全局异常处理器类上添加 @Hidden 注解,该注解告诉Swagger完全忽略这个类,不要将其纳入API文档生成范围 \`\`\`java @RestControllerAdvice @Hidden public class GlobalExceptionHandler { } \`\`\` # 10.springboot 拦截器 \`\`\`java public class MyInterceptor implements HandlerInterceptor { //进入controller之前被拦截,适合做权限验证,控制是否放行 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { System.out.println("preHandle"); return true; } } \`\`\` \`\`\`java @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { //拦截器一般都不需要成为bean,相对比较独立,减少sping的bean的管理,因此做法都是new, // 当然也可以把自定义拦截器通过@Component注册为bean,然后该配置类中注入即可 registry.addInterceptor(new MyInterceptor()) //允许访问的url .addPathPatterns("/\*\*") //不需要拦截的url, Spring MVC 拦截器处理的是去除 context path 后的路径。 //因此下面的路径不需要从/admin开始写 .excludePathPatterns("/user/login","/user/register"); } //跨域配置 @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/\*\*")//所有接口 .allowedOrigins("\*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("\*");//允许所有请求头 } } \`\`\` # 11.jwt登录 ## 1.jwt介绍 全称:json web token ,属于web领域的用于通信的传输信息 组成:三部分,如xxxx.yyyy.zzzz,长度是可变的 第一部分xxxx: 代表头部 header,用来声明算法、类型等 { "alg": "HS256", // 指定使用的签名算法(HS256最常用) "typ": "JWT" // 声明这是一个JWT令牌 } 第二部分yyyy: 代表负荷payload,记录自定义要承载的信息如 { "user": { "userid": "10000", "username": "张三" }, "exp": 1516239022 // 过期时间的时间戳 } 第三部分:zzzz: 代表签名signature: 通过header提供的算法和payload提供的信息 再加上自定义秘钥加密后得到的值(不可逆的) 整个过程中: header 和 payload 是通过base64加密,属于可逆的,可能会被篡改 而签名是通过不可逆的算法加密的,因此不会被篡改 ## 2.JWT生成-工具依赖 \`\`\`xml com.auth0 java-jwt 4.4.0 \`\`\` \`\`\`java public static void main(String\[\] args) { HashMap map = new HashMap\<\>(); map.put("userid", "10000"); map.put("username", "张三"); String token = JWT.create() .withClaim("user", map) //添加负荷 .withExpiresAt(new Date(System.currentTimeMillis() + 1000 \* 60 \* 60 \* 24 \* 7)) //过期时间(属于负荷) .sign(Algorithm.HMAC256("woniu120"));//指定算法,配置秘钥 System.out.println(token); } \`\`\` ## 3.JWT-解析 \`\`\`java public static void main(String\[\] args) { String token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLlvKDkuIkifSwiZXhwIjoxNzU0Mzk3ODUwfQ.-hT-G8gneMSck4lnc1gSwpxYQCtXF9xE7MLXPpbVnIM"; //得到验证器,和生成token时的签名保持一致的加密方式和秘钥 JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("woniu120")).build(); //验证token,生成解析后的jwt对象 DecodedJWT decodedJWT = jwtVerifier.verify(token); Claim claim = decodedJWT.getClaim("user"); Map res = claim.asMap(); System.out.println(res); //获得过期日期 Date expiresAt = decodedJWT.getExpiresAt(); System.out.println(expiresAt); } \`\`\` ## 4.模拟篡改了token 将token随便修改一个字母 在执行上述代码报错 \`\`\` Exception in thread "main" com.auth0.jwt.exceptions.JWTDecodeException: The string 'k"user":{"userid":"10000","username":"张三"},"exp":1754397850}' doesn't have a valid JSON format. \`\`\` ## 5.模拟过期后,验证失败 \`\`\` 等待token过期后在执行解析代码 报错: Exception in thread "main" com.auth0.jwt.exceptions.TokenExpiredException: The Token has expired on 2025-07-29T12:48:55Z. \`\`\` ## 6.jwt生成和解析时可以保存多个Claim \`\`\`java withclaim(String name, Date value) withclaim(String name, Long value) withclaim(string name, Double value) withclaim(String name, List list) withclaim(String name, String value) withclaim(String name, Boolean value) withclaim(String name, Instant value) withclaim(String name, Integer value) withclaim(String name,Map map) \`\`\` ## 7.jwt是如何防止篡改 ### 7.1生成token过程 \`\`\`java // 1. 创建header和payload String header = "{\\"alg\\":\\"HS256\\",\\"typ\\":\\"JWT\\"}"; String payload = "{\\"user\\":{\\"userid\\":\\"10000\\",\\"username\\":\\"张三\\"},\\"exp\\":1516239022}"; // 2. Base64Url编码 String encodedHeader = base64UrlEncode(header); // "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" String encodedPayload = base64UrlEncode(payload); // "eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLlvKDkuIki fQ" // 3. 拼接待签名字符串 String toSign = encodedHeader + "." + encodedPayload; // 结果: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLlvKDkuIki fQ" // 4. 使用HMAC256算法和密钥"woniu120"生成签名 byte\[\] signature = HMAC256(toSign, "woniu120"); // 5. Base64Url编码签名 String encodedSignature = base64UrlEncode(signature); // 假设结果是: "pdH57pSjA2mPYA4Y6E3zJ8v7F1rJ6h3N1J4k8Q9v2wE" // 6. 拼接最终token String token = encodedHeader + "." + encodedPayload + "." + encodedSignature; //最终生成的token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLlvKDkuIki fQ.pdH57pSjA2mPYA4Y6E3zJ8v7F1rJ6h3N1J4k8Q9v2wE 其中 header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 payload: eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLlvKDkuIkifQ signature: pdH57pSjA2mPYA4Y6E3zJ8v7F1rJ6h3N1J4k8Q9v2wE \`\`\` ### 7.2 验证签名的过程 \`\`\`java // 假设你篡改了payload中的username为"李四" 原始payload: {"user":{"userid":"10000","username":"张三"}} → Base64Url: eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLlvKDkuIkifQ 篡改后payload: {"user":{"userid":"10000","username":"李四"}} → Base64Url: eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLnlLcifQ 签名计算: // 原始签名计算 String originalToSign = header + "." + "eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLlvKDkuIkifQ"; String originalSignature = HMAC256(originalToSign, "woniu120"); 签名的结果: pdH57pSjA2mPYA4Y6E3zJ8v7F1rJ6h3N1J4k8Q9v2wE // 篡改后签名计算 String modifiedToSign = header + "." + "eyJ1c2VyIjp7InVzZXJpZCI6IjEwMDAwIiwidXNlcm5hbWUiOiLnlLcifQ"; String modifiedSignature = HMAC256(modifiedToSign, "woniu120"); 签名的结果: xYz123AbC456DeF789GhI012JkL345MnO678PqR90sT 两次结果不同,验证失败,说明发现已被篡改 补充:由于header和payload 都是base64加密,因此都能被篡改,但篡改后jwt都能检验出来 \`\`\` ### 7.3封装JWT工具类 \`\`\`java public class JwtUtils { private static final String JWT_SECRET = "woniu"; private static final long JWT_EXPIRE = 1000 \* 60 \* 60 \* 24 \* 7; //生成token public static String createToken(Map map) { String token = JWT.create() .withClaim("user", map) //添加负荷 .withExpiresAt(new Date(System.currentTimeMillis() + JWT_EXPIRE)) //过期时间未来时间 .sign(Algorithm.HMAC256(JWT_SECRET));//指定算法,配置秘钥 return token; } //解析token public static Map parseToken(String token) { return JWT.require(Algorithm.HMAC256(JWT_SECRET)) .build() .verify(token) .getClaim("user") .asMap(); } } \`\`\` ### 7.4jwt练习 1.登录验证通过时生成token并返回给前端 2.在拦截器里面拦截所有请求 放行 登录接口和注册接口 3.除开登录和注册其他的必须要携带token 4.需要在拦截器里面验证token是否过期或篡改 ## 7.5ThreadLocal线程变量 在当前线程存取数据,并且线程安全 作用:只要在同一个线程中,那么保存和获取的数据一定是同一个 \`\`\`java /\*\* \* 基于 ThreadLocal 的上下文存储工具类。 \* \* 每个线程独立维护一个 Map,用于存放临时变量, \* 避免为每个变量单独创建 ThreadLocal,方便在同一个线程的不同调用层级间传递数据。 \* \* @author YourName \* @version 1.0 \*/ public final class ThreadLocalUtils { private static final ThreadLocal\> CONTEXT = ThreadLocal.withInitial(HashMap::new); private ThreadLocalUtils() { // 工具类不允许实例化 } /\*\* \* 向当前线程的上下文中存入一个键值对。 \* \* @param key 键(非空) \* @param value 值(可以为 null) \*/ public static void put(String key, Object value) { CONTEXT.get().put(key, value); } /\*\* \* 从当前线程的上下文中获取一个值。 \* \* @param key 键 \* @return 值,若键不存在则返回 null \*/ public static Object get(String key) { return CONTEXT.get().get(key); } /\*\* \* 从当前线程的上下文中获取一个值,并转换为指定类型。 \* \* @param key 键 \* @param clazz 目标类型 \* @param 类型参数 \* @return 转换后的值,若键不存在或类型不匹配则返回 null \*/ @SuppressWarnings("unchecked") public static T get(String key, Class clazz) { Object value = CONTEXT.get().get(key); if (clazz.isInstance(value)) { return (T) value; } return null; } /\*\* \* 移除当前线程上下文中指定键的值。 \* @param key 键 \* @return 被移除的值,若键不存在则返回 null \*/ public static Object remove(String key) { return CONTEXT.get().remove(key); } /\*\* \* 清空当前线程的整个上下文(移除所有键值对)。 \*/ public static void clear() { CONTEXT.get().clear(); } /\*\* \* 获取当前线程的整个上下文 Map(建议只用于读取,不要直接修改,除非确实需要)。 \* @return 当前线程的上下文 Map \*/ public static Map getMap() { return CONTEXT.get(); } /\*\* \* 移除当前线程的上下文(彻底释放 ThreadLocal 变量)。 \* 强烈建议在线程执行完毕(如请求结束、任务完成)时调用此方法, \* 尤其是在使用线程池的场景下,防止内存泄漏。 \*/ public static void remove() { CONTEXT.remove(); } } \`\`\` \`\`\`java public class Demo { public static void main(String\[\] args) { // 存入数据 ThreadLocalUtils.put("userId", 12345L); ThreadLocalUtils.put("requestId", "REQ-001"); // 取出数据(原始类型) Long userId = (Long) ThreadLocalUtils.get("userId"); System.out.println("userId: " + userId); // 取出并自动转换类型 String requestId = ThreadLocalUtils.get("requestId", String.class); System.out.println("requestId: " + requestId); // 清理上下文(建议在 finally 块中调用) ThreadLocalUtils.remove(); } } \`\`\` # 12.密码加密 ## 1.市面上的加密手段 base64 ---可加密,可解密----可逆 MD5 ---可加密,不可解密----不可逆 (密码不能太简单) ## 2.MD5加盐加密介绍 1 MD5是一种加密算法,并且是不可逆的 2 如果原始值是相同的,那么通过MD5加密后也一定是相同的 3 盐(salt)的目的就是为了左右加密后的结果: 如果我密码是abc,那么加密后必然是def,那么我只需要看表中的其他人密码是否为edf就可以猜到他们的密码也是abc了,因此为了让每个的密码更加安全,我们需要让每个用户的盐不同,也就是尽管我们的密码一致,但由于盐不同,最终加密后的结果也不同。 4 表中的密码存储的是加密后的内容,也就意味着将来做登录操作,也需要对用户输入的密码进行加盐加密,拿到结果和表中的结果对比是否相同来查看密码是否正确。 5 对于每个用户而言,盐不能是随机产生的,假如注册的盐和登录的盐随机产生(不同),那肯定他们通过MD5加密后的结果是不同的,那么登录也就不可能成功。因此每个用户的盐需要不同,但又不能随机,我们就选择让每个用户的用户名来充当自己的盐 ## 3 登录分步验证的好处 登录时不采取直接用户名和密码直接查询: select \* from user where username=?and password=? 而是采取分部验证: 1 验证账号是否存在,如果存在则返回user对象到应用层 select \* from user where username=? 2 验证密码是否存在,直接用刚才查询返回的user的密码和用户传过来的密码对比 这样做的优点: 1 性能优化 数据库只需要对username字段建立索引即可 避免了对密码字段的索引需求(密码通常不应该被索引) 2 安全性: 密码不参与查询,密码相关的sql也不会出现到日志中,提供安全性 就算是加密后的密码被发现了也是很危险的 \`\`\`java /\*\* \* MD5加盐加密工具类 \*/ public class MD5Util { private static final String ALGORITHM = "MD5"; private static final int SALT_LENGTH = 16; // 盐值长度 private static final SecureRandom random = new SecureRandom(); /\*\* \* 生成随机盐值 \* @return 随机盐值字符串 \*/ public static String generateSalt() { byte\[\] salt = new byte\[SALT_LENGTH\]; random.nextBytes(salt); return bytesToHex(salt); } /\*\* \* MD5加盐加密 \* @param password 原始密码 \* @param salt 盐值 \* @return 加密后的字符串 \*/ public static String encrypt(String password, String salt) { try { MessageDigest md = MessageDigest.getInstance(ALGORITHM); // 将密码和盐值组合后进行MD5加密 String combined = password + salt; byte\[\] hashBytes = md.digest(combined.getBytes(StandardCharsets.UTF_8)); return bytesToHex(hashBytes); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5 algorithm not found", e); } } /\*\* \* 验证密码 \* @param inputPassword 输入的密码 \* @param salt 盐值 \* @param encryptedPassword 已加密的密码 \* @return 验证结果 \*/ public static boolean verify(String inputPassword, String salt, String encryptedPassword) { String newEncrypted = encrypt(inputPassword, salt); return newEncrypted.equals(encryptedPassword); } /\*\* \* 将字节数组转换为十六进制字符串 \* @param bytes 字节数组 \* @return 十六进制字符串 \*/ private static String bytesToHex(byte\[\] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } } \`\`\`