身份认证与会话管理
身份认证(Authentication)和会话管理(Session Management)是 Web 应用安全的核心部分。正确的实现可以保护用户身份,防止未授权访问。本章将详细介绍身份认证的最佳实践和安全防护措施。
什么是身份认证?
身份认证是验证用户身份的过程,确认用户确实是自己声称的那个人。常见的认证方式包括:
- 知识因素 - 用户知道什么(密码、PIN)
- 持有因素 - 用户拥有什么(手机、硬件令牌)
- 固有因素 - 用户本身是什么(指纹、面部识别)
密码安全
密码存储
绝对不能以明文方式存储密码!应该使用强哈希算法:
// 不安全:明文存储
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;
}
推荐的哈希算法
- BCrypt - 自动加盐,自适应成本因子
- Argon2 - 2015 年密码哈希竞赛冠军
- 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());
安全注意事项
- 验证 state 参数 - 防止 CSRF
- 验证 redirect_uri - 防止授权码重定向攻击
- 安全存储 client_secret - 不要暴露在客户端代码中
- 使用 PKCE - 对于公共客户端(移动应用、SPA)是必需的
最佳实践
认证安全清单
-
密码存储
- 使用 BCrypt、Argon2 等现代哈希算法
- 每个密码使用唯一盐值
- 实施密码强度策略
-
登录保护
- 实施账户锁定(5次失败后锁定15分钟)
- 添加图形验证码
- 限制登录尝试频率
-
会话管理
- 生成安全的随机会话 ID
- 设置合理的会话超时(15-30分钟)
- 使用 HttpOnly、Secure、SameSite Cookie
-
多因素认证
- 关键账户启用 MFA
- 使用 TOTP 或硬件令牌
-
错误处理
- 不要暴露用户名是否存在
- 使用通用的错误消息
常见错误
// 错误 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 安全的基石:
-
密码安全
- 使用 BCrypt/Argon2 哈希
- 实施强密码策略
- 启用多因素认证
-
登录保护
- 账户锁定防止暴力破解
- 验证码防止自动化攻击
- 限流防止暴力破解
-
会话管理
- 生成安全随机会话 ID
- HttpOnly、Secure、SameSite Cookie
- 合理超时和定期刷新
-
Token 认证
- JWT 使用签名保护
- 短期 Token + 刷新机制
- 优先使用 HttpOnly Cookie
记住:安全是一个整体,任何一个环节的漏洞都可能导致整个系统被攻破!