Spring Security教程

Spring Security

  • 描述:Spring 生态中核心的安全框架

    1. 认证:Security封装了【加载用户信息------身份校验】过程,提供过滤精细化拦截
    2. 鉴权:Security封装了Web接口前置鉴权过程
  • 引入依赖

xml 复制代码
<!-- Spring Security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • 原理

    1. Spring Security本质上就是多个【servlet 过滤器】组成的【FilterChain】
    2. 显然,采用MVC拦截器完全可以替代,而Spring Security则提供了标准化的认证 / 授权体系

前端/客户端 Security过滤器链 Service AuthenticationManager AuthenticationProvider Redis缓存 登录请求流程 1. 登录请求,过滤器放行 2. 创建待认证Authentication 3. AuthenticationManager.authenticate()开启验证 4. 调用UserDetailsService验证并加载用户信息 /调用三方API验证并加载用户信息 验证失败,返回403 5. 验证成功,缓存用户信息 6. 返回Token:登录成功 非登录请求流程 1. 非登录请求,过滤器拦截 未携带Token,返回403 2. 过滤器拦截:验证Token有效性和时效性 验证失败,返回401 3. 验证成功,将用户信息注入SecurityContext,进入鉴权检查 鉴权失败,返回403 4. 请求进入业务层 5. 业务层复用SecurityContext获取用户信息 6. 请求处理完毕,返回Token+业务数据 前端/客户端 Security过滤器链 Service AuthenticationManager AuthenticationProvider Redis缓存

Token认证技术

  • Token认证是【对非登录请求实现前置身份验证】的解决方案

    1. 本地或三方查库验证身份过程会产生大量IO,除了登录请求,其他请求都这么验证身份会很影响性能
    2. 将用户信息通过加密等手段转换为Token字符串,此后就可以通过Token凭证直接校验用户身份,无需真的查库校验身份
    3. Token可以是本地生成的(如JWT),也可以使用可信三方提供的Token(如OAuth)
    4. 推荐使用Redis缓存Token,保证性能同时兼顾了安全性
  • 本地Token

    1. 用户登录时,本地数据库验证通过后,通过JWT技术生成Token
    2. 根据用户信息再本地JWT生成Token,并保存到Redis中
    3. 前端后续请求都携带此Token,验证通过后直接使用Redis中的用户信息
    4. 实现方式见【本地登录功能认证】

前端/客户端 后端 Redis缓存 1.登录请求 2.系统内部查库验证 验证失败 3.验证成功,内部生成Token 4.保存{Token:用户信息}到Redis 5.登录成功,返回Token给前端 前端/客户端 后端 Redis缓存

  • 三方Token(OAuth 2.0)

    1. 用户登录时,调用三方API,验证通过后得到三方返回的Token
    2. 通过三方Token去查三方API,得用户信息
    3. 根据用户信息再本地JWT生成Token(本地Token可以自定义有效期、数据格式等高级功能)
    4. 将Token+用户信息保存到Redis中
    5. 前端后续请求都携带此Token,验证通过后直接使用Redis中的用户信息
    6. 实现方式见[【OAuth】](#OAuth 2.0)
sequenceDiagram title 授权码模式 participant U as 前端 participant AS as 授权服务器(如微信端) participant B as 后端 participant RS as Redis U->>B: 1. 发送登录请求 B->>U: 2. 重定向到三方 U->>AS: 3. 请求授权码 AS->>B: 4.三方认证成功,返回授权码 AS-->>U: 认证失败 B->>AS: 5. 验证授权码 AS-->>U: 验证失败 AS->>B: 6. 验证成功返回三方【access token】 B->>AS: 7. 携带【access token】请求查询用户信息 AS->>B: 8. JWT封装用户信息为本地Token B->>RS: 9. {Token:用户信息}存入Redis RS->>U: 10. 三方登录完成

RBAC模型

  • RBAC 是系统鉴权的主流解决方案

    1. 降低维护成本:【用户 - 角色 - 权限】解耦,例如用户离职时,仅修改用户-角色关联表即可
    2. 权限结构清晰:用户只关联角色信息,角色只关联权限信息,权限信息是系统唯一鉴权标准
    3. 功能拓展性强:用户可以有多个角色,角色可以有多个权限,后期信息变化时只需要同步关联表即可
    4. 框架适配性强:Spring Security原生适配并推荐 RBAC 作为主流方案
  • 设计数据表

    1. 用户信息表user:维护用户基本信息
    2. 角色表role:维护系统含有哪些角色
    3. 权限表peimisson:维护系统含有哪些权限
    4. 用户-角色关联表user_role:一个用户可以有多个角色
    5. 角色-权限关联表role_permisssion:一个角色可以有多个权限
    6. 中大型系统:增加「部门、数据权限、资源、租户、角色约束」等表,实现精细化权限管控
mysql 复制代码
`user` (
    `phone` varchar(50) NOT NULL PRIMARY
    `name` varchar(50) NOT NULL
    `password` varchar(128) NOT NULL
)

`role` (
    `id` int NOT NULL PRIMARY
    `name` varchar(255) NOT NULL
)

`permission` (
    `code` varchar(20) NOT NULL PRIMARY
    `name` varchar(255) NOT NULL 
)

`user_role` (
  `phone` varchar(20) NOT NULL PRIMARY
  `role_id` int NOT NULL PRIMARY
)

`role_permission` (
  `role_id` int NOT NULL PRIMARY
  `permission_code` varchar(20) NOT NULL PRIMARY
)
  • 创建对应PO

    1. 用户只关联角色,角色只关联权限
    2. 逻辑关联,PO不物理关联
java 复制代码
@AllArgsConstructor
@NoArgsConstructor
@Data
public class UserPo {
    private String phone;
    private String name;
    private String password;
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class RolePo {
    private Integer id;
    private String name;
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PermissionPo {
    private String code;
    private String name;
}
  • 【用户-角色】一对多、【角色-权限】多对多,因此实现批量查询方式,否则在业务层循环查库性能极低
java 复制代码
public interface UserRoleDao {
    // 根据用户ID,查询关联的索引角色ID
    Set<Integer> getRoleIdsByPhone(@Param("phone") String phone);
}

public interface RoleDao {
	// 根据所有角色ID,一次性批量查询到所有的角色详细信息
    Set<String> batchGetRoleNames(@Param("roleIds") Set<Integer> roleIds);
}

public interface RolePermissionDao {
	// 根据所有的角色ID,一次性查询到所有关联的权限ID
    Set<String> batchGetPermissionCodesByRoleIds(@Param("roleIds") Set<Integer> roleId);
}

public interface PermissionDao {
	// 根据所有的权限ID,一次性查询到所有的权限详细信息
    Set<String> batchGetPermissionsByCode(@Param("codeSet") Set<String> codes);
}

Security客户端配置

java 复制代码
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true) // 启用@PreAuthorize
public class SecurityConfig {

    @Autowired
    private JwtRefreshFilter jwtRefreshFilter; // 自定义过滤器

    @Autowired
    private AuthenticationExceptionHandler authenticationExceptionHandler; // 认证异常处理器

    @Autowired
    private LogoutHandler logoutHandler; // 注销处理器

    @Autowired
    private AccessDeniedExceptionHandler accessDeniedExceptionHandler; // 鉴权异常处理器

    @Autowired 
    private UserDetailsService userDetailsService; // 用户信息加载实现类
    
    @Autowired
    private RedisClient redisClient; // redis缓存

    /**
     * 密码编码器
     * Spring Security 必须通过 PasswordEncoder 加密密码,若不配置该 Bean,启动会直接报错
     * 采用 BCrypt 哈希算法,不可逆,只能密文比较
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        // 关闭csrf防护,用于前后端分离场景
        http.csrf(AbstractHttpConfigurer::disable); 
        // 关闭Session,用于分布式场景
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // 注册web接口
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/user/login").permitAll()
                .requestMatchers("/test").permitAll()
                .requestMatchers(HttpMethod.POST, "/user/logout").permitAll()
                .anyRequest().authenticated()
        );

        // 注册自定义过滤器
        http.addFilterBefore(jwtRefreshFilter, UsernamePasswordAuthenticationFilter.class); 

        // 注册自定义处理器
        http.logout(logout -> logout
                .logoutUrl("/user/logout") // 自定义注销路径(替代默认 /logout)
                .logoutSuccessHandler(logoutHandler) // 你的自定义注销成功处理器
        );

        // 注册异常处理器
        http.exceptionHandling(ex -> ex
                .accessDeniedHandler(accessDeniedExceptionHandler) // 权限不足
                .authenticationEntryPoint(authenticationExceptionHandler) // 未认证
        );
        return http.build();
    }

    /**
     * SpringSecurity 认证管理器
     */
    @Bean
    public AuthenticationManager configureAuthenticationManager(PasswordEncoder passwordEncoder) {
        // 1. 创建 DaoAuthenticationProvider(用于用户名密码登录)
        DaoAuthenticationProvider daoAuthProvider = new DaoAuthenticationProvider();
        daoAuthProvider.setUserDetailsService(userDetailsService);
        daoAuthProvider.setPasswordEncoder(passwordEncoder);
        // 2.创建 SmsCodeAuthenticationProvider (用户验证码登录)
        SmsCodeAuthenticationProvider smsAuthProvider = new SmsCodeAuthenticationProvider();
        smsAuthProvider.setUserDetailsService(userDetailsService);
        smsAuthProvider.setRedisClient(redisClient);
        // 2. 返回包含所有 Provider 的 AuthenticationManager
        return new ProviderManager(Arrays.asList(
                daoAuthProvider,   // 用户名密码登录
                smsAuthProvider    // 短信登录
        ));
    }
}
  • 目前主流版本已升级到Security 6,如果老项目是Security 5配置格式会有差异
java 复制代码
@Configuration
@EnableWebSecurity // 开启认证功能
@EnableGlobalMethodSecurity(securedEnabled = true) // 注意这里是 @EnableGlobalMethodSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter { // Security5需要继承WebSecurityConfigurerAdapter

    /**
     * 密码编码器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable(); // 5.x的语法
        
        // 分布式场景,禁止Session传递
        http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        
        // 设置验证请求路径和鉴权规则
        http.authorizeRequests()
                .antMatchers(HttpMethod.POST, "/user/login").permitAll()
                .antMatchers(HttpMethod.POST, "/user/logout").permitAll()
                .anyRequest().authenticated();
        
        // 添加自定义过滤器
        http.addFilterBefore(jwtRefreshFilter(), UsernamePasswordAuthenticationFilter.class);
        
        // 配置注销
        http.logout()
                .logoutUrl("/user/logout") // 自定义注销路径
                .logoutSuccessHandler(logoutHandler()); // 注销成功处理器
        
        // 异常处理器
        http.exceptionHandling()
                .accessDeniedHandler(accessDeniedExceptionHandler()) // 权限异常处理器
                .authenticationEntryPoint(authenticationExceptionHandler()); // 鉴权异常处理器
    }
    
    /**
     * 认证管理器 - 5.x需要显式配置
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

UserDetails封装用户信息

  • Spring Security提供了UserDetails接口,用于本地登录充当用户信息实体类

    1. UserDetailsService只支持UserDetails传递用户信息
    2. UserDetails一定要配置安全的构造函数,防止传入冗余或敏感信息
    3. 角色信息必须以 ROLE_ 开头, 否则 Security 识别 hasRole() 表达式会出错
java 复制代码
/*
 * Spring Security 规定 UserDetailsService 接口只接受 UserDetails 类型
 * 禁止UserDetails父类getter参与Json序列化,引用引用外部类的安全信息
 * */
@Data
public class UserSecurityDetails implements UserDetails {

    // 引用外部类的安全信息
    private final UserDto userDto;

    public UserSecurityDetails(UserDto userDto) {
        this.userDto = Objects.requireNonNull(userDto);
    }

    @Override
    @JSONField(serialize = false)
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        //添加角色
        if (!CollectionUtils.isEmpty(userDto.getRoleNames())) {
            //将角色设置到GrantedAuthority中,官网要求角色要加上前缀 ROLE_xxx 区分其它权限
            userDto.getRoleNames()
                    .forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
        }
        //添加权限
        if (!CollectionUtils.isEmpty(userDto.getPermissionNames())) {
            //将权限设置到GrantedAuthority中
            userDto.getPermissionNames()
                    .forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission)));
        }
        return authorities;
    }

    @Override
    @JSONField(serialize = false)
    public String getPassword() {
        // 返回用户密码
        return userDto.getPassword();
    }

    @Override
    @JSONField(serialize = false)
    public String getUsername() {
        // 返回用户主键,注意不一定是姓名,比如这里是手机号
        return userDto.getPhone();
    }

    @Override
    @JSONField(serialize = false)
    public boolean isAccountNonExpired() {
        // 账号是否过期,如果在数据库中没有设置,给默认值不过期
        return true;
    }

    @Override
    @JSONField(serialize = false)
    public boolean isAccountNonLocked() {
        // 账号是否锁定,如果在数据库中没有设置,给默认值不锁定
        return true;
    }

    @Override
    @JSONField(serialize = false)
    public boolean isCredentialsNonExpired() {
        // 密码是否过期,如果在数据库中没有设置,给默认值不过期
        return true;
    }

    @Override
    @JSONField(serialize = false)
    public boolean isEnabled() {
        // 账号是否可用,如果在数据库中没有设置,给默认值可用
        return true;
    }
}
  • 外部映射DTO类中感冒信息不参与序列化
java 复制代码
@NoArgsConstructor
@Data
public class UserDto {

    // 用户基本信息
    private String phone;

    private String name;

    @JSONField(serialize = false) // 密码不参与序列化
    private String password;

    // 用户角色信息
    private Set<String> roleNames;

    // 角色权限信息
    private Set<String> permissionNames;

    public UserDto(String phone, String name, String password, Set<String> roleNames, Set<String> permissionNames) {
        this.phone = phone;
        this.name = name;
        this.password = password;
        this.roleNames = roleNames;
        this.permissionNames = permissionNames;
    }
}

UserDetailsService加载用户信息

  • 本地登录场景下,用户信息是存储在本地库的,不同登录方式的认证过程不同,但最终从本地库加载用户信息的逻辑是相同的
java 复制代码
@Component
public class UserSecurityService implements UserDetailsService {

    @Autowired
    private UserDao userDao;

    @Autowired
    private RoleDao roleDao;

    @Autowired
    private PermissionDao permissionDao;

    @Autowired
    private UserRoleDao userRoleDao;

    @Autowired
    private RolePermissionDao rolePermissionDao;

    @Override
    public UserDetails loadUserByUsername(String phone) {
        // 查询用户信息,防止用户未注册
        UserPo userPo = userDao.getUserByPhone(phone);
        if (userPo == null) {
            throw new UsernameNotFoundException("user not exist: " + phone);
        }
        // 查询对应角色信息
        Set<Integer> roleIdSet = userRoleDao.getRoleIdsByPhone(phone);
        if (CollectionUtils.isEmpty(roleIdSet)) {
            throw new UsernameNotFoundException("user have no role: " + phone);
        }
        Set<String> roleNameSet = roleDao.getRoleNameSet(roleIdSet);
        if (CollectionUtils.isEmpty(roleNameSet)) {
            throw new UsernameNotFoundException("role not found: " + roleIdSet);
        }
        // 查询角色对应权限信息
        Set<String> codeSet = rolePermissionDao.batchGetPermissionCodesByRoleIds(roleIdSet);
        if (CollectionUtils.isEmpty(codeSet)) {
            throw new UsernameNotFoundException("role have no permission: " + roleIdSet);
        }
        Set<String> permissionSet = permissionDao.getPermissionByCodes(codeSet);
        if (CollectionUtils.isEmpty(permissionSet)) {
            throw new UsernameNotFoundException("permission not found: " + codeSet);
        }
        UserDto userDto = new UserDto(
            userPo.getPhone(),
            userPo.getName(),
            userPo.getPassword(),
            roleNameSet,
            permissionSet
        );
        return new UserSecurityDetails(userDto);
    }
}

本地登录认证

  • 描述

    1. Security封装了本地用户密码认证过程,可以自动认证登录身份
    2. 本地其他方式登录实现对应的Provider即可
graph TD Q["前端登录请求"] -.->|重复登录|DL[直接返回前端:重复登录] Q -->|用户登录信息|D["手动创建未认证Authentication"] --> E["手动执行 AuthenticationManager.authenticate(未认证Authentication) 启动认证"] --> ST[/"AuthenticationManager会根据Authentication的具体类型调用对应AuthenticationProvider.authenticate()验证方法"/] ST -.->|抛出AuthenticationException| P["AuthenticationEntryPoint处理"] -.-> JS[返回前端:登录失败] ST --> |认证成功|I["调用UserDetailsService加载用户信息,返回对应的Authentication"] I -->|"将 (UserSecurityDetails) Authentication.getPrincipal()+Token 存入 Redis"| M[(Redis)] ---> N["登录成功:前端返回Token"]

本地密码登录

  • Security已经内置了UsernamePasswordAuthenticationToken+DaoAuthenticationProvider本地密码认证
  • 直接调用AuthenticationManager.authenticate(UsernamePasswordAuthenticationToken Authentication)即可
java 复制代码
/**
 * DaoAuthenticationProvider 源码
 */
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

	private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";

	private PasswordEncoder passwordEncoder;

	private volatile String userNotFoundEncodedPassword;

	private UserDetailsService userDetailsService;

	private UserDetailsPasswordService userDetailsPasswordService;
    
    // getter、setter...

	@Override
	@SuppressWarnings("deprecation")
	protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
		if (authentication.getCredentials() == null) {
			this.logger.debug("Failed to authenticate since no credentials provided");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
		String presentedPassword = authentication.getCredentials().toString();
		if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
			this.logger.debug("Failed to authenticate since password does not match stored value");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
	}

	@Override
	protected void doAfterPropertiesSet() {
		Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
	}

	@Override
	protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		prepareTimingAttackProtection();
		try {
			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
			if (loadedUser == null) {
				throw new InternalAuthenticationServiceException(
						"UserDetailsService returned null, which is an interface contract violation");
			}
			return loadedUser;
		}
		catch (UsernameNotFoundException ex) {
			mitigateAgainstTimingAttack(authentication);
			throw ex;
		}
		catch (InternalAuthenticationServiceException ex) {
			throw ex;
		}
		catch (Exception ex) {
			throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
		}
	}

	@Override
	protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
			UserDetails user) {
		boolean upgradeEncoding = this.userDetailsPasswordService != null
				&& this.passwordEncoder.upgradeEncoding(user.getPassword());
		if (upgradeEncoding) {
			String presentedPassword = authentication.getCredentials().toString();
			String newPassword = this.passwordEncoder.encode(presentedPassword);
			user = this.userDetailsPasswordService.updatePassword(user, newPassword);
		}
		return super.createSuccessAuthentication(principal, authentication, user);
	}

	private void prepareTimingAttackProtection() {
		if (this.userNotFoundEncodedPassword == null) {
			this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
		}
	}

	private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
		if (authentication.getCredentials() != null) {
			String presentedPassword = authentication.getCredentials().toString();
			this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
		}
	}
    
    // 父类已经指定了token类型为UsernamePasswordAuthenticationToken
    @Override
    public boolean supports(Class<?> authentication) {
        return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
    }
}

验证码本地登录

  • 自定义AuthenticationToken:用于传递用户输入的验证码和认证通过后的AuthenticationToken
java 复制代码
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private final transient Object principal; // 认证前放手机号,认证后放UserDetails

    private transient Object credentials; // 用户输入的验证码

    // 构造函数1:用于认证前(未认证状态)
    public SmsCodeAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false); // 初始状态:未认证
    }

    // 构造函数2:用于认证后(已认证状态
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true); // 认证成功状态
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    // 重要:防止意外调用setAuthenticated(true)破坏状态
    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor with GrantedAuthority list instead");
        }
        super.setAuthenticated(false);
    }
}
  • 自定义AuthenticationProvider:Redis验证通过后,加载用户信息,返回携带UserDetailsAuthenticationToken
java 复制代码
@Setter
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
    
    // 仿照DaoAuthenticationProvider
    private UserDetailsService userDetailsService;

    private RedisClient redisClient;

    @Override
    public Authentication authenticate(Authentication authentication) {
        String phone = authentication.getName();
        String inputCode = (String) authentication.getCredentials();
        // 1. 验证验证码(Redis校验)
        String storedCode = redisClient.get(SMS_CODE + phone);
        if (!StringUtils.hasText(storedCode) || !inputCode.equals(storedCode)) {
            throw new BadCredentialsException("验证码错误或已过期");
        }
        redisClient.delete(SMS_CODE + phone);
        // 2. 加载用户信息(复用UserDetailsService)
        UserDetails userDetails = userDetailsService.loadUserByUsername(phone);
        // 3. 返回认证成功的Token
        return new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 只处理自定义的 SmsCodeAuthenticationToken 类型
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

实现登录功能

  • 业务层实现时,可以将验证逻辑解耦出来
java 复制代码
@Service
@Slf4j
public class UserService {

    @Autowired
    private RedisClient redisClient;

    @Autowired
    private AuthenticationManager authenticationManager;

    // 用户登录,用户密码匹配模式
    public Result<String> loginByPassword(String phone, String password, String token) {
        Authentication unauthentication = new UsernamePasswordAuthenticationToken(phone, password);
        return doAuth(unauthentication, token);
    }

    // 用户登录,用户密码匹配模式
    public Result<String> loginBySms(String phone, String code, String token) {
        Authentication unauthentication = new SmsCodeAuthenticationToken(phone, code);
        return doAuth(unauthentication, token);
    }

    private Result<String> doAuth(Authentication unauthentication,String token) {
        try {
            Authentication authentication = authenticationManager.authenticate(unauthentication);
            // 认证成功,authentication即为已认证的token,其中Principal即为加载的SecurityDetails
            UserSecurityDetails userDetails = (UserSecurityDetails) authentication.getPrincipal();
            // 生成 JWT token
            String newToken = JWTUtil.generateToken(userDetails.getUsername());
            // 存入Redis,不传密码
            redisClient.set(
                    USERINFO_PREFIX + newToken,
                    JsonUtil.toJson(userDetails),
                    TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
            if (StringUtils.hasText(token)) {
                // 直接删除旧数据
                // 不能直接复用数据,用户可能会切换账户
                redisClient.delete(USERINFO_PREFIX + token);
            }
            // 登录成功,返回token
            return Result.ok(newToken);
        } catch (AuthenticationException e) {
            // 认证失败时会抛出异常(如 AuthenticationException),而不是返回 null
            log.error("用户认证失败: {}", e.getMessage());
            return Result.fail("认证失败");
        } catch (Exception e) {
            // 捕获所有其他异常
            log.error("用户登录失败: {}", e.getMessage());
            return Result.fail("登录失败,请联系管理员,异常信息:" + e.getMessage());
        }
    }
}

登录注销

  • Spring Security提供了LogoutSuccessHandler接口,可以不编写Controller方法快速实现注销功能

    1. 本地登出功能实现很简单,直接删除Redis中的信息即可,后续请求Redis查不到直接认为未登录即可
    2. 敏感应用场景(如银行、企业应用)可能还需要立即使所有已颁发的访问令牌全部失效(一般不涉及)
    3. 编写完成后,将登出处理器注册到SecurityFilterChain即可
java 复制代码
@Component
public class LogoutHandler implements LogoutSuccessHandler {

    @Autowired
    private RedisClient redisClient;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 这里演示本地登出删除Redis数据
        String token = request.getHeader("token");
        if (StringUtils.hasText(token)) {
            redisClient.delete(USERINFO_PREFIX + token);
        }
        response.setContentType(HttpConstants.HTTP_JSON_TYPE);
        response.getWriter().print(JsonUtil.toJson(Result.ok("已登出")));
    }
}

服务资源保护

  • 描述:访问受保护资源,在请求到达Servlet前先检查身份信息,用户已经登录时才能访问
flowchart TD AA["需要认证的请求"]--> |进入Spring Security|JWT subgraph JWT[自定义身份验证过滤器] F[/"检查 token 是否合法"/] F -.->|token非法、Redis过期 或 产生其他异常| EX[手动清空SecurityContextHolder] F -->|token合法| G[/"检验 token 是否临近过期"/] G -->|临近过期| H[生成新的token(JWT或者三方Token),同步到Redis] G -->|未临近过期| K[复用token] H --> L[将用户信息转换为 Authentication 存入 SecurityContextHolder] L --> DTO[将最新token放入响应头返回前端] end subgraph HOUXU["后续过滤器"] FF[其他自定义过滤器] end DTO --> HOUXU -.-> |"如果有鉴权(见下文鉴权模块)"|EXA[前端返回错误信息:权限错误] K --> L HOUXU --> |Security内置ExceptionTranslationFilter会自动检查SecurityContextHolder是否为空|ISNULL[/SecurityContextHolder是否为空/] ISNULL -.-> |AuthenticationEntryPoint|FAIL[前端返回错误信息:身份认证失败,重新登录] ISNULL ---> |不为空,认证成功|OK[请求进入Controller]

注册身份验证过滤器

  • 实现OncePerRequestFilter

    1. 对于不涉及认证的请求可以在过滤器中忽略(例如注册、登出、登录功能)
    2. Token过滤器只建立或清除安全上下文,产生的认证异常由Security后续组件处理
    3. 对于高频访问场景,可以优化Token复用:当临近过期时才重新生成Token
java 复制代码
/*
 * Spring Security 自定义 Filter
 * */
@Component
@Slf4j
public class TokenRefreshFilter extends OncePerRequestFilter {

    @Autowired
    private RedisClient redisClient;

    /**
     * 虽然SecurityFilterChain指定请求放行,但依然会经过过滤器,只是不抛出AuthenticationException异常
     * 但登录等请求不需要经过此过滤器,否则token会重复刷新
     */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // 获取当前请求路径
        String requestURI = request.getRequestURI();
        // 排除 路径(返回 true 表示不过滤该路径)
        return requestURI.startsWith("/user/login") || requestURI.startsWith("/user/register")
            || requestURI.startsWith("/user/logout");
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception {
        try {
            String token = request.getHeader(JWT_TOKEN_HEADER);
            // JWT检查(JWTUtil见JWT篇)
            if (!StringUtils.hasText(token) || !JWTUtil.checkToken(token)) {
                // 无token/过期,直接放行
                filterChain.doFilter(request, response);
                return;
            }
            //redis中获取用户信息
            String userInfo = redisClient.get(USERINFO_PREFIX + token);
            if (!StringUtils.hasText(userInfo)) {
                // 必须同时校验「JWT 有效性」和「Redis 状态」,且Redis 状态是最终判据(因为 Redis 可动态管控)
                filterChain.doFilter(request, response);
                return;
            }
            UserSecurityDetails userSecurityDetails = JsonUtil.parse(userInfo, UserSecurityDetails.class);
            if (ObjectUtils.isEmpty(userSecurityDetails)) {
                filterChain.doFilter(request, response);
                return;
            }
            // 刷新或复用Token
            String newToken = refreshExpire(token, userInfo);
            /*
             * 封装用户信息,保存到SecurityContextHolder中
             * principal:UserSecurityDetails类
             * credentials:用于存储用户密码,但已认证场景下建议传 null
             * authorities:权限集合,即用户权限信息
             * */
            // 将用户信息送到SpringSecurity上下文中
            Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
                    userSecurityDetails, null, userSecurityDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            // 最终放行请求(所有分支都执行到这里,保证请求传递到后续过滤器)
            response.setHeader(JWT_TOKEN_HEADER, newToken);
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            // 异常时清空上下文,这样保证Spring Security最终会拦截请求
            SecurityContextHolder.clearContext();
            filterChain.doFilter(request, response);
        }
    }

    // 刷新token有效期,选择复用token
    private String refreshExpire(String token, String userInfo) {
        Long expire = redisClient.getExpire(USERINFO_PREFIX + token);
        if (expire != -1 && expire < TOKEN_EXPIRE_IN) {
            // 有效期不足五分钟,重置Redis和Token有效期(JWTUtil见JWT篇)
            String newToken = JWTUtil.refreshToken(token);
            // 这里在高并发场景下有线程安全风险,可以使用分布式锁
            redisClient.set(USERINFO_PREFIX + newToken, userInfo, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
            redisClient.delete(USERINFO_PREFIX + token);
            return newToken;
        }
        // 直接复用token
        return token;
    }
}

配置AccessDeniedHandler异常处理器

  • Spring Security提供的权限异常处理器,注册到SecurityFilterChain即可,触发场景如下

    1. 过滤器中主动抛出AccessDeniedException
    2. authenticated()请求】不匹配【aceess()指定的权限】
    3. authenticated()请求】不匹配【@PreAuthorize指定的权限】
    4. 注意:业务层抛出的 AuthenticationException 不会触发 AuthenticationEntryPoint
java 复制代码
@Component
public class AccessDeniedExceptionHandler implements AccessDeniedHandler {
    
    @Override
    public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ex) 
        throws IOException, ServletException {
        res.setContentType(HttpConstants.HTTP_JSON_TYPE);
        res.getWriter().write(JsonUtil.toJson(Result.fail("权限不足")));
    }
}

鉴权

  • 描述:用户登录后(用户已登录是鉴权的前提),检查用户权限信息,如果权限不够拒绝访问此Web接口
flowchart TD subgraph F["后续过滤器"] FF[其他自定义过滤器] end %% 入口:过滤器触发 AA["前端发送需要鉴权的请求"]--> |进入Spring Security|JWT[/身份验证过滤器(见上文会话保持认证模块)/] JWT --> SC[用户信息加载到SecurityContextHolder] --> F --> |"Security内置的AuthorizationFilter会根据SecurityContextHolder自动比对角色+权限信息"|D[/【角色+权限】是否匹配设置的鉴权规则/] D -.-> |角色信息、权限信息匹配失败,自动抛出权限异常|EXA[AccessDeniedHandler处理器] -.-> END[前端返回错误信息:权限异常] D -----> |鉴权成功|OK[进入Controller层]

声明接口权限

  • 优先级说明

    1. ( ) 括号
    2. ! 逻辑非
    3. and 逻辑与
    4. or 逻辑或
  • Security 虽然支持【角色+权限】验证,但标准的RBAC鉴权标准是验证权限信息,如果不是兼容老项目,尽量不要使用角色验证

基于配置类声明接口权限

java 复制代码
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启鉴权
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http,
            JwtRefreshFilter jwtRefreshFilter,
            AuthenticationExceptionHandler authenticationExceptionHandler,
            LogoutHandler logoutHandler,
            AccessDeniedExceptionHandler accessDeniedExceptionHandler) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        http.authorizeHttpRequests(auth -> auth
                // 公共API接口
                .requestMatchers("/user/login", "user/logout").permitAll()
                // 登录后,具有 ALL 权限可以访问
                .requestMatchers("/user/all").hasAuthority("ALL")
                // 登录后,同时具有 ADD 和 UPDATE 权限可以访问
                .requestMatchers("/user/addAndUpdate").access(
                        new WebExpressionAuthorizationManager("hasAuthority('ADD') and hasAuthority('UPDATE')"))
                // 登录后,具有 ADD 或者 UPDATE 权限就可以访问
                .requestMatchers("/user/allOrUpdate").hasAnyAuthority("ALL", "UPDATE")
                // 登录后,具有SUPER_ADMIN角色才能访问
                .requestMatchers("/user/super").hasRole("SUPER_ADMIN")
                // 登录后,具有admin或者super角色才能访问
                .requestMatchers("/user/adminOrSuper").hasAnyRole("ADMIN", "SUPER_ADMIN")
                // 登录后,同时具有APPROVE和PL角色才能访问
                .requestMatchers("/user/approveAndPL").access(
                        new WebExpressionAuthorizationManager("hasRole('APPROVE') and hasRole('PL')"))
                // 登录后,具有admin或者super角色、或者具有 VIEW 或者 ADD 权限才可以访问
                .requestMatchers("/user/approveAndPL").access(
                        new WebExpressionAuthorizationManager(
                                "hasAnyRole('SUPER_ADMIN','ADMIN') " +
                                        "or" +
                                        "hasAuthority('VIEW') and hasAuthority('ADD')"
                        )
                )
                // 基于SpEL表达式的复杂控制
                .requestMatchers("/api/project/{id}/**")
                .access("@projectAccessControl.checkAccess(authentication, #id)")
                // 记住我功能的访问控制
                .requestMatchers("/api/profile/**").fullyAuthenticated()
                // 任何其他请求都需要认证
                .anyRequest().authenticated()
        );
        http.addFilterBefore(jwtRefreshFilter, UsernamePasswordAuthenticationFilter.class) // 添加过滤器
                //注册自定义处理器(注销处理器,注销成功后删除redis中的数据)
                .logout(logout -> logout
                        .logoutUrl("/user/logout") // 自定义注销路径(替代默认 /logout)
                        .logoutSuccessHandler(logoutHandler) // 你的自定义注销成功处理器
                )
                // 异常处理器(6.x 语法不变)
                .exceptionHandling(ex -> ex
                        .accessDeniedHandler(accessDeniedExceptionHandler) // 403 权限不足
                        .authenticationEntryPoint(authenticationExceptionHandler) // 401 未认证
                );
        http.rememberMe(remember -> remember
            	.tokenValiditySeconds(7 * 24 * 60 * 60) // 7天
        );
        return http.build();
    }
    
    /**
     * 密码编码器
     * Spring Security 必须通过 PasswordEncoder 加密密码,若不配置该 Bean,启动会直接报错
     * 采用 BCrypt 哈希算法,不可逆,只能密文比较
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

基于注解声明接口权限

java 复制代码
@RestController
@RequestMapping("user")
public class UserController {

    @PostMapping("/login") // 登录请求已经全局设置allPermission,不参与鉴权
    public void login(@Valid @RequestBody UserVo userVo, HttpServletRequest request, HttpServletResponse response) throws IOException {
		...
    }

    @PostMapping("/register") // 注册请求完全忽略Spring Security,不参与鉴权
    public void register(@Valid @RequestBody UserVo userVo, HttpServletResponse response) throws IOException {
		...
    }

    /**
     * 登录后,具有 ALL 权限可以访问
     */
    @PreAuthorize(value = "hasAuthority('ALL')")
    @GetMapping("/All")
    public Result all(){
        return Result.ok("ok");
    }

    /**
     * 登录后,具有 VIEW 或者 ALL 权限可以访问
     */
    @PreAuthorize(value = "hasAnyAuthority('VIEW','ALL')")
    @GetMapping( "/viewOrAll")
    public Result viewOrAll(){
        return Result.ok("ok");
    }

    /**
     * 登录后,同时具有 ADD 和 UPDATE 权限可以访问
     */
    @PreAuthorize(value = "hasAuthority('ADD') and hasAuthority('UPDATE')")
    @GetMapping( "/addAndUpdate")
    public Result addAndUpdate(){
        return Result.ok("ok");
    }

    /**
     * 登录后,具有SUPER_ADMIN角色才能访问
     */
    @PreAuthorize(value = "hasRole('SUPER_ADMIN')")
    @GetMapping(value = "/super")
    public Result superAdmin(){
        return Result.ok("ok");
    }

    /**
     * 登录后,具有admin或者super角色才能访问
     */
    @PreAuthorize(value = "hasAnyRole('SUPER_ADMIN','ADMIN')")
    @GetMapping(value = "/adminOrSuper")
    public Result adminOrSuper(){
        return Result.ok("ok");
    }

    /**
     * 登录后,同时具有APPROVE和LEADER角色才能访问
     */
    @PreAuthorize(value = "hasRole('APPROVE') and hasRole('LEADER')")
    @GetMapping(value = "/test5")
    public Result approveAndLeader(){
        return Result.ok("ok");
    }
    
    /**
     * 登录后,具有admin或者super角色、或者具有 VIEW 或者 ADD 权限才可以访问
     */
    @GetMapping("test")
    @PreAuthorize("hasAnyRole('SUPER_ADMIN','ADMIN') or ( hasAuthority('VIEW') and hasAuthority('ADD') )")
    public Result compound(){
        return Result.ok("ok");
    }
}

配置AuthenticationEntryPoint异常处理器

  • Spring Security 提供的认证异常处理器,[注册到SecurityFilterChain即可](#Spring Security 提供的认证异常处理器,注册到SecurityFilterChain即可,触发场景如下),触发场景如下

    1. 过滤器中主动抛出AuthenticationException
    2. 过滤器链结束后,SecurityContextHolder中信息为空则会自动抛AuthenticationException
    3. 注意:业务层抛出的 AuthenticationException 不会触发 AuthenticationEntryPoint
java 复制代码
@Component
public class AuthenticationExceptionHandler implements AuthenticationEntryPoint {
    /*
     * AuthenticationException处理器
     * Spring Security 会调用该接口的 commence() 方法处理未认证响应
     */
    @Override
    public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex) 
        throws IOException {
        res.setContentType(HttpConstants.HTTP_JSON_TYPE);
        res.getWriter().print(JsonUtil.toJson(Result.fail("用户身份认证异常")));
    }
}
相关推荐
Dwzun5 小时前
基于SpringBoot+Vue的农产品销售系统【附源码+文档+部署视频+讲解)
数据库·vue.js·spring boot·后端·毕业设计
MYMOTOE65 小时前
ISC-3000S的U-Boot 镜像头部解析
java·linux·spring boot
小橙编码日志5 小时前
分布式系统推送失败补偿场景【解决方案】
后端·面试
程序员根根5 小时前
Maven 核心知识点(核心概念 + IDEA 集成 + 依赖管理 + 单元测试实战)
后端
想用offer打牌5 小时前
RocketMQ如何防止消息丢失?😯
后端·面试·rocketmq
CoderYanger5 小时前
A.每日一题——3606. 优惠券校验器
java·开发语言·数据结构·算法·leetcode
谷哥的小弟5 小时前
Spring Framework源码解析——ConfigurableEnvironment
java·spring·源码
毕设源码-郭学长5 小时前
【开题答辩全过程】以 基于SpringBoot的宠物医院管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
利剑 -~5 小时前
设计java高并安全类
java·开发语言