我最喜欢在web项目上线前的最后一轮测试里,去发现了个致命的安全问题:起初用 Fiddler 抓包,居然能看到用户登录的用户名和密码 —— 不是乱码,是清清楚楚的明文!所谓的 HTTP 基本认证,根本不是 “锁”,顶多是 “门上挂了个‘请勿入内’的牌子”,懂行的人一推就开。

今天,咱们就结合当年那些 “踩坑代码”,聊聊 Servlet 的安全机制:从 “门口登记”(HTTP 认证)、“前台核验”(HTML 表单认证),到 “加密通道”(SSL),带大家感受 “用代码给 Web 应用装安保系统” 的过程 —— 这些当年的基础方案,至今仍是理解 Web 安全的基石。
是不是最初的你对 “安全” 的理解很简单:“不让陌生人进后台”。直到抓包看到明文密码,才明白安全是 “全链路保护”—— 就像餐馆的安保,不只是门口有保安,还要防登记本被偷看、防客人钱包被偷、防通道里有偷听的人。
Servlet 的安全机制,本质就是给 Web 应用装 “多层安保”:
- 身份认证:确认 “你是谁”(比如客人出示会员卡);
- 数据传输安全:确保 “你说的话不被偷听”(比如客人和前台小声说话);
- 权限控制:确保 “你只能干该干的事”(比如客人不能进后厨)。
当年我就是靠HttpLogin、CustomLogin、ServletLogin这几个 Servlet,把这些 “安保措施” 一个个摸透的 —— 没有复杂的框架,全是基础 API 的实战。
一、 HTTP 认证
HTTP 认证是最简单的 “安保措施”—— 就像餐馆门口放个登记本,客人要进去,保安(Web 服务器)让他写名字和密码,写完才让进。这种方式不用自己写登录页面,浏览器会自动弹对话框,服务器负责验证,简单但 “防君子不防小人”。
1.1 基本认证:透明纸写的登记本
当年我写的HttpLogin servlet,就是用 HTTP 基本认证,代码特别简单:
package javaservlets.security;
import javax.servlet.*;
import javax.servlet.http.*;
public class HttpLogin extends HttpServlet {
public void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, java.io.IOException {
resp.setContentType("text/html");
PrintWriter out = resp.getWriter();
// 关键:从请求中拿已认证的用户名(服务器已验证过)
String user = req.getRemoteUser();
out.println("<html><body>");
out.println("<center><h2>欢迎 " + user + "!</h2></center>");
out.println("</body></html>");
out.flush();
}
}
这段代码本身不处理登录,关键在服务器配置—— 我在 Java Web 服务器的管理工具里,给HttpLogin设置了 “基本认证”,指定用户数据库(比如users.properties,存 “karlmoss:123456”)。
第一次访问/HttpLogin时,浏览器会弹个对话框(图 7.2 那种),输入用户名密码后,服务器验证通过,才会调用servlet,req.getRemoteUser()就能拿到用户名。
但当年我用 Fiddler 抓包时,发现浏览器传的是这样一串:Basic a2FybG1vc3M6MTIzNDU2—— 用之前写的Decoder类一解码,瞬间得到 “karlmoss:123456”:
// 当年用这个方法解码,发现Base64多脆弱
String encoded = "Basic a2FybG1vc3M6MTIzNDU2";
String decoded = Decoder.base64(encoded.substring(6)); // 去掉"Basic "
System.out.println(decoded); // 输出:karlmoss:123456
那一刻我才懂:Base64 编码根本不是加密,就是 “把明文折一下,假装别人看不懂”—— 就像登记本用透明纸写,别人拿起来对着光就能看清。后来老领导骂我:“你这是把客人密码贴在门口,懂点技术的都能偷走!”
1.2 摘要认证:带暗号的登记本
HTTP 摘要认证能解决基本认证的问题 —— 它不传明文密码,而是传 “密码 + 随机数” 的哈希值(就像客人和保安约定好,不说密码,只说密码的 “暗号”)。但当年我测试时发现,IE 和 Netscape 都不支持 —— 就像餐馆搞了个高级登记本,客人不会用,最后还是得用基本认证。
当年我总结了个 “基本认证的坑”:
- 密码用 Base64 编码,形同明文,绝对不能在公网上用;
- 浏览器弹的对话框没法定制,丑且不友好;
- 没法拿密码,只能用getRemoteUser()拿用户名,灵活性差。
后来做内部系统(比如公司后台)时,我偶尔还用基本认证 —— 毕竟简单,但对外的电商前台,坚决不用。
二、用户认证
HTTP 基本认证依赖 Web 服务器的用户数据库(比如users.properties),但当年我们的电商项目,用户数据存在 MySQL 里 —— 这时候就得自己写认证逻辑,让 “保安(servlet)自己查会员档案(数据库)”,而不是依赖 “物业(Web 服务器)”。
2.1 CustomLogin servlet:自己管登记本
CustomLogin servlet 是当年的核心代码,它自己处理认证逻辑,不用服务器配置:
package javaservlets.security;
import javax.servlet.*;
import javax.servlet.http.*;
public class CustomLogin extends HttpServlet {
static final String USER_KEY = "CustomLogin.user";
public void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, java.io.IOException {
resp.setContentType("text/html");
PrintWriter out = resp.getWriter();
HttpSession session = req.getSession(true);
String sessionUser = (String) session.getValue(USER_KEY);
// 1. 会话里没有用户,说明没登录,触发浏览器弹对话框
if (sessionUser == null) {
String user = validUser(req); // 自己验证用户
if (user == null) {
// 关键:设置响应头,让浏览器弹登录框
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resp.setHeader("WWW-Authenticate", "BASIC realm=\"custom\"");
out.println("请登录后访问");
out.close();
return;
}
// 验证通过,存会话
session.putValue(USER_KEY, user);
sessionUser = user;
}
// 2. 登录成功,显示欢迎页
out.println("<html><body>欢迎 " + sessionUser + "!</body></html>");
out.flush();
}
// 自己解析请求头,验证用户(核心方法)
protected String validUser(HttpServletRequest req) {
String encodedAuth = req.getHeader("Authorization");
if (encodedAuth == null || !encodedAuth.startsWith("BASIC ")) {
return null;
}
// 解码用户名密码(和之前的Decoder一样)
String decoded = Decoder.base64(encodedAuth.substring(6));
int idx = decoded.indexOf(":");
if (idx < 0) return null;
String user = decoded.substring(0, idx);
String pwd = decoded.substring(idx + 1);
// 自己查数据库验证(当年这里连MySQL,现在简化)
return validateUser(user, pwd) ? user : null;
}
// 模拟数据库验证
protected boolean validateUser(String user, String pwd) {
// 当年这里是JDBC查MySQL:select pwd from users where user=?
return "karlmoss".equals(user) && "123456".equals(pwd);
}
}
这段代码的关键是自己触发登录框:通过resp.setStatus(SC_UNAUTHORIZED)和WWW-Authenticate头,让浏览器弹框,然后自己解析请求头里的编码串,查数据库验证。
当年我用这个 servlet 解决了 “用户数据存 MySQL” 的问题,但抓包还是能看到 Base64 编码的密码 —— 本质和 HTTP 基本认证一样,只是把 “验证权” 从服务器拿到了 servlet 里,安全性没提升。
2.2 当年的 “解码惊魂”
写validUser方法时,我用Decoder类解码,发现只要拿到请求头,3 行代码就能还原密码:
String encoded = "Basic a2FybG1vc3M6MTIzNDU2";
String decoded = Decoder.base64(encoded.substring(6));
String[] up = decoded.split(":"); // up[0]是用户名,up[1]是密码
那一刻我意识到:这种方式只能防 “小白”,防不了懂技术的黑客。后来在生产环境,我给这个 servlet 加了个限制:只能在公司内网访问(通过 IP 白名单),才算勉强安全。
三、 HTML 表单认证
HTTP 认证的登录框太丑,还没法定制 —— 当年客户说 “要加我们公司的 logo,还要提示‘密码含大小写’”,于是我写了ServletLogin servlet,用 HTML 表单做登录页,就像餐馆前台放个定制的登记台,能加装饰、加提示,体验比门口登记好。
3.1 ServletLogin servlet:定制化登录
ServletLogin的核心是 “分两步”:没登录就显示表单,登录了就显示欢迎页:
package javaservlets.security;
import javax.servlet.*;
import javax.servlet.http.*;
public class ServletLogin extends HttpServlet {
public static String USER_KEY = "ServletLogin.user";
public static String FIELD_USER = "username";
public static String FIELD_PASSWORD = "password";
// 1. GET请求:显示登录表单(没登录)或欢迎页(已登录)
public void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, java.io.IOException {
resp.setContentType("text/html");
PrintWriter out = resp.getWriter();
HttpSession session = req.getSession(true);
String user = (String) session.getValue(USER_KEY);
if (user == null) {
// 没登录,显示定制表单(加logo、提示)
login(out, req.getRequestURI());
return;
}
// 已登录,显示欢迎页
out.println("<html><body><center><h2>欢迎来到会员区!</h2></center></body></html>");
out.flush();
}
// 2. POST请求:处理表单提交,验证用户
public void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, java.io.IOException {
resp.setContentType("text/html");
PrintWriter out = resp.getWriter();
HttpSession session = req.getSession(true);
String user = (String) session.getValue(USER_KEY);
if (user == null) {
// 从表单拿用户名密码
String username = req.getParameter(FIELD_USER);
String password = req.getParameter(FIELD_PASSWORD);
if (!validUser(username, password)) {
out.println("<html><body>用户名或密码错误!</body></html>");
out.close();
return;
}
// 验证通过,存会话
session.putValue(USER_KEY, username);
}
// 重定向到GET请求,避免刷新重复提交
resp.sendRedirect(req.getRequestURI());
}
// 显示定制登录表单(加logo、提示)
protected void login(PrintWriter out, String uri) throws IOException {
out.println("<html><head><title>会员登录</title></head>");
out.println("<body><center>");
out.println("<img src='logo.png' alt='公司logo'><br>"); // 加logo
out.println("<h2>请登录(密码含大小写字母)</h2><br>"); // 加提示
out.println("<form method=POST action=\"" + uri + "\">");
out.println("用户名:<input type=text name=" + FIELD_USER + "><br>");
out.println("密码:<input type=password name=" + FIELD_PASSWORD + "><br>");
out.println("<input type=submit value=\"登录\">");
out.println("</form></center></body></html>");
}
// 模拟数据库验证
protected boolean validUser(String username, String password) {
return "karlmoss".equals(username) && "Abc123".equals(password);
}
}
这个 servlet 的好处是完全定制:我给表单加了公司 logo、密码提示,还能加验证码(当年没加,后来加了个简单的数字验证码),用户体验比 HTTP 认证好太多。
但当年抓包测试时,发现 POST 请求的参数还是明文 —— 虽然表单里密码是type=password(输入时隐藏),但传输时还是能被抓到:username=karlmoss&password=Abc123。就像前台登记台的表格,客人填的时候密码被挡住,但表格被人偷看,还是会泄露。
3.2 当年的 “表单优化”
为了稍微提升点安全性,我做了两个优化:
- 禁止缓存:在doGet和doPost里加resp.setHeader("Expires", "Tues, 01 Jan 1980 00:00:00 GMT"),避免浏览器缓存登录页,防止别人看历史记录;
- 重定向:POST 验证通过后用resp.sendRedirect,避免用户刷新页面重复提交表单(比如刷新导致重复登录)。
但这些都是 “表面功夫”,没解决根本的 “传输明文” 问题 —— 真正的解决方案,是后面的 SSL。
四、 Applet 认证
HTML 表单虽然能定制,但还是依赖浏览器传输数据,没法自己加密 —— 当年有个银行项目,要求 “密码传输必须加密”,于是我用了 Applet 认证:写个 Java Applet 当 “专属登记窗口”,密码在 Applet 里加密后再传,就像餐馆给 VIP 客人开个单独的登记窗口,客人在窗口里小声说密码,外面听不到。
4.1 LoginApplet:专属加密窗口
LoginApplet是客户端的小应用,负责收集密码并加密(当年用了简单的 DES 加密):
package javaservlets.security;
import java.applet.*;
import java.awt.*;
import java.io.*;
import java.net.*;
public class LoginApplet extends Applet {
TextField username;
TextField password;
Button login;
String servletUrl;
String sessionId;
// 初始化界面:加输入框、登录按钮
public void init() {
setLayout(new GridLayout(3, 2));
add(new Label("用户名:"));
username = new TextField(15);
add(username);
add(new Label("密码:"));
password = new TextField(15);
password.setEchoCharacter('*'); // 隐藏输入
add(password);
login = new Button("登录");
add(login);
// 从HTML参数拿servlet地址和会话ID
servletUrl = getParameter("servlet");
sessionId = getParameter("id");
}
// 点击登录按钮,加密并发送数据
public boolean action(Event e, Object arg) {
if (e.target == login) {
try {
String user = username.getText();
String pwd = password.getText();
// 关键:密码加密(当年用DES,这里简化)
String encryptedPwd = encrypt(pwd);
// 发送给servlet验证
boolean valid = validate(user, encryptedPwd);
if (valid) {
// 验证通过,跳转到会员页
getAppletContext().showDocument(new URL(servletUrl + "?valid=1"));
} else {
showStatus("用户名或密码错误");
}
} catch (Exception ex) {
ex.printStackTrace();
}
return true;
}
return false;
}
// 加密密码(简化版DES)
private String encrypt(String pwd) {
// 当年用javax.crypto包的DES加密,这里模拟加密
return new StringBuilder(pwd).reverse().toString(); // 简单反转,实际用DES
}
// 和servlet通信,发送加密后的密码
private boolean validate(String user, String encryptedPwd) throws Exception {
URL url = new URL(servletUrl);
URLConnection conn = url.openConnection();
conn.setDoOutput(true);
DataOutputStream out = new DataOutputStream(conn.getOutputStream());
// 发送会话ID、用户名、加密后的密码
out.writeUTF(sessionId);
out.writeUTF(user);
out.writeUTF(encryptedPwd);
out.flush();
out.close();
// 读取servlet的验证结果
DataInputStream in = new DataInputStream(conn.getInputStream());
boolean valid = in.readBoolean();
in.close();
return valid;
}
}
4.2 AppletLogin servlet:后台解密验证
AppletLogin servlet 负责接收 Applet 的加密数据,解密后验证:
package javaservlets.security;
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.util.Hashtable;
public class AppletLogin extends HttpServlet {
public static String USER_KEY = "AppletLogin.user";
static Hashtable<String, String> sessionUserMap = new Hashtable<>(); // 会话ID-用户名映射
// GET请求:显示Applet(没登录)或欢迎页(已登录)
public void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, java.io.IOException {
resp.setContentType("text/html");
PrintWriter out = resp.getWriter();
HttpSession session = req.getSession(true);
String user = (String) session.getValue(USER_KEY);
// 从映射表拿用户(解决Applet和浏览器会话ID不一致的坑)
if (user == null) {
user = sessionUserMap.get(session.getId());
if (user != null) {
session.putValue(USER_KEY, user);
sessionUserMap.remove(session.getId());
}
}
if (user == null) {
// 没登录,显示Applet
out.println("<html><body><center>");
out.println("<applet code='javaservlets.security.LoginApplet' width=300 height=150>");
out.println("<param name='servlet' value='" + req.getRequestURI() + "'>");
out.println("<param name='id' value='" + session.getId() + "'>"); // 传会话ID
out.println("</applet></center></body></html>");
return;
}
// 已登录,显示欢迎页
out.println("<html><body><center><h2>欢迎 " + user + "!</h2></center></body></html>");
out.flush();
}
// POST请求:接收Applet的加密数据,验证
public void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, java.io.IOException {
resp.setContentType("application/octet-stream");
DataInputStream in = new DataInputStream(req.getInputStream());
DataOutputStream out = new DataOutputStream(resp.getOutputStream());
// 读取Applet发送的数据
String sessionId = in.readUTF();
String user = in.readUTF();
String encryptedPwd = in.readUTF();
// 解密密码(和Applet的加密对应)
String pwd = decrypt(encryptedPwd);
// 验证用户(查数据库)
boolean valid = "karlmoss".equals(user) && "Abc123".equals(pwd);
if (valid) {
// 验证通过,存会话ID-用户名映射(解决会话ID不一致)
sessionUserMap.put(sessionId, user);
out.writeBoolean(true);
// 发送会员页地址
out.writeUTF(req.getRequestURI() + "?valid=1");
} else {
out.writeBoolean(false);
}
out.flush();
out.close();
}
// 解密密码(和Applet的encrypt对应)
private String decrypt(String encryptedPwd) {
// 对应Applet的反转加密,实际用DES解密
return new StringBuilder(encryptedPwd).reverse().toString();
}
}
4.3 当年的 “Applet 坑”
用 Applet 认证时,踩了两个大坑:
- 会话 ID 不一致:浏览器和 Applet 用不同的 Cookie,导致 Applet 的会话 ID 和浏览器的不一样 —— 后来用sessionUserMap哈希表,把 Applet 传的会话 ID 和用户名绑定,浏览器访问时再从映射表取,才算解决;
- 浏览器兼容:当年 IE6 支持 Applet,但有些浏览器禁用了 Java 插件,导致 Applet 加载失败 —— 最后加了个降级方案:不支持 Applet 就跳转到 HTML 表单登录。
Applet 认证虽然能加密,但太复杂,后来 HTML5 和 AJAX 普及后,就很少用了 —— 不过它教会我:安全是 “trade-off”,想要更安全,就得付出更多开发成本。
五、 安全套接字协议层(SSL)
前面的认证方式,要么传输明文,要么加密复杂 —— 直到用了 SSL,才真正解决 “传输安全” 的问题。SSL 就像餐馆的 “加密通道”,客人从门口到前台,走的是封闭通道,外面的人听不到、看不到,所有数据都加密传输。
当年的 “SSL 配置惊魂”
2006 年做电商支付模块时,必须用 SSL—— 步骤很麻烦:
- 买证书:从 Verisign 买了个 SSL 证书(当年花了好几千),拿到一个.cer文件;
- 配置服务器:在 Java Web 服务器的server.xml里加 SSL 配置,指定证书路径和密码:
<Connector port="443" protocol="HTTP/1.1" SSLEnabled="true">
<SSLHostConfig>
<Certificate certificateFile="conf/ssl/server.cer" type="RSA"/>
</SSLHostConfig>
</Connector>
- 改代码:在 servlet 里判断是否用 SSL,强制支付页走 HTTPS:
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// 关键:判断协议,不是HTTPS就重定向
if (!"https".equals(req.getScheme())) {
String httpsUrl = "https://" + req.getServerName() + req.getRequestURI();
resp.sendRedirect(httpsUrl);
return;
}
// 后续处理支付...
}
配置完后,访问支付页,地址栏出现小锁图标,抓包看到的数据全是乱码 —— 那一刻我才放心:终于不用担心密码被偷了。
当年我总结了 SSL 的 “核心优势”:
- 透明加密:不用改业务代码,所有 HTTP 请求自动变成 HTTPS,数据传输时加密;
- 身份验证:证书能证明服务器是真的(比如淘宝的证书由 Verisign 签发,防止钓鱼网站);
- 完整性:数据传输中被篡改会被发现(比如黑客改支付金额,SSL 会报错)。
唯一的缺点是性能损耗:加密解密要耗服务器资源,当年我们的服务器 CPU 使用率涨了 10%,但为了安全,这是必须付出的代价。
最后小结
当年做安全相关的项目,老领导常说:“安全没有绝对,只有‘适合’—— 小餐馆不用装防弹玻璃,银行却必须装。” 这句话我记到现在。
回顾本章的几种安全方案,它们各有适用场景:
- HTTP 基本认证:适合内部系统(比如公司后台),简单快,但不安全;
- 自定义用户认证:适合需要自己查数据库的场景,但传输仍不安全;
- HTML 表单认证:适合对外的 Web 应用,体验好,但需配合 SSL;
- Applet 认证:适合对安全性要求极高的场景(比如银行),但复杂难维护;
- SSL:适合所有对外应用,尤其是支付、登录等敏感场景,是 “基础安全措施”。
当年的电商项目,最后用的是 “HTML 表单认证 + SSL”:登录页用定制表单,所有请求走 HTTPS,密码在服务器端哈希存储(不用明文存数据库)—— 这套方案不算最先进,但平衡了 “安全” 和 “体验”,上线后没出过安全问题。
现在的 Web 安全有了更多方案:OAuth2.0、JWT、HTTPS 普及、密码哈希用 BCrypt…… 但核心逻辑和当年一样:身份要认证,数据要加密,权限要控制。就像现在的餐馆,除了保安,还装了监控、门禁,但 “保护客人安全” 的初心没变。


被折叠的 条评论
为什么被折叠?



