首页 / 资讯中心 / 文章详情

Spring Boot整合MyBatis与Spring Security:认证授权全流程解析

Spring Boot整合MyBatis与Spring Security:认证授权全流程解析 ★ FEATURED ARTICLE
一直觉得Spring Security是个“上手有点劝退”的框架尤其当你第一次在一个全新的Spring Boot项目里同时引入MyBatis和Spring Security时那种感觉更像是“三座大山”叠在一起。但项目里又不能没有安全控制——接口裸奔上线第二天日志里就能看到一堆扫描和探测请求这几乎是每个后端开发迟早要面对的必修课。这篇教程我从一个实际可运行的模拟项目X出发完整走一遍Spring Boot与MyBatis、Spring Security的整合流程。从数据库表设计到Mapper层查询用户和角色再到Spring Security的认证与授权配置最后给出我踩过的一些坑和排查思路。面向有一定Spring Boot基础、正准备在项目中引入安全控制的中级开发者也适合刚毕业想弄懂安全框架到底在做什么的同学。直接进入正文。1. 整体架构与核心设计思路Spring Security的整个运作机制可以用一个生活场景来类比地铁安检。每个乘客要进站发起HTTP请求必须通过闸机Spring Security过滤器链闸机检查你有没有票认证身份、有效期对不对会话状态以及这张票能去几号线授权范围。MyBatis在这里扮演的角色是票务数据库管理员——它不负责安检但负责从后台系统把乘客的身份档案和权限记录调出来交给安检人员。很多人第一次接触Spring Security时容易陷入一个误区以为安全框架会“自动”搞定一切自己只需要配置几个URL规则。但实际上Spring Security是一个高度解耦的框架核心接口只有那么几个UserDetailsService负责告诉它“怎么根据用户名加载用户”UserDetails负责描述“用户是什么样”AuthenticationManager负责“怎么验明正身”。这三者才是整个认证流程的地基。在实际业务中用户数据几乎不可能由Spring Security替我们管理它们总是躺在业务数据库里散落在用户表、角色表、权限表中。Spring Security之所以能和MyBatis无缝整合正是因为它不关心用户数据存在哪只要你提供一个UserDetailsService的实现它就能用你的数据完成认证和授权。理解了这一点整合的本质上就变成了一件很简单的事情用MyBatis查数据把查询结果包装成Security认识的UserDetails对象然后交给Security框架去处理后续逻辑。架构上的职责边界建议这样划分组件职责关键接口/类MyBatis Mapper层只负责从数据库查询用户、角色、权限UserMapper、RoleMapperService层查询后组装成Security所需的数据结构自定义UserDetailsServiceImplSpring Security配置层定义放行规则、加密方式、会话策略SecurityFilterChain、PasswordEncoderController层暴露接口通过注解声明权限要求PreAuthorize、Secured这样分层之后安全逻辑和业务逻辑互不认识页面接口只关心当前登录人是谁不关心数据从哪来Mapper只关心SQL怎么写不关心结果被谁消费。后续如果想把数据库从MySQL换成PostgreSQL或者想引入Redis做缓存权限都只是替换某个局部模块的事。2. 项目初始化与数据库建模2.1 依赖引入我用的是Spring Boot 2.7.x版本这个版本目前仍占据不少存量项目并且社区资料丰富。在pom.xml中添加三个核心依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency如果你用的是Spring Boot 3.x注意两个变化一是mybatis-spring-boot-starter需要用3.0开头的新版本二是文档中如果要启用注解权限控制配置类是EnableMethodSecurity而不是2.x时代的EnableGlobalMethodSecurity。很多从2.x迁移到3.x的项目在这一步会遭遇配置类不存在或方法废弃的报错提前引起注意。数据库驱动和连接池我直接用项目已有的MySQL驱动application.yml里的基础配置如下spring: datasource: url: jdbc:mysql://localhost:3306/demo_security?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity注意mapper-locations这一行务必配置正确否则你写好的Mapper XML文件不会被加载启动后MyBatis会报Invalid bound statement (not found)之类的错误。这是新手最常见的第一个坑。2.2 用户与角色表设计Spring Security本身对数据库表结构没有强制要求你想怎么设计都可以但为了配合Security默认的用户模型我们需要在表里体现几个核心字段用户名、密码、是否可用、是否锁定、是否过期。通常我会建一张用户表和一张角色表再用一张关联表建立多对多关系这样后续扩展起来很舒服CREATE TABLE t_user ( id int NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密后, enabled tinyint(1) DEFAULT 1 COMMENT 账号是否可用, locked tinyint(1) DEFAULT 0 COMMENT 账号是否锁定, PRIMARY KEY (id), UNIQUE KEY idx_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;CREATE TABLE t_role ( id int NOT NULL AUTO_INCREMENT, role_code varchar(50) NOT NULL COMMENT 角色编码如ADMIN, role_name varchar(50) NOT NULL COMMENT 角色名称, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色表;CREATE TABLE t_user_role ( user_id int NOT NULL, role_id int NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户角色关联表;有些人可能会疑惑为什么角色表里用role_code而不是直接叫role_name因为在Spring Security中角色权限通常以ROLE_前缀表示比如ROLE_ADMIN、ROLE_USER。我把编码独立出来是为了在SQL里拼角色标识时灵活控制前缀不会和显示名称纠缠。实际上你也可以直接存ROLE_ADMIN这样的字符串。初始化数据的密码不能用明文。Spring Security 5之后要求所有密码必须经过PasswordEncoder处理最常用的是BCrypt。在还没有代码的情况下可以用下面这段临时代码生成初始化数据所需的哈希串public static void main(String[] args) { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); System.out.println(encoder.encode(123456)); }跑一次输出那串以$2a$开头的乱码把它写进SQL的password字段。这样你导入数据之后认证环节就能直接比对了。3. MyBatis持久层实现细节3.1 实体类设计用户实体类不要急着把Security的包引用进来保持纯POJO即可。设计一个User实体包含id、username、password、enabled、locked同时用一个ListRole承载角色集合。千万别在实体类里去实现什么UserDetails接口——实体归实体安全模型归安全模型混在一起会让代码后期非常难看。角色实体也比较简单包含id、roleCode、roleName两个字段。注意MyBatis的resultMap中数据库列名和Java属性名的映射要写清楚尤其是有下划线的字段resultMap idUserWithRoleMap typeUser id propertyid columnid/ result propertyusername columnusername/ result propertypassword columnpassword/ result propertyenabled columnenabled/ result propertylocked columnlocked/ collection propertyroles ofTypeRole id propertyid columnrole_id/ result propertyroleCode columnrole_code/ result propertyroleName columnrole_name/ /collection /resultMap3.2 用户查询SQL与Mapper层实现安全场景下最核心的查询是按用户名查出一个完整用户及其角色列表。这里用关联查询一次搞定select idfindByUsername resultMapUserWithRoleMap SELECT u.id, u.username, u.password, u.enabled, u.locked, r.id AS role_id, r.role_code, r.role_name FROM t_user u LEFT JOIN t_user_role ur ON u.id ur.user_id LEFT JOIN t_role r ON ur.role_id r.id WHERE u.username #{username} /select这里需要特别强调一个实现细节在UserDetailsService的查询路径中密码字段绝对不能漏而且查询条件尽量用UNIQUE KEY上的用户名而不是ID。因为登录请求到达这里时能够拿到的输入只有用户在页面上填的那串用户名完全没有其他信息。用用户名做定位条件是最自然、也是最安全的选择。Mapper接口定义极简单public interface UserMapper { User findByUsername(String username); }业务层在调用这个查询时还要留个心眼如果一个用户拥有多个角色但你的角色表里没有数据resultMap中的collection会是空集合而不是null。在后续构建Security权限时空集合不会导致认证失败但如果你在代码里漏判了null容易踩到NPE。4. Spring Security认证链路接入4.1 实现UserDetailsService核心的衔接工作在这里完成。自定义一个UserDetailsServiceImpl实现Spring Security的UserDetailsService接口在loadUserByUsername方法里调用前面写好的UserMapper把查询结果转换成Security框架认识的数据结构Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserMapper userMapper; public UserDetailsServiceImpl(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userMapper.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListSimpleGrantedAuthority authorities new ArrayList(); if (user.getRoles() ! null) { for (Role role : user.getRoles()) { authorities.add(new SimpleGrantedAuthority(ROLE_ role.getRoleCode())); } } return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true, true, !user.getLocked(), authorities ); } }注意这里new的User是org.springframework.security.core.userdetails.User千万别和业务实体User搞混。构造参数依次是用户名、密码、启用状态、账号未过期、凭证未过期、账号未锁定、权限集合。这种层层包装的过程被不少开发者称为“换壳”其实就是把数据库里的数据翻译成框架能理解的语言。还要记住一点如果用户不存在抛出UsernameNotFoundException最好让异常消息里不要包含太详细的提示比如“管理员账号不存在”这种话就不要写在异常消息里防止给攻击者提供用户名枚举的可能性。4.2 密码加密的必要性Spring Security 5之后密码加密方案已经不只是“建议”而是“强制”。如果你在配置中没有显式声明一个PasswordEncoder启动阶段框架会直接罢工。BCrypt是目前的主流选择和普通的MD5、SHA-256有本质区别BCrypt内部内置随机盐同样的明文每次加密得到的哈希串都不同而且可以通过增加成本因子来调节计算耗时。这意味着攻击者即使拿到数据库密文也无法通过彩虹表反推明文。实际业务里经常有人为了偷懒直接用NoOpPasswordEncoder或者自定义一个弱加密方案。我确实见过有项目把密码用MD5加密后存库结果几年后遭遇撞库事件整张用户表被脱下。这事的教训是安全框架能做的只是把门锁好但如果你自己选了一把纸糊的锁那就怨不得别人了。4.3 核心配置类SecurityConfig在Spring Boot 2.7时代WebSecurityConfigurerAdapter已经进入废弃阶段推荐的方式是直接声明SecurityFilterChain的Bean。这种做法更灵活也更容易自定义多个过滤器链。下面是一个完整的基础配置Configuration EnableWebSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/register).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(form - form.disable()) .httpBasic(basic - basic.disable()) .logout(logout - logout.logoutUrl(/api/auth/logout)) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); return http.build(); } }如果你第一次看这段代码可能觉得都是模板没啥技术含量。但真正读懂它需要理解几层含义第一requestMatchers的顺序很重要匹配规则按声明顺序从上到下执行一旦命中某个规则就停止继续判断。所以把放行规则写在前面把兜底的anyRequest().authenticated()写在最后是必须遵守的套路。第二hasRole(ADMIN)这个写法实际上最终交给框架的权限值是ROLE_ADMIN。它和我们前面在UserDetailsServiceImpl里拼出来的ROLE_前缀正好对上。如果你在数据库里存的就是ROLE_ADMIN那在授权规则里写hasAuthority(ROLE_ADMIN)更合适如果你存的是ADMIN就用hasRole(ADMIN)。两者等价但别混用否则容易出401。第三csrf().disable()这行看起来很随意但对前后端分离的JSON接口来说不关闭CSRF会拦截所有POST/PUT/DELETE请求返回403。CSRF防护主要针对传统表单登录的站点如果项目是全接口化API禁用是常见的实践。但也要清楚这是有取舍的——如果接口走的是SessionCookie模式请认真考虑CSRF的配置不要无脑禁用。5. 授权控制与接口配置5.1 登录接口与认证管理器登录接口是整个认证环节的入口。当用户提交用户名和密码时我们需要通过AuthenticationManager完成校验RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; private final UserMapper userMapper; public AuthController(AuthenticationManager authenticationManager, UserMapper userMapper) { this.authenticationManager authenticationManager; this.userMapper userMapper; } PostMapping(/login) public MapString, Object login(RequestBody LoginRequest request) { UsernamePasswordAuthenticationToken token new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); Authentication authentication authenticationManager.authenticate(token); SecurityContextHolder.getContext().setAuthentication(authentication); // 这里可以继续生成JWT或者返回Session信息 return Map.of(message, 登录成功, username, authentication.getName()); } }注意authenticationManager.authenticate(authentication)这行执行过程中框架会调用我们自定义的UserDetailsServiceImpl去加载用户再用配置的PasswordEncoder比对密码。如果用户名不存在或者密码不匹配这里都会抛出对应异常。捕获后返回给前端的消息不要暴露具体原因统一“用户名或密码错误”即可。登录成功后调用SecurityContextHolder.getContext().setAuthentication(authentication)这行非常关键——它把认证结果保存到当前线程的安全上下文中后续请求才能通过SecurityContextHolder拿到当前用户信息。不少开发者忘了这一步结果登录成功后后续请求依然返回401排查半天发现是上下文没写进去。5.2 方法级权限注解URL级别的授权规则适合做粗粒度控制比如所有/api/admin/**都需要管理员角色。但真实业务里更多时候是同一个接口不同角色能访问的数据范围不一样这个时候方法级注解就派上用场了。在启动类或配置类上加上EnableGlobalMethodSecurity(prePostEnabled true)Spring Boot 3.x用EnableMethodSecurity就可以在Service接口或Controller方法上标注PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long userId) { // 业务逻辑 }常用的注解含义我整理成一张表注解作用常见写法PreAuthorize方法执行前校验支持SpEL表达式PreAuthorize(hasRole(ADMIN))PostAuthorize方法执行后校验可以访问返回结果PostAuthorize(returnObject.username authentication.name)Secured注解式授权只支持角色名不支持SpELSecured(ROLE_ADMIN)RolesAllowed和Secured用法类似来自JSR-250规范RolesAllowed(ADMIN)实际项目中最常用的是PreAuthorize因为SpEL表达式给了非常大的灵活性。比如“只有管理员或者资源所属人本人才能访问”这种典型的私有数据控制写成PreAuthorize(hasRole(ADMIN) or #id authentication.principal.id)就能搞定。5.3 跨域配置与前后端联调前后端分离的项目中跨域问题几乎是必现的。前端跑在localhost:8080后端跑在localhost:8081随便一个POST请求就会被浏览器挡下来。Spring Security下配置跨域有专属姿势Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(Arrays.asList(http://localhost:3000, http://127.0.0.1:3000)); config.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(Collections.singletonList(*)); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }然后在SecurityFilterChain的HttpSecurity配置里加入http.cors()。这里有个容易忽略的细节如果配置了setAllowCredentials(true)那么setAllowedOriginPatterns中不能用*通配表示所有源必须列出具体的源地址。这一点和Spring MVC层的CrossOrigin注解行为不太一样浏览器对携带凭证的跨域请求管控更严格。6. 常见问题与排查技巧实录6.1 高频场景问题速查现象可能原因处理思路登录后所有接口返回401SecurityContextHolder没有保存认证信息检查登录接口是否有setAuthentication调用密码正确却登录失败数据库里存的密码可能不是BCrypt哈希重新生成BCrypt串更新数据库请求返回403缺少角色授权或CSRF拦截检查URL规则顺序以及是否显式配置了CSRF配置了放行但依然被拦requestMatchers路径写错或顺序不对核对前后斜杠、通配符把放行规则放在前面启动报循环依赖Security配置和自定义Service互相注入使用构造器注入并检查依赖方向查询用户时报Invalid bound statementmapper-locations路径配置错误检查application.yml中MyBatis的配置6.2 排查认证链路的一线经验每次遇到“明明数据库里有用户但登录就是失败”的奇葩问题我都会按照一个固定顺序排查。第一步看异常类型。UsernameNotFoundException说明没查到用户去数据库直接执行对应的SQL看看结果BadCredentialsException说明查到了用户但密码不匹配重点检查密码是否经过同样的加密方式。第二步看Security上下文。登录成功后在后续请求的Controller方法里加一行Authentication auth SecurityContextHolder.getContext().getAuthentication()打印出来看看有没有内容。如果是AnonymousAuthenticationToken说明请求根本没携带任何登录凭证——多半是前端没有带上SessionID或Token。第三步看过滤器链。Spring Security的过滤器链是有顺序的SecurityContextPersistenceFilter在最前面负责从Session中读取上下文UsernamePasswordAuthenticationFilter负责处理表单登录FilterSecurityInterceptor最后决定放行还是拒绝。如果你自定义了过滤器要特别注意它插入的位置一个放在链子末尾的过滤器即使逻辑正确也可能被前面的拦截器挡住。6.3 一个容易忽视的性能穴BCryptPasswordEncoder的强度默认是10意味着每次密码比对大约需要100毫秒左右的CPU计算时间。这在单次登录场景下完全没问题但如果你在循环里批量校验用户密码或者在高并发登录场景下对同一账号重复尝试登录就会成为明显的瓶颈。解决方案是适当提高防御强度但别过头同时注意登录接口加上限流机制比如同账号5分钟内只允许失败5次。这个设计既挡住了暴力破解又不会因为BCrypt的计算开销拖垮服务。另外一个和性能相关的小细节是角色查询。我在前面的SQL里用了LEFT JOIN一次性查出用户和角色这在用户量不大时没问题。但如果某个用户拥有的角色数量庞大几十上百个可以考虑拆分成两次查询避免一个大SQL带出无限增长的笛卡尔积。实践中更常见的方式是用户和角色分开查询先根据用户名查出用户再根据用户ID查出角色列表然后在UserDetailsServiceImpl里组装。这样SQL更清晰也更容易加缓存。7. 进阶思考与个人体会这套整合方案跑通之后团队在此之上的扩展方向通常有三个一是引入JWT无状态认证登录成功后签发令牌前端每次请求带上Authorization: Bearer xxx二是结合Redis缓存用户权限信息避免每次请求都查询数据库三是深入OAuth2的授权码模式把认证中心独立成服务。但不管怎么演进最底层的设计哲学是相同的MyBatis管“取数”Security管“判断”Service管“组装”三者的边界清晰了整个系统才能活得好。在实际项目中我最大的体会是Spring Security整合MyBatis最难的从来不是某个API不会调用而是筛选和过滤网上随便一搜就有十年前基于WebSecurityConfigurerAdapter的老教程照着敲完发现启动就报错有时找到一篇2020年后的教程又用的是Spring Boot 2.1和当前项目版本不一致。这篇文章用的方案以2.7为基准已经避开了那些历史包袱如果你的版本高于这个区间注意把文中提到的方法名做一次新旧对照基本都能顺利迁移。最后再分享一个小技巧Spring Security调试真的很痛苦日志默认又是WARN级别很多关键信息看不到。在application.yml里临时把logging.level.org.springframework.securityDEBUG打开你会看到类似Authorizing filterSecurityInterceptor request [GET /api/user]的日志输出这会让你非常直观地看到框架到底放行了哪些请求、拦截了哪些请求以及拦截原因是什么。排查完记得把日志级别调回去。这条在混乱的认证故障中救过我很多次。
阅读完成 · 觉得有帮助?
咨询建站