1. 引入
引入security依赖, 版本随spring boot版本就可以, 版本不一致,可能会出现依赖冲突. 我当前使用的是3.5.9, 底层是spring-security 6.5.7
<!-- spring security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jjwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- 也可以选择 jjwt-gson -->
<scope>runtime</scope>
</dependency>
2. 整体架构
登录认证和鉴权的完整流程:
sequenceDiagram
participant C as 客户端
participant G as API网关
participant A as 认证服务
participant S as 系统服务
participant B as 业务服务
C ->> G: 1. 登录请求 (用户名、密码)
G ->> A: 2. 路由至认证服务
A ->> S: 3. 调用 getUerByName (Feign)
S -->> A: 4. 返回用户基础信息 (含角色)
A ->> A: 5. 验证密码,生成JWT
A -->> C: 6. 返回JWT令牌
C ->> G: 7. 业务请求 (Header: Bearer <JWT>)
G ->> G: 8. 校验JWT,解析出用户ID
G ->> B: 9. 转发请求,添加Header (X-User-Id)
Note over B: 10. 业务服务内
alt 白名单接口命中
B -->> C: 直接返回业务数据
end
B ->> B: 10.1 JWT过滤器从缓存查询用户
alt 缓存未命中
B -->> C: 拒绝访问
else 缓存命中
B -->> B: 从缓存获取完整用户信息
B ->> B: 10.4 构建Authentication对象
B ->> B: 10.5 设置SecurityContext
B --> B: 10.6 根据PreAuthorize进行鉴权
B -->> C: 11. 返回业务数据
end
3. spring security 配置
3.1 SecurityFilterChain 配置
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用跨域保护
.cors(AbstractHttpConfigurer::disable)
// 前后端访问, 禁用跨站保护
.csrf(AbstractHttpConfigurer::disable)
// 禁用Basic认证
.httpBasic(AbstractHttpConfigurer::disable)
// 禁用表单认证
.formLogin(AbstractHttpConfigurer::disable)
// 添加请求的鉴权拦截器
.addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class)
// 前后端分离禁用session
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorization -> {
// 通过yml 配置,控制哪些不需要spring security保护
if (!CollectionUtils.isEmpty(securityProperty.getExcludeAuth())) {
authorization.requestMatchers(securityProperty.getExcludeAuth().toArray(new String[0])).permitAll();
}
authorization.anyRequest().authenticated();
})
// 异常处理
.exceptionHandling(exception ->
// 未认证访问统一处理
exception.authenticationEntryPoint(myAuthenticationEntryPoint)
// 鉴权失败统一处理
.accessDeniedHandler(accessDeniedHandler));
return http.build();
}
3.2 AuthenticationManager 配置
AuthenticationManager 提供一个集中式的认证调度中心。它本身不直接执行认证逻辑,而是作为认证流程的入口,负责协调和管理具体的认证工作。
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
// 从 AuthenticationConfiguration 获取全局的 AuthenticationManager
return authConfig.getAuthenticationManager();
}
工作执行流程:
sequenceDiagram
participant Client
participant UPFilter as UsernamePassword-<br>AuthenticationFilter
participant AuthMan as AuthenticationManager
participant DaoAuthP as DaoAuthenticationProvider
participant UDS as UserDetailsService
participant PE as PasswordEncoder<br>(MyPasswordEncoder)
Client ->> UPFilter: 提交登录请求 (用户名/密码)
UPFilter ->> AuthMan: 传递未认证的<br>UsernamePasswordAuthenticationToken
AuthMan ->> DaoAuthP: 委托认证
DaoAuthP ->> UDS: 调用 loadUserByUsername(username)
UDS ->> DaoAuthP: 返回 UserDetails<br>(包含数据库存储的加密密码)
DaoAuthP ->> PE: 调用 matches(rawPassword,<br>encodedPassword)
PE ->> DaoAuthP: 返回密码比对结果 (true/false)
Note over DaoAuthP, PE: 核心调用点
alt 密码匹配成功
DaoAuthP ->> AuthMan: 返回已认证的<br>Authentication 对象
AuthMan ->> UPFilter: 返回认证结果
UPFilter ->> Client: 认证成功处理
else 密码匹配失败
DaoAuthP ->> AuthMan: 抛出 BadCredentialsException
AuthMan ->> UPFilter: 抛出认证异常
UPFilter ->> Client: 认证失败处理
end
3.3 PasswordEncoder 自定义
实现PasswordEncoder接口, 自定义加密方式和匹配方式, 并注册为bean
@RequiredArgsConstructor
@Component
public class MyPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return encodePwd(rawPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodePwd(rawPassword).equals(encodedPassword);
}
/**
* 处理密码
*
* @param rawPassword
* @return
*/
private String encodePwd(CharSequence rawPassword) {
// todo 自定义实现密码加密方式, 可以用 sm3等
return rawPassword;
}
}
3.4 AuthenticationEntryPoint, AccessDeniedHandler 配置,
实现AuthenticationEntryPoint,AccessDeniedHandler,实现认证,鉴权异常的统一响应处理
/**
* 未认证访问受保护的资源处理
*/
@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
Map<String, Object> body = new HashMap<>();
body.put("status", HttpStatus.UNAUTHORIZED.value());
body.put("error", "Unauthorized");
body.put("message", "需要认证后才能访问该资源");
body.put("path", request.getServletPath());
body.put("timestamp", Instant.now().toString());
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
}
}
/**
* 已认证但权限不足处理
*/
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
Map<String, Object> body = new HashMap<>();
body.put("status", HttpStatus.FORBIDDEN.value());
body.put("error", "Forbidden");
body.put("message", "权限不足,无法访问该资源");
body.put("path", request.getServletPath());
body.put("timestamp", Instant.now().toString());
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
}
}
3.5 UserDetailService 自定
实现UserDetailsService 接口, 返回实现UserDetails接口的对象, 接收的会是用户名, 查询具体用户的密码,角色等信息
@RequiredArgsConstructor
@Component
public class MyUserDetailService implements UserDetailsService {
private final SystemFeignClient systemFeignClient;
@Override
public UserDetail loadUserByUsername(String username) throws UsernameNotFoundException {
// 获取用户详细信息
ResponseEntity<UserPasswordDto> userInfo = systemFeignClient.getByUserName(username);
UserPasswordDto body = userInfo.getBody();
if (Objects.isNull(body)) {
throw new UsernameNotFoundException(username);
}
// 自定义返回内容
return UserDetail.builder()
.userName(body.getUserName())
.passWord(body.getPassword())
.userId(body.getUserId())
.authoritiesInfo(body.getRoles().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet()))
.build();
}
}
3.6 开启方法级别鉴权
标注 @EnableMethodSecurity 开启方法注解 @PreAuthorize, 框架会根据hasRole 和 hasAuthority等注解,等录人员的权限中去鉴权, 其中 role是特殊的权限, 生成GrantedAuthority 时,需要加 "ROLE_"前缀
/**
* spring security配置
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// todo 具体bean配置
}
3.7 AuthorizationManager精细化或批量处理鉴权
除了使用3.6的 @PreAuthorize注解实现鉴权外,如有特殊需求,或根据特殊条件实现鉴权,可以实现AuthorizationManager
public class MyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
HttpServletRequest request = context.getRequest();
String requestURI = request.getRequestURI();
Authentication auth = authentication.get();
if (requestURI.startsWith("/api/special")) {
// 处理特定的一类接口,做精细化的鉴权
// 动态权限检查逻辑
boolean hasPermission = checkPermission(auth, requestURI, request.getMethod());
return new AuthorizationDecision(hasPermission);
}
return new AuthorizationDecision(Boolean.TRUE);
}
@Override
public void verify(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
AuthorizationDecision decision = check(authentication, object);
if (decision == null || !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
}
private boolean checkPermission(Authentication auth, String uri, String method) {
// 实现具体的权限检查逻辑
// 可以从数据库加载权限规则进行匹配
return true;
}
}
自定义的MyAuthorizationManager 可以加全局注册,也可以在SecurityFilterChain中配置
// 只针对/api/special/**的接口
authorization.requestMatchers("/api/special/**").
access(new MyAuthorizationManager())
4. 登录认证
4.1 自定义登陆
自定义登录,并返回jwt
@PostMapping("/login")
public TokenDto login(@RequestBody @Validated LoginDto loginDto) {
// 校验验证码
String code = loginDto.getCode();
if (!redisUtil.hasKey(GlobalConstant.CAPTCHA_REDIS_PREFIX_KEY + code)) {
throw new BusinessException("验证码错误");
}
// 2. 🔑 调用Spring Security进行核心认证
// 创建一个未认证的Authentication对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUserName(),
loginDto.getPassword());
Authentication authentication;
try {
// 调用认证管理器,这会触发你的UserDetailsService和PasswordEncoder
authentication = authenticationManager.authenticate(authenticationToken);
} catch (AuthenticationException e) {
// todo 认证失败处理,如记录日志
throw new BusinessException("用户名或密码错误");
}
// 3. ✅ 认证成功后的自定义操作
// 将认证信息存入安全上下文,后续请求即可识别为已登录
SecurityContextHolder.getContext().setAuthentication(authentication);
// 获取登录的用户信息
if (authentication.getPrincipal() instanceof UserDetail userDetail) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userDetail.getUserId());
return jwtTokenUtil.generateToken(userDetail.getUsername(), claims);
}
throw new BusinessException("生成认证信息失败");
}
5. 鉴权处理
在方法上添加注解, 如
@PreAuthorize("hasRole('ADMIM') or hasAuthority('permission:save')")
@PostMapping("/saveOrUpdate")
public ResponseEntity<Boolean> saveOrUpdate(@RequestBody @Validated PermissionDto dto) {
return ResponseEntity.ok(permissionService.saveOrUpdate(dto));
}
6. 过滤器
- 我是微服务项目, 在gateway 结合GlobalFilter拦截请求的jwt , 解析jwt的用户id ,放到请求头作为下游服务的校验信息
- 下游服务的通用过滤器,获取请求头中的用户id,去缓存里获取用户信息, 生成登陆信息
AuthUserDetail userDetail = new AuthUserDetail(loginUserVo);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetail,
null,
userDetail.getAuthorities());
// 前后端分离项目必须执行
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
7. 总结
记录一下搭建权限体系的过程,防止遗忘, 有不足的地方,欢迎提意见