技术演进中的开发沉思-152 java-servlet:安全性

我最喜欢在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>

  1. 改代码:在 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…… 但核心逻辑和当年一样:身份要认证,数据要加密,权限要控制。就像现在的餐馆,除了保安,还装了监控、门禁,但 “保护客人安全” 的初心没变。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值