在当今的企业级应用开发中,安全认证和权限控制是每个系统不可或缺的核心功能。作为一名长期从事Java后端开发的工程师,我经历过多种安全方案的选型和实现,其中Spring Security与JWT的组合可以说是目前最为主流和可靠的解决方案之一。
这个方案主要解决三个核心问题:
我最近在一个体育赛事管理系统中完整实现了这套方案,实测下来系统安全性、扩展性和性能表现都很不错。下面我将从原理到实践,详细拆解每个关键环节的实现过程。
在技术选型阶段,我们对比了几种常见方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Session-Cookie | 实现简单,Servlet原生支持 | 服务器存储压力大,不适合分布式 |
| OAuth2 | 标准协议,适合开放平台 | 实现复杂,过度设计 |
| Spring Security+JWT | 无状态,适合分布式,灵活 | 需要自行处理令牌失效 |
最终选择Spring Security + JWT主要基于以下考虑:
JWT(JSON Web Token)本质上是一个经过数字签名的JSON对象,由三部分组成:
Header:指定签名算法(如HS256)
json复制{
"alg": "HS256",
"typ": "JWT"
}
Payload:携带的用户声明(claims)
json复制{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Signature:对前两部分的签名,防止篡改
code复制HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
令牌生成后,客户端每次请求都在Authorization头中携带:
code复制Authorization: Bearer <token>
安全提示:务必使用足够复杂的密钥(推荐至少32字符),并设置合理的过期时间(通常2-4小时)
我们采用标准的RBAC(基于角色的访问控制)模型,主要包含5张表:
sql复制CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`status` tinyint DEFAULT '1',
PRIMARY KEY (`id`)
);
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `sys_user_role` (
`user_id` bigint NOT NULL,
`role_id` bigint NOT NULL,
PRIMARY KEY (`user_id`,`role_id`)
);
CREATE TABLE `sys_menu` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`url` varchar(200) DEFAULT NULL,
`perms` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `sys_role_menu` (
`role_id` bigint NOT NULL,
`menu_id` bigint NOT NULL,
PRIMARY KEY (`role_id`,`menu_id`)
);
在实际项目中,我们优化了权限查询的SQL,避免N+1问题:
java复制@Mapper
public interface UserMapper {
@Select("SELECT DISTINCT m.perms " +
"FROM sys_user_role ur " +
"JOIN sys_role_menu rm ON ur.role_id = rm.role_id " +
"JOIN sys_menu m ON rm.menu_id = m.id " +
"WHERE ur.user_id = #{userId} AND m.perms IS NOT NULL")
List<String> selectPermsByUserId(Long userId);
}
核心配置类需要继承WebSecurityConfigurerAdapter:
java复制@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 启用方法级安全控制
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationFilter jwtFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 使用BCrypt强哈希加密
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().and() // 启用CORS
.csrf().disable() // 禁用CSRF(因使用JWT)
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话
.and()
.authorizeRequests()
.antMatchers("/auth/login").permitAll() // 登录接口放行
.antMatchers("/swagger-ui/**").permitAll() // Swagger文档
.anyRequest().authenticated(); // 其他接口需认证
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
}
实现UserDetailsService加载用户信息:
java复制@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) {
User user = userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 查询用户权限
List<SimpleGrantedAuthority> authorities = userMapper.selectPermsByUserId(user.getId())
.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
authorities);
}
}
java复制@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenProvider tokenProvider;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
// 1. 认证用户名密码
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// 2. 生成JWT
String token = tokenProvider.generateToken(authentication);
return ResponseEntity.ok(new JwtResponse(token));
}
}
java复制@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication", ex);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
Spring Security提供多种权限注解:
java复制@RestController
@RequestMapping("/api/users")
public class UserController {
@PreAuthorize("hasRole('ADMIN')") // 仅管理员可访问
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
@PreAuthorize("hasAuthority('user:write')") // 需要特定权限
@PostMapping
public User createUser(@RequestBody User user) {
return userService.save(user);
}
}
对于更复杂的场景,可以实现PermissionEvaluator:
java复制@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication authentication,
Object targetDomainObject,
Object permission) {
// 实现自定义权限逻辑
return true;
}
}
然后在配置类中启用:
java复制@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Autowired
private CustomPermissionEvaluator permissionEvaluator;
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(permissionEvaluator);
return handler;
}
}
问题场景:用户修改密码后,已发放的JWT仍然有效
解决方案:
java复制public class JwtTokenProvider {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void invalidateToken(String token) {
String username = getUsernameFromToken(token);
long expireTime = getExpirationFromToken(token).getTime();
long currentTime = System.currentTimeMillis();
if (expireTime > currentTime) {
redisTemplate.opsForValue().set(
"blacklist:" + token,
username,
expireTime - currentTime,
TimeUnit.MILLISECONDS
);
}
}
}
问题场景:前端请求时出现CORS错误
解决方案:配置Spring Security的CORS
java复制@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
java复制@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.initialize();
return executor;
}
}
java复制@RestController
@RequestMapping("/auth")
public class AuthController {
private final RateLimiter rateLimiter = RateLimiter.create(5.0); // 每秒5个请求
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
if (!rateLimiter.tryAcquire()) {
throw new RuntimeException("请求过于频繁");
}
// ...原有逻辑
}
}
java复制@SpringBootTest
public class AuthServiceTest {
@Autowired
private AuthService authService;
@Test
public void testLoginSuccess() {
LoginRequest request = new LoginRequest("admin", "123456");
String token = authService.login(request);
assertNotNull(token);
}
@Test
public void testLoginFailure() {
LoginRequest request = new LoginRequest("admin", "wrong");
assertThrows(AuthenticationException.class, () -> {
authService.login(request);
});
}
}
java复制@AutoConfigureMockMvc
@SpringBootTest
public class SecurityIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testUnauthorizedAccess() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
@Test
public void testAuthorizedAccess() throws Exception {
String token = obtainToken("admin", "123456");
mockMvc.perform(get("/api/users")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
}
密钥管理:不要将JWT密钥硬编码在代码中,应使用环境变量或配置中心
yaml复制# application.yml
jwt:
secret-key: ${JWT_SECRET:defaultStrongSecretKey}
expiration: 86400000 # 24小时
健康检查:确保/actuator/health接口不被安全拦截
java复制.antMatchers("/actuator/health").permitAll()
日志脱敏:过滤日志中的敏感信息(如令牌)
java复制@Component
public class SensitiveDataFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// 实现日志脱敏逻辑
}
}
在微服务场景中,可以考虑:
| 方案 | 适用场景 | 与JWT对比优势 |
|---|---|---|
| Session | 单体应用 | 服务端可控性强 |
| OAuth2 | 第三方应用集成 | 标准化程度高 |
| CAS | 多系统SSO | 集中式管理 |
| JWT | 分布式/无状态架构 | 性能好,扩展性强 |
在实际项目中,我曾遇到过需要同时支持JWT和Session的方案,这时可以通过配置灵活切换:
java复制@Profile("jwt")
@Configuration
public class JwtSecurityConfig extends WebSecurityConfigurerAdapter {
// JWT配置
}
@Profile("session")
@Configuration
public class SessionSecurityConfig extends WebSecurityConfigurerAdapter {
// Session配置
}
经过多个项目的实战,我总结了以下几点经验:
一个常见的坑是忘记处理密码编码问题。记得在用户注册时就要加密存储密码:
java复制@Transactional
public User register(User user) {
if (userRepository.existsByUsername(user.getUsername())) {
throw new RuntimeException("用户名已存在");
}
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
}
调试工具:
学习资源:
辅助库:
不同版本的组合可能有差异:
| Spring Boot | Spring Security | JJWT | 注意事项 |
|---|---|---|---|
| 2.7.x | 5.7.x | 0.11.2 | 推荐稳定组合 |
| 3.0.x | 6.0.x | 0.11.5 | 需要Java 17+ |
| 2.4.x | 5.4.x | 0.9.1 | 旧项目维护 |
升级时特别注意:
WebSecurityConfigurerAdaptercode复制src/main/java
├── config
│ ├── SecurityConfig.java
│ └── MethodSecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── service
│ ├── AuthService.java
│ └── UserService.java
├── security
│ ├── JwtTokenProvider.java
│ ├── JwtAuthenticationFilter.java
│ └── CustomUserDetails.java
├── repository
│ └── UserRepository.java
└── exception
├── GlobalExceptionHandler.java
└── JwtAuthenticationException.java
javascript复制// 登录请求示例
async function login(username, password) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
localStorage.setItem('token', data.token); // 或使用HttpOnly Cookie
}
// 请求拦截器
axios.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器(处理401)
axios.interceptors.response.use(
response => response,
error => {
if (error.response.status === 401) {
// 跳转登录页
}
return Promise.reject(error);
}
);
建议监控以下关键指标:
使用Micrometer暴露指标:
java复制@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags(
"application", "auth-service"
);
}
接口契约:明确定义认证错误码:
java复制public enum AuthErrorCode {
INVALID_TOKEN(1001, "无效令牌"),
EXPIRED_TOKEN(1002, "令牌过期"),
// ...
}
Code Review重点:
文档模板:
markdown复制## 认证流程
1. 获取令牌 POST /auth/login
2. 接口请求 Header: `Authorization: Bearer <token>`
## 错误处理
| 状态码 | 错误码 | 说明 |
|--------|--------|-------------|
| 401 | 1001 | 无效令牌 |
上线前务必验证:
案例1:直接比较加密密码
java复制// 错误做法
if (user.getPassword().equals(inputPassword)) {
// ...
}
// 正确做法
passwordEncoder.matches(inputPassword, user.getPassword());
案例2:过度信任JWT内容
java复制// 错误做法
String username = jwt.getClaim("username").asString();
// 正确做法
String username = SecurityContextHolder.getContext()
.getAuthentication().getName();
案例3:忽略令牌刷新
java复制// 应该实现类似逻辑
if (tokenProvider.shouldRefreshToken(token)) {
String newToken = tokenProvider.refreshToken(token);
response.setHeader("New-Token", newToken);
}
在4核8G的测试环境中,使用JMeter压测结果:
| 场景 | 吞吐量 (req/s) | 平均延迟 (ms) | 错误率 |
|---|---|---|---|
| 无认证简单接口 | 3200 | 12 | 0% |
| JWT认证+权限校验 | 1800 | 28 | 0% |
| 动态权限查询 | 950 | 65 | 0% |
优化建议:
定期检查:
工具推荐:
当系统需要与其他语言交互时:
示例Python验证JWT:
python复制import jwt
def validate_jwt(token, secret):
try:
payload = jwt.decode(token, secret, algorithms=['HS256'])
return payload
except jwt.ExpiredSignatureError:
raise Exception("Token expired")
except jwt.InvalidTokenError:
raise Exception("Invalid token")
移动端面临的独特挑战:
Android示例:
kotlin复制fun storeToken(context: Context, token: String) {
val sharedPref = context.getSharedPreferences("auth", Context.MODE_PRIVATE)
with(sharedPref.edit()) {
putString("jwt_token", token)
apply()
}
}
前端集成时注意:
HttpOnly Cookie:防止XSS攻击窃取令牌
java复制ResponseCookie cookie = ResponseCookie.from("token", token)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
CSP策略:内容安全策略防止注入
code复制Content-Security-Policy: default-src 'self'
HSTS头:强制HTTPS
java复制http.headers().httpStrictTransportSecurity()
.includeSubDomains(true)
.preload(true);
关键日志记录点:
登录成功/失败:
java复制log.info("用户{}登录成功,IP:{}", username, request.getRemoteAddr());
权限拒绝:
java复制log.warn("用户{}尝试访问未授权资源:{}",
SecurityContextHolder.getContext().getAuthentication().getName(),
request.getRequestURI());
令牌操作:
java复制log.debug("为用户{}生成令牌,有效期:{}分钟", username, expiration);
日志脱敏处理:
java复制public String maskToken(String token) {
if (token == null || token.length() < 10) return "<invalid>";
return token.substring(0, 4) + "****" + token.substring(token.length()-4);
}
错误消息国际化:
定义消息资源:
properties复制# messages.properties
security.invalidToken=无效的认证令牌
security.expiredToken=令牌已过期
异常处理器:
java复制@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthException(
AuthenticationException ex, Locale locale) {
String message = messageSource.getMessage(
"security." + ex.getClass().getSimpleName(),
null, locale);
return ResponseEntity.status(401).body(new ErrorResponse(message));
}
使用Swagger展示认证接口:
java复制@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.info(new Info().title("认证服务API"));
}
}
安全组件需要特别谨慎的发布:
安全相关依赖应固定版本:
xml复制<properties>
<spring-security.version>5.7.3</spring-security.version>
<jjwt.version>0.11.5</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring-security.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
</dependencies>
提升开发效率的技巧:
测试用户脚本:快速生成测试用户
sql复制INSERT INTO sys_user VALUES(1,'admin','$2a$10$xVCHQ...',1);
Mock令牌工具:
java复制@Test
public void testWithMockUser() {
mockMvc.perform(get("/api/users")
.with(SecurityMockMvcRequestPostProcessors
.jwt().jwt(jwt -> jwt.claim("scope", "admin"))))
.andExpect(status().isOk());
}
开发配置:本地使用简单密钥
yaml复制# application-dev.yml
jwt:
secret-key: devOnlySimpleKey
强制密码复杂度:
java复制@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder() {
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
// 先验证密码复杂度
if (!checkComplexity(rawPassword)) {
throw new IllegalArgumentException("密码不符合复杂度要求");
}
return super.matches(rawPassword, encodedPassword);
}
private boolean checkComplexity(CharSequence password) {
// 至少8位,包含大小写和数字
String pattern = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$";
return password.toString().matches(pattern);
}
};
}
防止暴力破解:
java复制public class LoginAttemptService {
private final int MAX_ATTEMPTS = 5;
private Map<String, Integer> attemptsCache = new ConcurrentHashMap<>();
public void loginFailed(String key) {
int attempts = attemptsCache.getOrDefault(key, 0);
attemptsCache.put(key, attempts + 1);
}
public boolean isBlocked(String key) {
return attemptsCache.getOrDefault(key, 0) >= MAX_ATTEMPTS;
}
}
// 在认证逻辑中使用
if (loginAttemptService.isBlocked(username)) {
throw new LockedException("账户已锁定,请稍后再试");
}
限制同一账户的多设备登录:
java复制public class ConcurrentSessionControl {
private Map<String, String> activeSessions = new ConcurrentHashMap<>();
public void registerLogin(String username, String sessionId) {
activeSessions.put(username, sessionId);
}
public void validateConcurrentLogin(String username, String currentSession) {
String activeSession = activeSessions.get(username);
if (activeSession != null && !activeSession.equals(currentSession)) {
throw new ConcurrentLoginException("账户已在其他地方登录");
}
}
}
增强HTTP安全头:
java复制@Override
protected void configure(HttpSecurity http) throws Exception {
http.headers()
.xssProtection()
.and()
.contentSecurityPolicy("default-src 'self'")
.and()
.frameOptions().deny()
.and()
.httpStrictTransportSecurity()
.includeSubDomains(true)
.preload(true);
}
制定安全事件响应流程:
密钥泄露:
暴力攻击:
权限绕过:
安全相关的测试用例:
java复制@Test
public void testAdminAccessNormalUserEndpoint() throws Exception {
mockMvc.perform(get("/admin/users")
.with(SecurityMockMvcRequestPostProcessors
.jwt().jwt(jwt -> jwt.claim("scope", "user"))))
.andExpect(status().isForbidden());
}
@Test
public void testExpiredToken() throws Exception {
String expiredToken = createExpiredToken();
mockMvc.perform(get("/api/users")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
使用OWASP Dependency-Check:
xml复制<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>7.1.0</version>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
定期检查安全漏洞:
code复制mvn dependency-check:check
使用专业密钥管理服务:
集成示例(AWS KMS):
java复制public class KmsSecretProvider {
private final AWSKMS kmsClient;
public String getJwtSecret() {
DecryptRequest request = new DecryptRequest()
.withCiphertextBlob(ByteBuffer.wrap(
Base64.getDecoder().decode(encryptedSecret)));
ByteBuffer plaintext = kmsClient.decrypt(request).getPlaintext();
return new String(plaintext.array(), StandardCharsets.UTF_8);
}
}
向零信任安全模型演进:
java复制public class ZeroTrustFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) {
// 验证设备指纹
String deviceId = request.getHeader("X-Device-ID");
if (!deviceService.isTrustedDevice(deviceId)) {
throw new UntrustedDeviceException();
}
// 其他验证逻辑...
}
}
团队应遵守的规范: