跳到主要内容

身份认证与会话管理

身份认证(Authentication)和会话管理(Session Management)是 Web 应用安全的核心部分。正确的实现可以保护用户身份,防止未授权访问。本章将详细介绍身份认证的最佳实践和安全防护措施。

什么是身份认证?

身份认证是验证用户身份的过程,确认用户确实是自己声称的那个人。常见的认证方式包括:

  1. 知识因素 - 用户知道什么(密码、PIN)
  2. 持有因素 - 用户拥有什么(手机、硬件令牌)
  3. 固有因素 - 用户本身是什么(指纹、面部识别)

密码安全

密码存储

绝对不能以明文方式存储密码!应该使用强哈希算法:

// 不安全:明文存储
user.setPassword(password);

// 安全:使用 BCrypt 哈希
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String hashedPassword = encoder.encode(rawPassword);
user.setPassword(hashedPassword);

密码验证

BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

// 验证密码
if (encoder.matches(inputPassword, storedHash)) {
// 密码正确
} else {
// 密码错误
}

密码强度要求

实施强密码策略:

// 密码验证规则
public boolean isStrongPassword(String password) {
// 至少 8 位
if (password.length() < 8) 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;

return true;
}

推荐的哈希算法

  1. BCrypt - 自动加盐,自适应成本因子
  2. Argon2 - 2015 年密码哈希竞赛冠军
  3. PBKDF2 - NIST 推荐

不要使用

  • MD5(已破解)
  • SHA-1(已破解)
  • SHA-256(不够安全,用于密码)
  • 加盐的 MD5/SHA(不够安全)

多因素认证(MFA)

多因素认证要求用户提供多个独立的认证因素:

// 实现 TOTP(基于时间的一次性密码)
import dev.samstevens.totp.code.*;

CodeVerifier verifier = new DefaultCodeGenerator(HashingAlgorithm.SHA1);
CodeGenerator generator = new DefaultCodeGenerator(HashingAlgorithm.SHA1);

// 生成密钥
String secret = new SecretKeyGenerator().generate();
user.setTotpSecret(secret);

// 验证
Code code = generator.generate(secret, System.currentTimeMillis() / 30000);
if (verifier.isValidCode(secret, code.toString())) {
// 验证成功
}

登录保护

账户锁定

防止暴力破解:

public class LoginService {
private int maxAttempts = 5;
private int lockoutDuration = 15; // 分钟

public LoginResult login(String username, String password) {
// 检查是否被锁定
if (isLocked(username)) {
return new LoginResult(false, "账户已被锁定");
}

// 验证密码
boolean success = verifyPassword(username, password);

if (!success) {
// 记录失败
recordFailedAttempt(username);

// 检查是否达到锁定阈值
if (getFailedAttempts(username) >= maxAttempts) {
lockAccount(username, lockoutDuration);
}

return new LoginResult(false, "用户名或密码错误");
}

// 登录成功
clearFailedAttempts(username);
return new LoginResult(true, "登录成功");
}
}

验证码

防止自动化登录:

// 生成图形验证码
BufferedImage image = new BufferedImage(200, 60, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();

// 绘制背景
g.setColor(Color.WHITE);
g.fillRect(0, 0, 200, 60);

// 绘制随机字符
String captchaText = generateRandomText(4);
g.setFont(new Font("Arial", Font.BOLD, 32));
g.setColor(Color.BLACK);
g.drawString(captchaText, 50, 40);

// 验证
if (!userInput.equalsIgnoreCase(storedCaptcha)) {
return new LoginResult(false, "验证码错误");
}

登录尝试限制

// 使用 Redis 实现登录限流
String key = "login:failed:" + username;
Long failedCount = redis.opsForValue().increment(key);
if (failedCount == 1) {
redis.expire(key, 3600); // 1小时过期
}

if (failedCount > 5) {
throw new RateLimitExceededException("登录尝试过于频繁");
}

会话管理

创建安全会话

public String createSession(HttpServletRequest request, User user) {
// 生成安全的会话 ID
String sessionId = UUID.randomUUID().toString().replace("-", "");

// 将会话与用户关联
Session session = new Session();
session.setId(sessionId);
session.setUserId(user.getId());
session.setCreatedAt(new Date());
session.setExpiresAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000));

sessionRepository.save(session);

// 创建安全 Cookie
Cookie cookie = new Cookie("SESSION_ID", sessionId);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setSameSite("Strict");
cookie.setPath("/");
cookie.setMaxAge(30 * 60); // 30 分钟

response.addCookie(cookie);

return sessionId;
}

会话过期

// Servlet 配置
@WebServlet(urlPatterns = {"/app/*"})
public class SecureServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {

HttpSession session = req.getSession(false);
if (session == null) {
resp.sendRedirect("/login");
return;
}

// 检查会话是否过期
Long lastAccess = (Long) session.getAttribute("lastAccess");
if (lastAccess != null &&
System.currentTimeMillis() - lastAccess > 30 * 60 * 1000) {
session.invalidate();
resp.sendRedirect("/login?expired=true");
return;
}

// 更新最后访问时间
session.setAttribute("lastAccess", System.currentTimeMillis());

// 处理请求
}
}

会话固定防护

// 登录后重新生成会话 ID
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response) {
// 使旧会话无效
HttpSession oldSession = request.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}

// 创建新会话
HttpSession newSession = request.getSession(true);
newSession.setAttribute("user", getCurrentUser());

// 设置新会话 Cookie
Cookie sessionCookie = new Cookie("JSESSIONID", newSession.getId());
sessionCookie.setHttpOnly(true);
sessionCookie.setSecure(true);
sessionCookie.setSameSite("Strict");
response.addCookie(sessionCookie);
}

Token 认证(JWT)

生成 Token

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

public String generateToken(User user) {
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

String token = Jwts.builder()
.setSubject(user.getId().toString())
.claim("username", user.getUsername())
.claim("role", user.getRole())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) // 24小时
.signWith(key)
.compact();

return token;
}

验证 Token

public Claims validateToken(String token) {
try {
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (JwtException e) {
return null; // Token 无效
}
}

安全的 Token 存储

// 安全地存储 Token(不要使用 localStorage)
// 推荐:使用内存或 HttpOnly Cookie

// 如果必须使用 localStorage,请确保:
// 1. 页面没有 XSS 漏洞
// 2. 使用 short-lived token
// 3. 实现 token 刷新机制

OAuth 2.0 和 OpenID Connect

OAuth 2.0 流程

// 1. 重定向用户到授权服务器
String authUrl = "https://oauth.example.com/authorize?" +
"client_id=" + clientId +
"&redirect_uri=" + URLEncoder.encode(redirectUri) +
"&response_type=code" +
"&scope=read:user" +
"&state=" + generateStateToken();

response.sendRedirect(authUrl);

// 2. 授权服务器回调
String code = request.getParameter("code");
String state = request.getParameter("state");

// 3. 交换授权码为 Access Token
HttpClient client = HttpClient.newHttpClient();
HttpRequest tokenRequest = HttpRequest.newBuilder()
.uri(URI.create("https://oauth.example.com/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(
"grant_type=authorization_code" +
"&code=" + code +
"&client_id=" + clientId +
"&client_secret=" + clientSecret +
"&redirect_uri=" + redirectUri
))
.build();

HttpResponse<String> response = client.send(tokenRequest,
HttpResponse.BodyHandlers.ofString());

安全注意事项

  1. 验证 state 参数 - 防止 CSRF
  2. 验证 redirect_uri - 防止授权码重定向攻击
  3. 安全存储 client_secret - 不要暴露在客户端代码中
  4. 使用 PKCE - 对于公共客户端(移动应用、SPA)是必需的

最佳实践

认证安全清单

  1. 密码存储

    • 使用 BCrypt、Argon2 等现代哈希算法
    • 每个密码使用唯一盐值
    • 实施密码强度策略
  2. 登录保护

    • 实施账户锁定(5次失败后锁定15分钟)
    • 添加图形验证码
    • 限制登录尝试频率
  3. 会话管理

    • 生成安全的随机会话 ID
    • 设置合理的会话超时(15-30分钟)
    • 使用 HttpOnly、Secure、SameSite Cookie
  4. 多因素认证

    • 关键账户启用 MFA
    • 使用 TOTP 或硬件令牌
  5. 错误处理

    • 不要暴露用户名是否存在
    • 使用通用的错误消息

常见错误

// 错误 1:密码以明文存储
user.setPassword(password);

// 错误 2:使用弱哈希
user.setPassword(MD5(password));

// 错误 3:会话 ID 在 URL 中
response.sendRedirect("/app?session=" + sessionId);

// 错误 4:没有会话超时
session.setMaxInactiveInterval(-1);

// 错误 5:登录错误消息暴露用户名
if (!userExists(username)) {
return "用户名不存在"; // 泄露信息
}
// 应该返回通用的
return "用户名或密码错误";

小结

身份认证和会话管理是 Web 安全的基石:

  1. 密码安全

    • 使用 BCrypt/Argon2 哈希
    • 实施强密码策略
    • 启用多因素认证
  2. 登录保护

    • 账户锁定防止暴力破解
    • 验证码防止自动化攻击
    • 限流防止暴力破解
  3. 会话管理

    • 生成安全随机会话 ID
    • HttpOnly、Secure、SameSite Cookie
    • 合理超时和定期刷新
  4. Token 认证

    • JWT 使用签名保护
    • 短期 Token + 刷新机制
    • 优先使用 HttpOnly Cookie

记住:安全是一个整体,任何一个环节的漏洞都可能导致整个系统被攻破!