1. 为什么需要自定义Spring Security表结构
在企业级应用开发中,Spring Security默认提供的表结构往往无法满足实际业务需求。默认的users和authorities表设计过于简单,只包含最基本的用户名、密码和权限字段。当我们需要实现以下功能时,就必须考虑自定义表结构:
- 多维度用户属性(部门、职级、岗位等)
- 复杂的权限层级(菜单权限、数据权限、操作权限)
- 第三方登录集成(微信、钉钉等OAuth2认证)
- 审计日志需求(记录登录IP、设备信息等)
我在金融行业项目中就遇到过这样的案例:系统需要支持员工+客户双类型用户体系,每种用户有不同的认证方式和权限控制策略。Spring Security的默认实现根本无法满足这种复杂场景。
2. 核心接口UserDetailsService的实现要点
2.1 自定义UserDetails对象
首先需要扩展UserDetails接口,创建符合业务需求的用户对象:
java复制public class CustomUser implements UserDetails {
private Long userId;
private String username;
private String password;
private String deptCode;
private List<CustomGrantedAuthority> authorities;
private boolean accountNonExpired;
// 其他字段和getter/setter
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
// 其他接口方法实现
}
关键点:
GrantedAuthority的实现类也需要自定义,通常需要包含权限标识和权限类型字段
2.2 实现UserDetailsService
核心的认证逻辑在loadUserByUsername方法中实现:
java复制@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleMapper roleMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 查询用户基础信息
User user = userMapper.findByUsername(username);
if(user == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 2. 查询用户权限
List<Role> roles = roleMapper.findByUserId(user.getId());
List<CustomGrantedAuthority> authorities = roles.stream()
.map(role -> new CustomGrantedAuthority(role.getCode(), role.getType()))
.collect(Collectors.toList());
// 3. 构建UserDetails对象
return new CustomUser(
user.getId(),
user.getUsername(),
user.getPassword(),
user.getDeptCode(),
authorities,
!user.getIsLocked()
);
}
}
3. 数据库表结构设计实践
3.1 基础用户表设计
sql复制CREATE TABLE `sys_user` (
`id` bigint NOT NULL COMMENT '用户ID',
`username` varchar(64) NOT NULL COMMENT '登录账号',
`password` varchar(128) NOT NULL COMMENT '加密密码',
`dept_id` bigint DEFAULT NULL COMMENT '部门ID',
`email` varchar(128) DEFAULT NULL COMMENT '邮箱',
`mobile` varchar(20) DEFAULT NULL COMMENT '手机号',
`is_locked` tinyint(1) DEFAULT '0' COMMENT '是否锁定',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
3.2 权限相关表设计
推荐采用RBAC(基于角色的访问控制)模型:
sql复制-- 角色表
CREATE TABLE `sys_role` (
`id` bigint NOT NULL,
`code` varchar(64) NOT NULL COMMENT '角色编码',
`name` varchar(128) NOT NULL COMMENT '角色名称',
`data_scope` int DEFAULT NULL COMMENT '数据权限范围',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
-- 用户角色关联表
CREATE TABLE `sys_user_role` (
`id` bigint NOT NULL,
`user_id` bigint NOT NULL,
`role_id` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
-- 资源权限表
CREATE TABLE `sys_permission` (
`id` bigint NOT NULL,
`code` varchar(64) NOT NULL COMMENT '权限标识',
`name` varchar(128) NOT NULL COMMENT '权限名称',
`type` int NOT NULL COMMENT '权限类型(1:菜单 2:按钮 3:API)',
`url` varchar(255) DEFAULT NULL COMMENT '资源路径',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源权限表';
-- 角色权限关联表
CREATE TABLE `sys_role_permission` (
`id` bigint NOT NULL,
`role_id` bigint NOT NULL,
`permission_id` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';
4. 安全配置与集成
4.1 Web安全配置
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login", "/oauth/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/")
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.and()
.csrf().disable();
}
}
4.2 密码加密策略
Spring Security推荐使用BCryptPasswordEncoder:
java复制@Bean
public PasswordEncoder passwordEncoder() {
// 强度参数默认10,可根据服务器性能调整
return new BCryptPasswordEncoder(12);
}
在实际存储密码时:
java复制public class UserService {
@Autowired
private PasswordEncoder passwordEncoder;
public void createUser(UserCreateDTO dto) {
User user = new User();
user.setUsername(dto.getUsername());
// 加密存储密码
user.setPassword(passwordEncoder.encode(dto.getPassword()));
// 其他字段设置
userMapper.insert(user);
}
}
5. 实战中的经验与坑点
5.1 权限缓存问题
用户权限数据如果每次请求都查数据库,性能会很差。推荐两种优化方案:
- Redis缓存方案:
java复制@Service
public class CachingUserDetailsService implements UserDetailsService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private CustomUserDetailsService delegate;
@Override
public UserDetails loadUserByUsername(String username) {
String key = "user:details:" + username;
UserDetails userDetails = (UserDetails) redisTemplate.opsForValue().get(key);
if(userDetails == null) {
userDetails = delegate.loadUserByUsername(username);
redisTemplate.opsForValue().set(key, userDetails, 30, TimeUnit.MINUTES);
}
return userDetails;
}
}
- JWT方案:将权限信息直接编码到token中,避免频繁查询
5.2 权限变更实时生效
当管理员修改用户权限后,如何让已登录用户立即生效?
java复制@RestController
public class PermissionController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@PostMapping("/admin/update-permission")
public Result updatePermission(@RequestBody PermissionUpdateDTO dto) {
// 更新数据库权限
permissionService.updatePermission(dto);
// 清除相应用户的权限缓存
String key = "user:details:" + dto.getUsername();
redisTemplate.delete(key);
return Result.success();
}
}
5.3 多端登录的特殊处理
移动端和Web端可能需要不同的权限控制策略。可以在UserDetails实现中添加设备类型字段:
java复制public class CustomUser implements UserDetails {
// 原有字段
private String deviceType; // mobile/web
public boolean hasPermission(String permission) {
if("mobile".equals(deviceType)) {
// 移动端特殊权限逻辑
} else {
// Web端权限逻辑
}
}
}
6. 高级场景扩展
6.1 数据权限控制
除了接口权限,实际业务中经常需要控制数据可见范围。可以在UserDetails中扩展数据权限信息:
java复制public class CustomUser implements UserDetails {
// 原有字段
private DataScope dataScope;
public static class DataScope {
private Integer type; // 1:全部 2:本部门 3:自定义
private List<Long> deptIds; // 可见部门ID列表
}
}
在MyBatis查询中应用数据权限:
xml复制<select id="selectUsers" resultType="User">
SELECT * FROM sys_user
<where>
<if test="dataScope.type == 2">
AND dept_id = #{user.deptId}
</if>
<if test="dataScope.type == 3">
AND dept_id IN
<foreach collection="dataScope.deptIds" item="deptId" open="(" separator="," close=")">
#{deptId}
</foreach>
</if>
</where>
</select>
6.2 多因素认证集成
对于安全要求高的系统,可以集成短信/邮箱验证码等二次认证:
java复制public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private SmsService smsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String credentials = (String) authentication.getCredentials();
// 1. 基础认证
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if(!passwordEncoder.matches(credentials, userDetails.getPassword())) {
throw new BadCredentialsException("密码错误");
}
// 2. 二次认证
if(userDetails.isMfaEnabled()) {
String mfaCode = ((CustomWebAuthenticationDetails) authentication.getDetails()).getMfaCode();
if(!smsService.verifyCode(userDetails.getMobile(), mfaCode)) {
throw new BadCredentialsException("验证码错误");
}
}
return new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
}
}
7. 性能优化建议
- 权限查询优化:使用
JOIN一次性查询用户的所有权限信息,避免N+1查询问题
sql复制SELECT u.*, r.code AS role_code, p.code AS permission_code
FROM sys_user u
LEFT JOIN sys_user_role ur ON u.id = ur.user_id
LEFT JOIN sys_role r ON ur.role_id = r.id
LEFT JOIN sys_role_permission rp ON r.id = rp.role_id
LEFT JOIN sys_permission p ON rp.permission_id = p.id
WHERE u.username = ?
- 权限验证优化:使用
@PreAuthorize注解时,SpEL表达式不宜过于复杂
java复制// 不推荐 - 每次请求都会执行
@PreAuthorize("hasPermission(#id, 'order', 'read')")
// 推荐 - 提前计算好权限标识
@PreAuthorize("hasAuthority('order:read')")
- Session管理:无状态应用考虑使用JWT,有状态应用注意Session超时设置
properties复制# application.properties
server.servlet.session.timeout=30m
spring.session.redis.flush-mode=on_save
spring.session.redis.namespace=spring:session
