身份认证与会话管理
身份认证 (Authentication) 是确认识别用户身份的过程。会话管理 (Session Management) 负责在认证成功后维护用户的状态机制。本章介绍 Web 应用中主流的认证方案,包括传统的 Session-Cookie 机制与现代的令牌认证。
认证方式概览
Web 应用中的身份认证主要包含以下几种方式:
| 认证方式 | 特点 | 适用场景 |
|---|---|---|
| Session-Cookie | 服务器端存储会话状态 | 传统 Web 应用、服务端渲染 |
| JWT | 无状态、自包含的令牌 | 分布式系统、移动应用、SPA |
| OAuth 2.1 | 第三方授权标准 | 社交登录、API 授权 |
| OpenID Connect | 基于 OAuth 2.1 的身份层 | 单点登录、身份联邦 |
| HTTP Basic/Digest | 简单的 HTTP 层认证 | 内部 API、快速原型 |
Session-Cookie 认证
Session-Cookie 是最传统、最广泛使用的认证机制。它的核心思想是在服务器端存储用户的会话状态,通过 Cookie 将会话标识传递给客户端。
工作原理
┌─────────┐ ┌─────────┐
│ Client │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ 1. 登录请求 (username, password) │
│ ───────────────────────────────────────────> │
│ │
│ 2. 验证成功,创建 Session │
│ 3. 生成 Session ID │
│ 4. 存储 Session 数据到内存/Redis/数据库 │
│ │
│ 5. 返回响应,设置 Cookie: SESSION_ID=xxx │
│ <─────────────────────────────────────────── │
│ │
│ 6. 后续请求自动携带 Cookie │
│ ───────────────────────────────────────────> │
│ 7. 服务器根据 Session ID 查找会话状态 │
│ 8. 验证通过,返回受保护资源 │
│ <─────────────────────────────────────────── │
完整实现示例(Java Spring Boot)
/**
* Session 配置类
* 配置会话超时、Cookie 安全属性等
*/
@Configuration
public class SessionConfig {
@Bean
public ServletListenerRegistrationBean<HttpSessionEventPublisher>
httpSessionEventPublisher() {
return new ServletListenerRegistrationBean<>(
new HttpSessionEventPublisher()
);
}
}
/**
* 登录控制器
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 用户登录
* 验证成功后创建 Session
*/
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest request,
HttpSession session) {
// 1. 查找用户
User user = userService.findByUsername(request.getUsername());
if (user == null) {
return ResponseEntity.status(401)
.body("用户名或密码错误");
}
// 2. 验证密码
if (!passwordEncoder.matches(request.getPassword(),
user.getPassword())) {
// 记录失败日志
log.warn("登录失败: {}", request.getUsername());
return ResponseEntity.status(401)
.body("用户名或密码错误");
}
// 3. 检查账户状态
if (!user.isEnabled()) {
return ResponseEntity.status(403)
.body("账户已被禁用");
}
// 4. 创建 Session(关键步骤)
session.setAttribute("userId", user.getId());
session.setAttribute("username", user.getUsername());
session.setAttribute("role", user.getRole());
session.setAttribute("loginTime", new Date());
// 5. 记录登录日志
log.info("用户登录成功: {}, Session ID: {}",
user.getUsername(), session.getId());
return ResponseEntity.ok(new LoginResponse(
user.getUsername(),
user.getRole(),
"登录成功"
));
}
/**
* 用户登出
* 使当前 Session 失效
*/
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpSession session) {
String username = (String) session.getAttribute("username");
// 使 Session 失效
session.invalidate();
log.info("用户登出: {}", username);
return ResponseEntity.ok("登出成功");
}
/**
* 获取当前登录用户信息
*/
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser(HttpSession session) {
String userId = (String) session.getAttribute("userId");
if (userId == null) {
return ResponseEntity.status(401).body("未登录");
}
return ResponseEntity.ok(Map.of(
"userId", userId,
"username", session.getAttribute("username"),
"role", session.getAttribute("role"),
"loginTime", session.getAttribute("loginTime")
));
}
}
/**
* Session 拦截器
* 验证用户是否已登录
*/
@Component
public class SessionInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 放行登录接口
if (request.getRequestURI().startsWith("/api/auth/")) {
return true;
}
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("userId") == null) {
response.setStatus(401);
response.getWriter().write("未登录或会话已过期");
return false;
}
// 将会话信息存入请求属性,供后续使用
request.setAttribute("currentUser",
new CurrentUser(
(String) session.getAttribute("userId"),
(String) session.getAttribute("username"),
(String) session.getAttribute("role")
)
);
return true;
}
}
安全 Cookie 配置
/**
* 安全配置
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository
.withHttpOnlyFalse())
)
.sessionManagement(session -> session
// 会话创建策略
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
// 最大并发会话数
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry())
);
return http.build();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
/**
* 自定义 Cookie 序列化器
* 设置安全的 Cookie 属性
*/
@Component
public class SecureCookieSerializer
implements CookieSerializer {
@Override
public void writeCookieValue(CookieValue cookieValue) {
HttpServletRequest request = cookieValue.getRequest();
HttpServletResponse response = cookieValue.getResponse();
String cookieValueStr = cookieValue.getCookieValue();
StringBuilder cookie = new StringBuilder();
cookie.append("SESSION=").append(cookieValueStr);
// 设置 Cookie 属性
cookie.append("; Path=/");
cookie.append("; HttpOnly"); // 禁止 JavaScript 访问
cookie.append("; Secure"); // 仅 HTTPS 传输
cookie.append("; SameSite=Strict"); // 防止 CSRF
cookie.append("; Max-Age=1800"); // 30 分钟过期
response.addHeader("Set-Cookie", cookie.toString());
}
@Override
public String readCookieValue(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("SESSION".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
Session 存储方案对比
- 内存存储(默认):适用于单服务器部署,配置简单,但无法跨节点共享会话。
- Redis 存储:适用于分布式部署,支持高并发会话同步。
Session 安全最佳实践
/**
* Session 安全服务
*/
@Service
public class SessionSecurityService {
@Autowired
private HttpSession session;
/**
* 重新生成 Session ID
* 防止会话固定攻击
*/
public void regenerateSessionId(HttpServletRequest request) {
HttpSession oldSession = request.getSession(false);
Map<String, Object> attributes = new HashMap<>();
// 保存旧会话数据
if (oldSession != null) {
oldSession.getAttributeNames().asIterator()
.forEachRemaining(name ->
attributes.put(name, oldSession.getAttribute(name))
);
oldSession.invalidate();
}
// 创建新会话
HttpSession newSession = request.getSession(true);
attributes.forEach(newSession::setAttribute);
log.info("Session ID 已重新生成");
}
/**
* 验证会话合法性
* 检查 IP 地址和用户代理是否一致
*/
public boolean validateSession(HttpServletRequest request) {
String sessionIp = (String) session.getAttribute("ipAddress");
String sessionUserAgent = (String) session.getAttribute("userAgent");
String currentIp = getClientIpAddress(request);
String currentUserAgent = request.getHeader("User-Agent");
// IP 地址检查(可选,考虑动态 IP 情况)
if (sessionIp != null && !sessionIp.equals(currentIp)) {
log.warn("Session IP 不匹配: {} vs {}", sessionIp, currentIp);
// 可以选择使会话失效
// return false;
}
// 用户代理检查
if (sessionUserAgent != null &&
!sessionUserAgent.equals(currentUserAgent)) {
log.warn("User-Agent 不匹配,可能存在会话劫持");
return false;
}
return true;
}
/**
* 获取客户端真实 IP 地址
*/
private String getClientIpAddress(HttpServletRequest request) {
String[] headers = {
"X-Forwarded-For",
"X-Real-IP",
"Proxy-Client-IP",
"WL-Proxy-Client-IP"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
}
HTTP 基本认证
HTTP Basic Authentication 是最简单的认证方式,通过 HTTP 头传递 Base64 编码的用户名和密码。
工作原理
客户端 服务器
│ │
│ GET /api/data │
│ ──────────────────────────────────────> │
│ │
│ 401 Unauthorized │
│ WWW-Authenticate: Basic realm="API" │
│ <────────────────────────────────────── │
│ │
│ GET /api/data │
│ Authorization: Basic dXNlcjpwYXNz │
│ ──────────────────────────────────────> │
│ │
│ 200 OK │
│ <────────────────────────────────────── │
实现示例
/**
* HTTP Basic 认证配置
* 适用于内部 API 或快速原型
*/
@Configuration
@EnableWebSecurity
public class BasicAuthConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
http
.securityMatcher("/api/internal/**")
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user = User.builder()
.username("admin")
.password(passwordEncoder().encode("secret"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
/**
* 前端使用 Basic Auth
*/
// 使用 Fetch API
const username = 'admin';
const password = 'secret';
const credentials = btoa(`${username}:${password}`);
fetch('/api/internal/data', {
headers: {
'Authorization': `Basic ${credentials}`
}
})
.then(response => response.json())
.then(data => console.log(data));
// 使用 Axios
axios.get('/api/internal/data', {
auth: {
username: 'admin',
password: 'secret'
}
});
注意事项:
- Basic Auth 必须在 HTTPS 上使用,否则凭据容易被截获
- 每次请求都需要传递凭据,无法主动登出
- 不适合面向用户的 Web 应用
多因素认证(MFA)
多因素认证要求用户提供两个或更多独立的认证因素,显著提升账户安全性。
TOTP(基于时间的一次性密码)
/**
* TOTP 多因素认证服务
*/
@Service
public class MfaService {
/**
* 生成 TOTP 密钥
*/
public String generateSecret() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[20];
random.nextBytes(bytes);
return Base32.encode(bytes);
}
/**
* 生成二维码 URL
*/
public String generateQrCodeUrl(String username, String secret,
String issuer) {
String otpAuthUrl = String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s",
issuer, username, secret, issuer
);
return otpAuthUrl;
}
/**
* 验证 TOTP 码
*/
public boolean verifyCode(String secret, String code) {
try {
Totp totp = new Totp(secret);
return totp.verify(code);
} catch (Exception e) {
return false;
}
}
/**
* 生成备份码
* 用于在无法使用 TOTP 时恢复账户
*/
public List<String> generateBackupCodes() {
List<String> codes = new ArrayList<>();
SecureRandom random = new SecureRandom();
for (int i = 0; i < 10; i++) {
byte[] bytes = new byte[4];
random.nextBytes(bytes);
String code = BaseEncoding.base32().encode(bytes).substring(0, 8);
codes.add(code);
}
return codes;
}
}
/**
* MFA 控制器
*/
@RestController
@RequestMapping("/api/mfa")
public class MfaController {
@Autowired
private MfaService mfaService;
/**
* 启用 MFA
*/
@PostMapping("/enable")
public ResponseEntity<?> enableMfa(@AuthenticationPrincipal User user) {
// 生成密钥
String secret = mfaService.generateSecret();
// 生成二维码 URL
String qrUrl = mfaService.generateQrCodeUrl(
user.getUsername(),
secret,
"MyApp"
);
// 生成二维码图片
BufferedImage qrCode = generateQrCodeImage(qrUrl);
// 临时保存密钥(待验证后正式启用)
user.setTempMfaSecret(secret);
userService.save(user);
return ResponseEntity.ok(Map.of(
"secret", secret,
"qrCode", encodeImageToBase64(qrCode),
"message", "请使用身份验证器应用扫描二维码,然后输入验证码完成绑定"
));
}
/**
* 验证并确认启用 MFA
*/
@PostMapping("/verify")
public ResponseEntity<?> verifyMfa(
@AuthenticationPrincipal User user,
@RequestParam String code) {
String secret = user.getTempMfaSecret();
if (secret == null) {
return ResponseEntity.badRequest()
.body("请先启用 MFA");
}
if (!mfaService.verifyCode(secret, code)) {
return ResponseEntity.badRequest()
.body("验证码错误");
}
// 正式启用 MFA
user.setMfaSecret(secret);
user.setMfaEnabled(true);
user.setTempMfaSecret(null);
// 生成备份码
List<String> backupCodes = mfaService.generateBackupCodes();
user.setBackupCodes(backupCodes);
userService.save(user);
return ResponseEntity.ok(Map.of(
"message", "MFA 启用成功",
"backupCodes", backupCodes,
"warning", "请妥善保存备份码,它们只会显示一次"
));
}
}
认证安全最佳实践
安全清单
/**
* 认证安全检查
*/
@Component
public class AuthSecurityChecker {
/**
* 密码强度检查
*/
public boolean isStrongPassword(String password) {
// 至少 12 位
if (password.length() < 12) return false;
// 包含大写字母
if (!password.matches(".*[A-Z].*")) return false;
// 包含小写字母
if (!password.matches(".*[a-z].*")) return false;
// 包含数字
if (!password.matches(".*\\d.*")) return false;
// 包含特殊字符
if (!password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*"))
return false;
// 不包含常见弱密码
String[] weakPasswords = {"password", "123456", "qwerty", "admin"};
for (String weak : weakPasswords) {
if (password.toLowerCase().contains(weak)) return false;
}
return true;
}
/**
* 检测暴力破解
*/
@Autowired
private StringRedisTemplate redisTemplate;
public boolean isBruteForceAttempt(String username, String ip) {
String key = "login:attempts:" + username + ":" + ip;
Long attempts = redisTemplate.opsForValue().increment(key);
if (attempts == 1) {
// 设置 15 分钟过期
redisTemplate.expire(key, 15, TimeUnit.MINUTES);
}
// 5 分钟内超过 5 次尝试,认为是暴力破解
return attempts > 5;
}
/**
* 记录登录成功,清除失败计数
*/
public void recordLoginSuccess(String username, String ip) {
String key = "login:attempts:" + username + ":" + ip;
redisTemplate.delete(key);
// 记录登录历史
String historyKey = "login:history:" + username;
redisTemplate.opsForList().leftPush(historyKey,
LocalDateTime.now() + "|" + ip);
redisTemplate.opsForList().trim(historyKey, 0, 99); // 保留最近 100 条
}
}
常见安全错误
// ❌ 错误 1: 明文存储密码
user.setPassword(password);
// ✅ 正确: 使用强哈希算法
user.setPassword(passwordEncoder.encode(password));
// ❌ 错误 2: 登录错误信息泄露用户名
if (!userExists(username)) {
return "用户名不存在"; // 泄露信息
}
if (!passwordMatch(password)) {
return "密码错误"; // 泄露信息
}
// ✅ 正确: 使用通用错误信息
return "用户名或密码错误";
// ❌ 错误 3: Session ID 暴露在 URL 中
response.sendRedirect("/app?sessionId=" + sessionId);
// ✅ 正确: 使用 Cookie 传递 Session ID
Cookie cookie = new Cookie("SESSION", sessionId);
cookie.setHttpOnly(true);
response.addCookie(cookie);
// ❌ 错误 4: 没有会话超时
session.setMaxInactiveInterval(-1); // 永不过期
// ✅ 正确: 设置合理的超时时间
session.setMaxInactiveInterval(30 * 60); // 30 分钟
// ❌ 错误 5: 登录后不重新生成 Session ID
// 容易受到会话固定攻击
// ✅ 正确: 登录后重新生成 Session ID
String oldId = session.getId();
session.invalidate();
session = request.getSession(true);
String newId = session.getId();
小结
身份认证是安全防御的关键一环。在实际工程中,应根据业务场景选择合适的机制:
- Session-Cookie:适用于传统 Web 项目,由服务器控制会话生命周期,但需处理分布式环境下的同步问题。
- JWT:适用于无状态微服务和移动端应用,扩展性强,需注意令牌一旦颁发无法主动失效。
- OAuth 2.1 / OIDC:适用于第三方登录和 API 授权,是行业标准。
- 多因素认证 (MFA):对于高价值账户,应强制开启 MFA 以对抗由于凭据泄露导致的路径攻击。
通用原则:始终使用 HTTPS 传输数据,实施严谨的密码策略,并对认证事件进行审计。
- 防止暴力破解和会话劫持
- 记录和监控认证事件
- 定期进行安全审计