☰
Java自动登录实战:HttpClient会话、验证码与加密链路拆解
2026/11/2 2:18:45 网站建设 项目流程

简介:面向想研究人人网(校内)登录流程的 Java 开发者、自动化学习者以及具备基础网络编程经验的读者,这份简易登录程序提供了可直接参考的代码样本,重点解决旧版校内登录代码难以适配、依赖缺失的问题。压缩包共 4 个文件,约 4.31MB,其中 1 个 Java 源文件承载登录逻辑,3 个 zip 为 HTTP 客户端、编解码与日志等依赖库,便于自行添加后调试运行。已有 43239 人学习下载,说明该主题仍有一定参考需求。代码定位为简易版,不包含登录后的业务功能,但保留了请求参数构造、HTTP 客户端调用与依赖组织思路,适合用来了解早期人人网登录实现、拆解请求流程或做二次改造;读者也可据此补充验证码、Cookie 管理与异常处理等排错方向,作为抛砖引玉的学习材料。

1. 从一次抓包开始:校内类站点自动登录卡在哪

校内类社交站点的登录接口看着像十年前的产物:一个 form、两个隐藏域、一张四位验证码。可真拿 Java 把这条链路跑通,拦住人的往往不是 HttpClient 的 API,而是三个藏在页面里的取值:rkey 的有效期、密码的加密开关、验证码与会话的绑定关系。这套 Java 代码做的就是把这三点固定成可复用的流程:先 GET 登录页拿到会话 Cookie 与隐藏字段,再拉验证码图片,最后 POST 表单并用落地页内容判定成败。它适合两类人:需要给老系统补接口自动化的后端,以及想搞明白 Cookie 会话到底怎么在 Java 里连续起来的工程师。后面每一步我都会给出可直接抄的写法和参数含义,也会把我们踩过的坑按现象摆出来。

2. 拆登录链路:三个请求、两处加密与一张验证码

动手写代码之前,先要把浏览器里的动作还原成一条确定的时序。多数校内类站点的登录不是一次 POST 就完事,而是「取页 → 取码 → 提交」三段式,少一段就会出现「返回 200 但还停在登录页」这种最让人抓狂的结果。这一章先把链路上的每个产出物对齐,再谈 Java 怎么复刻。

2.1 用开发者工具锁定登录前的三个请求

打开登录页,F12 进 Network 面板,勾上 Preserve log 和 Disable cache,然后正常登录一次。你会看到的关键请求基本是这三条,顺序不能乱:

序号方法典型路径关键产出
1GET/login 或 /Login.doSet-Cookie(JSESSIONID、t 之类的会话标识),响应体里的 hidden 字段
2GET/icode 或 /getCode.do验证码图片二进制,同时写入一个绑定验证码的 Cookie
3POST/login.do表单字段提交,返回 302 并带 Location

第一条请求的响应体里通常有rkey、timestep、origURL、domain这些 hidden input。它们不是装饰品:rkey是服务端种下的一次性凭证,短则几分钟、长则半小时失效;origURL决定登录成功后跳回哪个页面;timestep是毫秒时间戳,部分站点会拿它和rkey做一次联合校验。第二条请求的重点是:验证码不是独立生成的,它和第一步建立的会话绑定,所以取图和提交必须在同一个 CookieStore 里完成,换了会话就等于换了张码。

提示:抓包时把「保留日志」打开,因为登录成功会有 302 跳转,不保留日志的话第一条 GET 请求会被冲掉。

这一步的产出清单要落到纸上:会话 Cookie 的键名、hidden 字段的名字和取值方式、验证码接口路径、登录接口路径、表单全部字段名。缺任何一项,后面代码里就得靠猜,而猜是这类活儿里最贵的成本。

2.2 把隐藏字段提取干净:正则与实体反转义

隐藏字段最容易出问题的地方是两个:属性顺序不固定,以及 value 里带 HTML 实体。页面可能写成<input type="hidden" name="rkey" value="abc">,也可能是name在type前面。所以我不建议一条大正则直接匹配到 value,而是先用标签级正则切出整段 input,再分别取 name 和 value:

import java.util.HashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; public final class HiddenFieldParser { // 先切出整个 <input ...> 标签,避免属性顺序变化导致匹配失败 private static final Pattern INPUT_TAG = Pattern.compile("<input\\b[^>]*>", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_ATTR = Pattern.compile("\\bname\\s*=\\s*[\"']([^\"']*)[\"']", Pattern.CASE_INSENSITIVE); private static final Pattern VALUE_ATTR = Pattern.compile("\\bvalue\\s*=\\s*[\"']([^\"']*)[\"']", Pattern.CASE_INSENSITIVE); // 只关心 hidden,其它类型的 input 一律跳过 private static final Pattern TYPE_HIDDEN = Pattern.compile("\\btype\\s*=\\s*[\"']hidden[\"']", Pattern.CASE_INSENSITIVE); public static Map<String, String> parse(String html) { Map<String, String> fields = new HashMap<>(); Matcher tag = INPUT_TAG.matcher(html); while (tag.find()) { String segment = tag.group(); if (!TYPE_HIDDEN.matcher(segment).find()) { continue; } Matcher name = NAME_ATTR.matcher(segment); if (!name.find()) { continue; } Matcher value = VALUE_ATTR.matcher(segment); // value 可能为空字符串,没匹配到时按空处理而不是丢弃字段 String v = value.find() ? value.group(1) : ""; fields.put(name.group(1), unescapeHtml(v)); } return fields; } // 只处理最常见的三种实体,够用且不引入额外依赖 private static String unescapeHtml(String s) { return s.replace("&amp;", "&") .replace("&quot;", "\"") .replace("&#39;", "'"); } }

逻辑上分两步走:先按标签粒度定位,再按属性粒度取值。这样即使页面模板改过属性顺序、加了>import javax.crypto.Cipher; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.MessageDigest; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public final class PasswordEncoder { // 摘要方案:注意编码统一用 UTF-8,别用平台默认编码 public static String md5Hex(String plain) throws Exception { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(plain.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(32); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } // 非对称方案:公钥来自登录页 JS,Base64 解码后走 X509 规范 public static String rsaEncrypt(String plain, String publicKeyBase64) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); PublicKey publicKey = KeyFactory.getInstance("RSA") .generatePublic(new X509EncodedKeySpec(keyBytes)); Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] out = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(out); } }

md5Hex里用String.format("%02x")而不是Integer.toHexString,是为了保证个位数补零,否则 31 位的结果会让服务端直接判错。rsaEncrypt的关键在RSA/ECB/PKCS1Padding这个变换名——不写全,不同 JDK 提供者选到的默认填充可能不一样,结果就是加密成功但服务端解不开。还有一个边界:1024 位密钥单块最多加密 117 字节,密码远小于这个数所以不必分段,但如果哪天要加密更长的字段,就得手动按 117 字节切块再拼接。指纹校验、密钥格式这类细节,写错了现象统一表现为「密码错误」,跟你密码打错一个字母长得一模一样,这也是它最坑的地方。

3. 用 Java 把 POST 发出去:会话、表单编码与重定向

链路清楚了,接下来是让 Java 表现得像一个浏览器。这里的核心不是「发一个 POST」,而是「让三次请求共享同一份会话状态」。HttpClient 默认不保存 Cookie,你不显式挂 CookieStore,第一步 GET 拿到的会话就会在第二步丢掉,验证码永远对不上。

3.1 CookieStore 与连接池:让三个请求共享会话

我一般把 client 的构建单独抽一个方法,把 CookieStore 从外面传进来,这样同一个 client 可以被多次登录复用,也方便测试时注入一个空 store:

import org.apache.http.client.CookieStore; import org.apache.http.client.config.RequestConfig; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; public final class HttpClientFactory { private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"; public static CloseableHttpClient build(CookieStore store) { PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(20); // 整个连接池上限 cm.setDefaultMaxPerRoute(10); // 单个域名并发上限,登录场景 10 足够 RequestConfig config = RequestConfig.custom() .setConnectTimeout(5000) // 建连超时 .setConnectionRequestTimeout(3000) // 从池里取连接的超时 .setSocketTimeout(10000) // 读数据超时 .build(); return HttpClients.custom() .setConnectionManager(cm) .setDefaultCookieStore(store) // 这一行决定了会话能否连续 .setDefaultRequestConfig(config) .setUserAgent(UA) // 全生命周期固定 UA .build(); } }

参数上最需要解释的是setDefaultCookieStore:它让 client 在每个请求前后自动读写 Cookie,你不用手动拼Cookie头。setDefaultMaxPerRoute(10)看着随意,其实是刻意的——登录流程并发不会高,路由池开太大反而容易在没关闭的响应上泄漏连接。setUserAgent(UA)必须固定成一个常量,原因在避坑那章会讲:同一个会话里 UA 前后不一致,是最典型的触发风控的动作之一。三个超时值里,socketTimeout给到 10 秒是因为验证码图片和登录响应偶尔会慢,给 3 秒会出现「网络不好」的假故障。

3.2 构造登录表单:编码问题导致的「密码错误」

表单提交这一步,几乎所有「密码明明对却提示错误」的案例都能归到编码上。正确写法是用UrlEncodedFormEntity让框架去做 URL 编码,而不是自己拼字符串:

import org.apache.http.NameValuePair; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.message.BasicNameValuePair; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; public static HttpPost buildLoginPost(String account, String encodedPwd, String icode, Map<String, String> hidden) throws Exception { List<NameValuePair> form = new ArrayList<>(); form.add(new BasicNameValuePair("email", account)); form.add(new BasicNameValuePair("password", encodedPwd)); form.add(new BasicNameValuePair("icode", icode)); // 隐藏字段原样回填,缺一个都可能被服务端判为非法请求 form.add(new BasicNameValuePair("rkey", hidden.getOrDefault("rkey", ""))); form.add(new BasicNameValuePair("origURL", hidden.getOrDefault("origURL", ""))); form.add(new BasicNameValuePair("domain", hidden.getOrDefault("domain", ""))); form.add(new BasicNameValuePair("timestep", String.valueOf(System.currentTimeMillis()))); HttpPost post = new HttpPost("https://example-domain/login.do"); // 指定 UTF-8,框架会把 Base64 里的 + / = 正确转义 post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); post.setHeader("Referer", "https://example-domain/login"); post.setHeader("Origin", "https://example-domain"); return post; }

逻辑说明:UrlEncodedFormEntity会按application/x-www-form-urlencoded规则对每个 value 做转义,Base64 里出现的+会被编码成%2B,=编码成%3D。如果你图省事手拼"password=" + base64,服务端解码时会把+还原成空格,解出来的密文自然对不上,报错还是「密码错误」。参数上的两个细节:timestep用System.currentTimeMillis()现场生成,不要复用抓包时的旧值;Referer和Origin要跟登录页同域,部分站点会做来源校验,缺了直接 403。

3.3 302 之后怎么判断:跟随与不跟随两种写法

登录 POST 成功后,服务端一般返回 302。这里有两个流派,各有用途。第一种是让 client 自动跟随,最终拿到首页 HTML,从页面里找昵称或用户标识;第二种是关掉重定向,只读Location头,从跳转地址里提取票据参数:

import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; // 流派二:关闭自动重定向,手动读 Location public static String loginAndReadLocation(CloseableHttpClient base, HttpPost post) throws Exception { try (CloseableHttpClient noRedirect = HttpClients.custom() .disableRedirectHandling() // 关键:拦下 302 .build(); CloseableHttpResponse resp = noRedirect.execute(post)) { int status = resp.getStatusLine().getStatusCode(); if (status == 302 || status == 301) { // Location 里可能带 ticket / ref,后续请求需要带上 return resp.getFirstHeader("Location").getValue(); } // 非 3xx 说明没跳转,直接把这轮响应体打出来排查 return "STATUS=" + status + " BODY=" + EntityUtils.toString(resp.getEntity(), "UTF-8"); } }

注意这里为了演示关闭重定向,单独 new 了一个 client,实际项目里更推荐的做法是在构造 client 时就设好策略,避免每次登录都新建连接池。两个流派的选择标准很简单:如果后续要用跳转地址里的票据做二次校验,用不跟随;如果只需要判断「登没登上」,用跟随更省事,直接解析首页 HTML 里的用户名节点即可。用不跟随流派时还有一个坑——Location可能是相对路径,得用URI.resolve拼成绝对地址再用,直接拿去请求会抛异常。

4. 登录态持久化:把一次成功变成可复用的会话

自动登录的价值不在于登录那一下,而在于登录后的会话能被后续任务复用。如果每次跑任务都重新登录一遍,验证码会越来越难缠,频率一高还会被服务端盯上。所以这一章做两件事:把会话存下来,以及把「登录成功」这个判断做得足够可靠。

4.1 Cookie 落盘:哪些字段必须存

BasicCookieStore本身可序列化,但我不建议直接 Java 序列化落盘——格式绑死 JDK 版本,调试时也没法肉眼查看。更实用的做法是存成一行一条的文本,字段固定为五个:

import org.apache.http.cookie.Cookie; import org.apache.http.impl.cookie.BasicClientCookie; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; // 落盘:name \t value \t domain \t path \t expires public static void saveCookies(List<Cookie> cookies, File file) throws IOException { try (BufferedWriter w = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) { for (Cookie c : cookies) { // 会话级 Cookie 没有过期时间,落盘意义有限,这里跳过 if (c.getExpiryDate() == null) { continue; } w.write(String.join("\t", c.getName(), c.getValue(), c.getDomain() == null ? "" : c.getDomain(), c.getPath() == null ? "/" : c.getPath(), String.valueOf(c.getExpiryDate().getTime()))); w.newLine(); } } } public static List<Cookie> loadCookies(File file) throws IOException { List<Cookie> list = new ArrayList<>(); try (BufferedReader r = new BufferedReader( new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { String line; while ((line = r.readLine()) != null) { String[] p = line.split("\t", -1); if (p.length < 5) continue; BasicClientCookie c = new BasicClientCookie(p[0], p[1]); c.setDomain(p[2]); c.setPath(p[3].isEmpty() ? "/" : p[3]); c.setExpiryDate(new java.util.Date(Long.parseLong(p[4]))); list.add(c); } } return list; }

为什么跳过会话级 Cookie?因为expires为空的 Cookie 一旦落盘,下次读回来就变成「永不过期」,但服务端那边早失效了,你会拿到一堆请求返回登录页却找不到原因的诡异现象。domain和path两个字段必须原样保存,它们是 Cookie 匹配规则的一部分,丢了会导致 Cookie 不被发送——这是很多人「存了但没用上」的根因。时间戳用毫秒存,读回时直接构造Date,不用处理格式解析。

注意:Cookie 文件等同于登录凭证,不要提交到代码仓库,也不要把完整值打进日志。

4.2 四个成功判据,别只看 HTTP 200

登录成功与否,只看状态码是不够的——很多老站点密码错了也返回 200,只是页面把错误信息渲染出来。我一般会组合四个判据,按可靠度排序:

判据检查方式误判风险
访问需登录接口不跳转请求个人主页,状态码 200 且内容含用户标识最低
存在登录后专属 Cookie检查 CookieStore 里是否有仅在登录后下发的键低
响应体含昵称或用户 id正则匹配页面里的用户节点中,昵称可能为空
状态码 200 且 URL 不含 login只做粗筛高,仅用于快速排除

实践里我用「第一判据 + 第二判据」组合:先看 CookieStore 里有没有登录态标志,再拿这个会话去请求一个必须登录才能访问的页面,两次都对才算过。这个双检的成本很低,一次额外请求,但能把「假成功」挡在流程外面,避免后续任务全部白跑。

4.3 封装成 LoginService:三个方法收口

把前面所有碎片收进一个类,对外只暴露login、isAlive、getClient三个方法。这样调用方完全不用关心 rkey 从哪来、密码怎么加密:

public class LoginService { private final CloseableHttpClient client; private final CookieStore store; public LoginService() { this.store = new BasicCookieStore(); this.client = HttpClientFactory.build(store); } // 完整登录:取页 -> 取码 -> 提交 -> 校验 public boolean login(String account, String rawPwd, CaptchaProvider captcha) throws Exception { String html = Http.get(client, "https://example-domain/login"); Map<String, String> hidden = HiddenFieldParser.parse(html); byte[] img = Http.getBytes(client, "https://example-domain/icode"); String code = captcha.solve(img); // 验证码策略外置,见第五章 String pwd = PasswordEncoder.md5Hex(rawPwd); // 按抓包结论切换加密方式 HttpPost post = buildLoginPost(account, pwd, code, hidden); try (CloseableHttpResponse resp = client.execute(post)) { String body = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); // 双判据:页面无错误提示 + 探活通过 return !body.contains("验证码错误") && !body.contains("用户名或密码") && isAlive(); } } // 探活:请求必须登录才能访问的页面 public boolean isAlive() throws Exception { return Http.getStatus(client, "https://example-domain/home.do") == 200; } public CloseableHttpClient getClient() { return client; } }

这里把验证码处理抽成CaptchaProvider接口是有意为之:验证码策略变化最频繁,把它隔离出去,主流程就不用改。login方法里的双判据用的是「页面关键字黑名单 + 探活」,比单纯判断状态码稳得多。isAlive单独暴露出来,是为了让调用方在长任务里定期自查,会话失效了及时重登,而不是等到业务报错才发现。

5. 避坑:五类登录失败的排查顺序

这套流程我在不同站点上复刻过好几轮,失败的现象翻来覆去就那么几种。下面按「现象 → 原因 → 解决」列出来,建议照着顺序排查,因为大部分问题出在前两步,越往后越是小概率。

5.1 五类高频失败对照

现象一:POST 返回 200,但响应体还是登录页。原因:会话没接上,或者 rkey 已过期。最常见的是取验证码时换了一个 client,导致 Cookie 对不上;其次是从抓包到实际提交之间隔了太久,rkey 失效。 解决:确认三步请求共用一个 CookieStore;登录前打印一次store.getCookies(),看会话 Cookie 是否在;把「取页到提交」的总耗时控制在 60 秒以内,超时就重走一遍取页。

现象二:明确提示密码错误,但账号密码手输能登。原因:编码或加密方式不对。九成是 Base64 里的+被当成了空格,剩下一成是摘要算法用错(MD5 大小写、UTF-8 与 GBK 混用)。 解决:用UrlEncodedFormEntity而不是手拼字符串;打印加密后的字符串长度和首尾几位,跟浏览器抓包对比;确认摘要输出是小写十六进制。

现象三:第一次能登,隔一会儿再登直接 403。原因:频率触发了服务端限制,或者 UA、Cookie 与请求本身不匹配。 解决:登录间隔加到秒级以上并加随机抖动;UA 在整个会话生命周期内保持一致;不要在同一个 IP 上并发跑多个账号——这类站点对「同源多账号」很敏感。

现象四:响应里的中文用户名是乱码。原因:响应实体没有按声明编码解码,EntityUtils.toString(entity)不带参数时用的是默认字符集。 解决:统一传StandardCharsets.UTF_8;遇到个别页面用 GBK 的,从Content-Type头里解析 charset 再决定。

现象五:重定向循环,请求一直 302 打转。原因:Cookie 的 domain/path 不匹配,导致会话 Cookie 没被带上,服务端每次都认为你没登录。 解决:检查落盘再读回时 domain 是否带前导点、path 是否是/;关掉自动重定向手动跟一次,看Location是不是又跳回登录页,如果是,问题一定在 Cookie 匹配上。

5.2 排查顺序与日志怎么打

顺序上我固定为:先看 Cookie → 再看加密 → 再看编码 → 最后看频率。理由是前三项都是确定性问题,改一次就能复现验证;频率问题带随机性,放到最后排除才不会浪费时间。

日志只打四样东西:每步请求的 URL 与状态码、Cookie 的键名列表(不打值)、加密后密码的长度、最终判据的结果。Cookie 值、密码明文、完整响应体一律不进日志——这些东西一旦进了日志文件,等于给自己埋了个长期隐患。我们在一个内部项目里就因为打了完整 Cookie,导致日志被同步到共享盘,最后不得不全量清理,算是一次血泪经验。

6. 让自动登录稳跑一个月:限速、退避与探活脚本

把登录跑通只是起点,真正难的是让它连续跑一个月不出事。我后来固定加了三样东西,成本都不高,但能消掉大部分偶发失败。

第一是限速。登录类请求不要并发,用一个简单的信号量加随机间隔就够:

import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; // 每次登录前抖动 800~2000ms,避免固定节奏 public static void pace() throws InterruptedException { long ms = ThreadLocalRandom.current().nextLong(800, 2000); TimeUnit.MILLISECONDS.sleep(ms); }

固定的请求节奏比随机节奏更容易被识别,这个改动一行代码,效果比调任何参数都明显。

第二是退避重试,但只对网络层异常重试。连接超时、读超时可以退避后重试,ConnectTimeoutException按 2 的幂次退避,最多三次;而「密码错误」「验证码错误」这类业务失败绝对不能重试,重试只会加速触发限制。区分方法很简单:捕获IOException走重试,业务判据返回 false 就直接向上抛。

第三是探活。长任务里挂一个定时器,每 10 分钟调一次isAlive(),失败就重登并把新会话推给业务方。探活接口一定要选「必须登录才能访问」的页面,选首页这种匿名也能看的页面,探活永远是 200,等于没做。

还有一个容易忽略的点:会话文件要加锁。多进程同时读写同一个 Cookie 文件,会出现读到半行内容的情况,解析直接抛异常。我用的是文件锁加「写临时文件再原子重命名」,两行代码的事,但能避免一类极难复现的间歇性故障。

从那以后我每次做这类自动登录,都会先把「取页 → 取码 → 提交 → 探活」四步的日志跑一遍,确认每一步的产出都对得上,才开始灌账号。这个习惯救过我很多次,因为绝大多数问题在第一步就能暴露,而不是等到跑批量任务时才发现全部失败。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询