跳到主要内容

身份认证与会话管理

身份认证 (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 是最传统、最广泛使用的认证机制。它的核心思想是在服务器端存储用户的会话状态,通过 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;
}
}
/**
* 安全配置
*/
@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 存储方案对比

  1. 内存存储(默认):适用于单服务器部署,配置简单,但无法跨节点共享会话。
  2. 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();

小结

身份认证是安全防御的关键一环。在实际工程中,应根据业务场景选择合适的机制:

  1. Session-Cookie:适用于传统 Web 项目,由服务器控制会话生命周期,但需处理分布式环境下的同步问题。
  2. JWT:适用于无状态微服务和移动端应用,扩展性强,需注意令牌一旦颁发无法主动失效。
  3. OAuth 2.1 / OIDC:适用于第三方登录和 API 授权,是行业标准。
  4. 多因素认证 (MFA):对于高价值账户,应强制开启 MFA 以对抗由于凭据泄露导致的路径攻击。

通用原则:始终使用 HTTPS 传输数据,实施严谨的密码策略,并对认证事件进行审计。

  • 防止暴力破解和会话劫持
  • 记录和监控认证事件
  • 定期进行安全审计