
简介这是一份面向Java初学者与网络请求实践者的简易登录示例围绕人人网校内自动登录场景整理重点解决旧版登录程序难以直接复用、依赖缺失和运行环境不明确等问题。压缩包共4个文件整体约4.31MB内含1个Java源码文件和3个依赖库压缩包源码承担登录主流程依赖包则覆盖HTTP通信、编码处理与日志输出等常用组件需按压缩包内说明自行添加后再运行。已有43239人学习下载可见这一旧版登录案例仍有一定参考热度。读者可据此了解登录请求的构造方式、请求参数与响应处理思路以及第三方库的引入和配置方法并在此基础上扩展验证码识别、会话保持、登录结果解析等功能。源码保持精简适合作为研究旧站点登录机制、完成课程练习或进行二次开发的入门样本。1. 一个登录按钮背后的三次失败去年帮朋友做一个校园社交平台的数据备份小工具第一步就卡在自动登录上。抓包看登录请求账号是明文密码却是一长串 Base64直接复制粘贴到 Java 代码里跑返回永远是「参数错误」。前后改了三版第一版没带初始 Cookie第二版用错了 RSA 填充模式第三版忘了把时间戳字段拼进去。等这套流程跑通我才意识到所谓「自动登录」根本不是发一个 POST 那么简单它是一条由预请求、加密参数、会话票据、保活定时器拼起来的链子任何一环对不上服务器就只回你一个 200 加一句模糊的错误提示。这份 Java 代码做的就是这条链子拿到账号密码之后自动完成加密、提交、验证码分支处理、Cookie 落盘与失效重登把「登录态」变成一个可以反复取用的对象。适合两类人一类是手上有自己的老账号、想把里面的内容批量导出来的开发者另一类是想学 HTTP 模拟登录这套基本功的人——它是练手 Jsoup、HttpClient、Cookie 管理最典型的场景比拿一个公开 API 打转要真实得多。下面的拆解都基于一个同构的模拟项目接口路径和字段名做了替换逻辑结构保持一致。2. 项目定位与技术选型Java 写自动登录该上哪些轮子2.1 模拟项目做到哪一步先把边界说清楚免得读下去预期错位。这个模拟项目不是一个带界面的客户端它是一个纯后端的小型 Java 工程对外暴露三个能力login(account, password)返回登录结果、alive()判断当前会话还有没有效、getHttpClient()交出一个已经带上 Cookie 的客户端实例后续所有业务请求直接复用。换句话说它把「登录」这件事封装成一个可注入的基础设施业务侧不关心密码怎么加密的、Cookie 存在哪。它覆盖的场景包括明文表单登录、密码前端 RSA 加密后登录、带图形验证码的登录、登录成功后 Cookie 序列化到本地文件、定时探测会话有效性并在失效时自动重登。不覆盖的场景也要讲明白扫码登录、短信验证码登录、需要滑块轨迹验证的登录这些不在工程范围内强行加进来的复杂度远超这份代码的价值。工程规模上核心类大概七八个加上工具类和测试不到两千行。这个体量是有意控制的结果——登录逻辑一旦写厚改一个字段就要动三个文件反而是负担。我在实际项目里见过把整个风控对抗逻辑塞进登录模块的最后没人敢碰账号一被封就全组加班。技术栈选得很朴素JDK 8 或 11、Apache HttpClient 4.5 负责发请求、Jackson 解析返回的 JSON、SLF4J Logback 打日志、JUnit 4 写单测验证码识别部分留了接口默认实现是保存图片到本地让人工输入需要自动化时接一个 OCR 或打码服务。没有 Spring没有数据库配置文件就是一个config.properties。选 JDK 8 起步不是怀旧。这类工具很多跑在别人的旧机器或者内网服务器上装个 JDK 17 反而给自己找麻烦用到的新语法也就是var、Optional、java.time这几样向下兼容没什么成本。HttpClient 4.5 是这套技术栈里最稳的一环连接池、Cookie 管理、重试策略都是现成的比手搓HttpURLConnection少写几百行。2.2 HTTP 客户端选型HttpClient、OkHttp、Selenium 各管什么这部分值得单独讲因为选错客户端的代价是重写整个登录模块。三个候选的定位差别很大方案适合场景代价Apache HttpClient 4.5接口字段固定、无 JS 执行、纯表单提交需要自己还原加密参数OkHttp 3/4同上链式 API 更顺手Android 端复用方便生态文档比 HttpClient 少一些Selenium / HtmlUnit页面靠 JS 动态生成参数、有复杂风控启动慢、内存高、并发差判断标准其实只有一条登录参数是不是由前端 JS 在运行时算出来的。如果密码加密用的公钥写死在 JS 文件里、时间戳就是Date.now()那 HttpClient 完全够用你把参数还原出来就行。如果参数里带了浏览器指纹、鼠标轨迹、canvas哈希这些东西纯 HTTP 模拟就会被识别这时候才轮到 Selenium 出场。这个项目选 HttpClient原因是目标站点的加密逻辑相对干净一个固定公钥的 RSA、一个毫秒时间戳、一个来源域名没有动态下发密钥。我在实际动手前会先做一件事——把登录页的 JS 全部prettify一遍搜索encrypt、password、publicKey这几个关键词五分钟就能判断出属于哪一档。这个习惯省下的时间比事后调试多得多。提示不要把 Selenium 当成万能回退方案。它解决的是「浏览器环境模拟」不是「登录逻辑本身」风控升级到设备指纹层面时它一样会被拦而且调试成本翻倍。2.3 目录结构与模块划分项目结构按职责切分每层只做一件事auto-login-demo/ ├── src/main/java/com/example/login/ │ ├── LoginClient.java # 对外入口串起整个登录流程 │ ├── LoginResult.java # 登录结果值对象 │ ├── Credential.java # 账号凭据含账号/密码/验证码 │ ├── http/ │ │ ├── HttpClientFactory.java # 连接池、超时、UA 统一配置 │ │ └── CookieStore.java # Cookie 获取、落盘、过期过滤 │ ├── crypto/ │ │ └── RsaEncryptor.java # 密码加密公钥可配置 │ ├── captcha/ │ │ ├── CaptchaSolver.java # 识别接口 │ │ ├── ManualSolver.java # 默认存图 控制台输入 │ │ └── OcrSolver.java # 可选接第三方识别 │ └── keepalive/ │ └── SessionKeeper.java # 定时探测 失效重登 ├── src/main/resources/ │ └── config.properties # 域名、路径、公钥、UA、重试次数 └── src/test/java/ # 单测用本地 Mock Server 跑这个划分的关键在于http和crypto两个包不依赖业务语义。换一个站点理论上只需要改config.properties和LoginClient里的字段拼装加密和 Cookie 这两块可以原样搬走。实际迁移的时候确实如此我把它挪到第二个站点上只改了一个类。config.properties里放的是易变项把硬编码从代码里彻底赶出去login.base.urlhttps://example-campus.com login.page.path/login login.submit.path/login.do login.rsa.publicKeyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ... login.userAgentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 login.retry.max3 cookie.file./data/cookie.bin keepalive.interval.minutes10公钥放配置而不是常量是因为它会变。站点前端换一次密钥你只需要改一行配置重启不用重新打包。这个细节在单人开发时无所谓放到有 CI 的环境里就是省一次发版。3. 登录链路拆解一次成功登录要凑齐哪些参数3.1 抓包定位真实登录接口打开浏览器开发者工具勾上Preserve log因为登录成功后往往有一串 302 跳转不勾的话日志会被清掉你就只能看到跳转后的首页请求找不到真正的提交地址。在登录页填一次账号密码点提交在 Network 面板按Method: POST过滤第一个application/x-www-form-urlencoded的请求基本就是目标。点开这个请求重点看三样Request URL、Form Data、Response Headers里的Set-Cookie。Form Data 里会有一堆看着没用的字段比如origURL、domain、autoLogin、_rt别嫌烦能带就带上——服务端常有「字段缺失即判为异常请求」的兜底逻辑少一个就返回参数错误而错误提示往往和真正的原因完全无关这就是这个场景里最典型的黑匣子。还有一个容易漏的点登录页本身的 GET 请求。有些站点会在你打开登录页时下发一个初始 Cookie比如风控标识、CSRF token登录 POST 时必须带上否则参数校验通过也会被判为跨站请求。所以完整流程是「先 GET 登录页 → 再 POST 登录接口」两步之间共用同一个HttpClient实例和同一个 CookieStore。3.2 密码加密参数的还原抓包看到password字段是一串 Base64长度 172 或 344 字符这就是 RSA 加密的典型特征——1024 位密钥加密后 Base64 是 172 字符2048 位是 344 字符。去前端 JS 里搜publicKey或者setPublicKey能定位到公钥字符串和填充模式。还原的时候有两个坑必须踩实。第一是填充模式前端用的往往是RSA/ECB/PKCS1PaddingJava 默认的Cipher.getInstance(RSA)在部分 JDK 上等价于RSA/ECB/PKCS1Padding但另一些实现会走不同 provider为保险起见显式写全。第二是编码前端JSEncrypt默认按 UTF-8 转字节再加密Java 侧如果用了平台默认编码中文密码或者特殊字符就会出问题。public final class RsaEncryptor { private final PublicKey publicKey; public RsaEncryptor(String base64PublicKey) { try { // 前端导出的是 X509 格式的 SPKI不是 PKCS#1用 X509EncodedKeySpec 解析 X509EncodedKeySpec spec new X509EncodedKeySpec( Base64.getDecoder().decode(base64PublicKey)); this.publicKey KeyFactory.getInstance(RSA).generatePublic(spec); } catch (Exception e) { throw new IllegalStateException(公钥解析失败检查是否被换过, e); } } public String encrypt(String plain) { try { // 必须和前端 JSEncrypt 的填充方式一致否则解出来是乱码 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); } catch (Exception e) { throw new IllegalStateException(密码加密失败, e); } } }X509EncodedKeySpec和PKCS8EncodedKeySpec的区别是前者用于公钥、后者用于私钥混用会直接抛InvalidKeySpecException。另外 RSA 单块加密有长度上限1024 位密钥最多 117 字节明文密码场景当然够用但如果你打算把别的长字段也塞进去加密就得改成分块或者换 AES 对称加密。密钥版本号这个字段示例里叫key_id也不能丢。站点做密钥轮换时会同时保留新旧两把公钥靠这个字段告诉服务端用哪把解你不传它就默认用最新版某天密钥一换旧代码立刻全挂。3.3 表单字段与请求头拼装字段拼装单独抽一个类别散在登录方法里。原因很实际这些字段的增减频率比想象中高抽出来之后改动面收敛在一处也方便写单测对比抓包结果。public final class LoginParamBuilder { public static MapString, String build(String account, String encryptedPwd, String captcha, long timestamp) { MapString, String form new LinkedHashMap(); form.put(email, account); // 账号字段部分站点叫 username form.put(password, encryptedPwd); // 已加密不能传明文 form.put(icode, captcha); // 无验证码时传空串不能省这个 key form.put(origURL, http://example-campus.com/home); form.put(domain, example-campus.com); form.put(key_id, 1); // 公钥版本号 form.put(_rt, String.valueOf(timestamp)); // 毫秒时间戳服务端做时效校验 return form; } }LinkedHashMap不是随手选的某些站点的签名逻辑对参数顺序敏感用HashMap可能十次里错三次而且错得毫无规律属于典型的玄学 bug。时间戳的时效窗口一般在 5 到 30 分钟跨时区或者服务器时间漂移都会导致「参数校验失败」排查时先对一下本机时间和标准时间差多少。登录主流程把前面几块串起来public LoginResult login(Credential credential) throws IOException { // 第一步GET 登录页拿初始 Cookie风控标识/CSRF token HttpGet page new HttpGet(cfg.get(login.base.url) cfg.get(login.page.path)); page.setHeader(User-Agent, cfg.get(login.userAgent)); try (CloseableHttpResponse resp client.execute(page)) { EntityUtils.consume(resp.getEntity()); // 不消费实体会导致连接不归还连接池 } // 第二步POST 登录接口 HttpPost post new HttpPost(cfg.get(login.base.url) cfg.get(login.submit.path)); post.setHeader(User-Agent, cfg.get(login.userAgent)); post.setHeader(Referer, cfg.get(login.base.url) cfg.get(login.page.path)); post.setHeader(Content-Type, application/x-www-form-urlencoded); String encrypted rsa.encrypt(credential.getPassword()); ListNameValuePair form new ArrayList(); LoginParamBuilder.build(credential.getAccount(), encrypted, credential.getCaptcha(), System.currentTimeMillis()) .forEach((k, v) - form.add(new BasicNameValuePair(k, v))); post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); try (CloseableHttpResponse resp client.execute(post)) { int status resp.getStatusLine().getStatusCode(); String body EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); // 成功判定要「状态码 票据」双条件只看 302 会误判 boolean hasTicket cookieStore.contains(t) || cookieStore.contains(id); boolean ok (status 302 || status 200) hasTicket; return new LoginResult(ok, status, body); } }几个参数单独说。Referer必须带上很多站点的服务端会校验来源缺失时返回的仍然是 200但内容是一段重定向脚本光看状态码发现不了。EntityUtils.consume这一行看着多余实际不加的话连接池很快就会耗尽跑批几十次之后开始超时你会误以为是对方限流。判断成功与否是这个方法里最容易写错的地方。很多人只看status 302就认为登录成功但验证码错误、密码错误同样会 302 回登录页只是Location不同。稳妥做法是双条件跳转目标不是登录页并且 Cookie 里出现了会话票据字段名。票据的 key 名从抓包时的Set-Cookie里抄别猜。4. 核心模块实现Cookie 池、验证码与保活4.1 CookieStore登录态落盘与复用登录一次要花两三秒如果每次跑任务都重新登录既慢又容易触发风控。正确做法是把 Cookie 序列化到本地下次启动先尝试加载加载成功就直接判活活着就不登录。public class CookieStore { private final BasicCookieStore store new BasicCookieStore(); private final Path file; public CookieStore(Path file) { this.file file; load(); } public synchronized void save() { try { Files.createDirectories(file.getParent()); try (ObjectOutputStream out new ObjectOutputStream(Files.newOutputStream(file))) { // org.apache.http.cookie.Cookie 实现了 Serializable可直接序列化 out.writeObject(store.getCookies()); } } catch (IOException e) { log.warn(Cookie 落盘失败不影响本次运行, e); } } SuppressWarnings(unchecked) private synchronized void load() { if (!Files.exists(file)) return; try (ObjectInputStream in new ObjectInputStream(Files.newInputStream(file))) { ListCookie list (ListCookie) in.readObject(); for (Cookie c : list) { // 过期的不装载否则会带着死 Cookie 发请求 if (c.getExpiryDate() null || c.getExpiryDate().after(new Date())) { store.addCookie(c); } } } catch (Exception e) { log.warn(Cookie 文件损坏忽略并重新登录, e); try { Files.deleteIfExists(file); } catch (IOException ignored) { } } } public synchronized boolean contains(String name) { return store.getCookies().stream().anyMatch(c - c.getName().equals(name)); } public BasicCookieStore unwrap() { return store; } }这段代码里最值得抄的是「过期过滤」和「损坏自愈」。Cookie 文件是二进制序列化格式跨 JDK 版本或者中途被截断都会反序列化失败ObjectInputStream抛的异常还挺迷惑不捕获就是启动直接崩。删掉重来成本极低没必要跟它较劲。另外序列化存 Cookie 意味着这个文件本身等价于账号凭据谁拿到谁就能登录。所以我在落盘时会顺手设一下文件权限Linux 下Files.setPosixFilePermissions设成 600Windows 上至少别放在共享目录。这一点在本地开发时容易忽略一旦代码跑在多人共用的服务器上就是事故。4.2 验证码分支OCR、打码、手动兜底验证码是这个项目里唯一没法用纯代码稳定解决的问题所以设计上做成策略接口让三种实现可以随时切换。public interface CaptchaSolver { /** param image 验证码图片字节 return 识别结果无法识别返回 null */ String solve(byte[] image) throws Exception; } public class ManualSolver implements CaptchaSolver { Override public String solve(byte[] image) throws Exception { Path tmp Files.createTempFile(captcha-, .png); Files.write(tmp, image); // 弹系统默认看图程序人工在控制台输入 Desktop.getDesktop().open(tmp.toFile()); System.out.print(请输入验证码: ); return new Scanner(System.in).nextLine().trim(); } }手动方案看着土但它是所有自动化方案的后备。我第一次写这套东西时直接上了 OCR识别率 40%失败了就重试重试三次后账号被临时限制得不偿失。后来改成「先 OCR置信度低就落人工」成功率立刻上去了实际人工介入的比例不到 5%。登录流程里判断是否需要验证码的依据是第一步 GET 登录页的返回如果页面 HTML 里出现了验证码图片的src或者接口返回里有个needCaptcha字段为 true就走验证码分支。图片地址一般形如/getCaptcha.do?t时间戳这个时间戳不能复用每取一次都要重新打否则拿到的是同一张过期图。重试的节奏也要控制。同一账号连续失败 3 次就停下来隔几分钟再试中间不要立刻换验证码猛刷。风控判断的依据往往不是失败次数本身而是失败频率——一秒钟试五次和一分钟试三次后果完全不同。注意验证码识别只用于自己账号的自动化登录别拿去做批量注册或者撞库那是另一个性质的事跟技术无关。4.3 保活与失效重登登录态不是永久的。服务器端会话一般 30 分钟到几小时不等业务侧如果正在跑长任务中途掉线会导致后面所有请求返回登录页 HTML而解析代码会给你一堆莫名其妙的空指针。保活的做法是定时打一个轻量接口看返回内容里有没有用户标识public class SessionKeeper { private final ScheduledExecutorService pool Executors.newSingleThreadScheduledExecutor(r - { Thread t new Thread(r, session-keeper); t.setDaemon(true); // 守护线程主程序退出时自动结束 return t; }); public void start(LoginClient client, long intervalMinutes) { pool.scheduleWithFixedDelay(() - { try { if (!client.alive()) { log.info(会话失效触发重登); client.relogin(); } } catch (Exception e) { // 这里必须吞掉异常否则一次网络抖动会让定时任务永久停止 log.warn(保活探测异常下个周期重试, e); } }, intervalMinutes, intervalMinutes, TimeUnit.MINUTES); } }alive()的实现别用首页探测首页往往是游客也能访问的永远返回 200。要挑一个必须登录才能拿到的接口比如用户信息或者消息数看返回 JSON 里有没有userId字段有就是活着。探测频率控制在 10 分钟一次太频繁本身就是异常行为。scheduleWithFixedDelay而不是scheduleAtFixedRate是因为前者保证上一次执行完才计时下一次不会出现任务堆积。最后一个坑是异常处理ScheduledExecutorService里抛出的未捕获异常会静默终止后续调度任务就再也不跑了而日志上只有一行堆栈很容易被忽略。所以try-catch包住整个任务体是必须的。5. 避坑与排查登录成功率从 30% 拉到 95% 的几条经验5.1 现象本地能登录服务器上一直返回参数错误原因基本锁定在时间戳。加密参数里的_rt或者ts字段是毫秒时间戳服务端会校验它和服务器时间的差值超出窗口就判为过期请求。本地机器常年开着时间同步没问题容器或者虚拟机从快照恢复之后时间可能停在几个月前。解决办法是启动时先做一次时间校准检查误差超过 60 秒就打警告日志并在日志里把本机时间和响应头里的Date都打出来。生产环境上建议直接接入时间同步别在代码里做偏移补偿那是给自己埋雷。5.2 现象抓包复制的请求在 Postman 里能通Java 里跑不通大概率是请求头差异。浏览器会自动带上Accept、Accept-Language、Accept-Encoding、Origin、Referer、User-Agent这一整套而 HttpClient 默认只带Host和Connection。服务端如果做了来源校验缺Origin或者Referer就会被拒。排查方法是把浏览器请求头全量导出在 Java 里逐个加上每次去掉一个做二分找出真正被校验的那几个。通常只有User-Agent、Referer、Origin三个是必需的其余加上只是让请求更像浏览器。5.3 现象前 20 次调用正常之后开始大面积超时十有八九是连接没释放。CloseableHttpResponse用完必须关闭而且要保证实体被消费掉否则连接回不到池子里。EntityUtils.toString()会消费实体但如果代码走了异常分支提前返回实体就悬在那了。解决方式是用 try-with-resources 包住每一次execute并且在只关心状态码不关心内容的请求里显式调用EntityUtils.consume(resp.getEntity())。另外把连接池上限和validateAfterInactivity配上默认值在长任务里不够用。5.4 现象登录成功但下一个请求返回的是登录页 HTML这是 Cookie 域不匹配。BasicCookieStore里的 Cookie 带domain属性如果登录接口在passport.example.com业务接口在www.example.com而 Cookie 的 domain 是.example.com正常情况能共享但如果你手动构造 Cookie 或者跨域抓取domain 对不上HttpClient 就不会把它发出去。排查时打印store.getCookies()看每条的 domain 和 path和实际请求的 URL 对比。修复办法是用HttpClientContext显式管理而不是让框架自动匹配或者在构造BasicClientCookie时把 domain 设成父域。5.5 现象验证码明明输对了还是提示错误有两种可能。一是验证码和会话绑定你取验证码图用的 Cookie 和提交登录用的 Cookie 不是同一套服务端自然对不上这种情况下必须保证「取图 → 提交」在同一个HttpClient实例上完成。二是验证码有一次性识别失败后重新取图时旧的作废如果你缓存了图片地址重复使用拿到的就是上一张。解决方式是每次重试都重新走一遍「GET 验证码图 → 识别 → 提交」中间不缓存任何东西。这条在并发场景下更要小心多线程共用一个 CookieStore 时记得给取图和提交加同步块。6. 二次开发把这套登录能力做成可复用的 LoginProvider代码跑通之后真正的价值在于让它能适配第二个、第三个站点而不是每次复制粘贴改字段。我的做法是先抽一层接口把「怎么登录」和「登录之后干什么」彻底分开。public interface LoginProvider { LoginResult login(Credential credential) throws Exception; boolean alive(); void relogin() throws Exception; } // 具体实现里只关心本站在的表单和密码规则 public class CampusLoginProvider implements LoginProvider { private final LoginClient client; // 复用第三节的登录主流程 private final RsaEncryptor rsa; Override public LoginResult login(Credential c) throws Exception { // 站点特有的差异化逻辑全部收敛在这里 return client.login(c, rsa); } Override public boolean alive() { return client.checkUserInfo() ! null; } Override public void relogin() throws Exception { client.clearCookies(); } }抽完这层之后接新站点的成本从「改一周」降到「改一个实现类 一份配置」。我一般会顺手写一个LoginProviderFactory根据配置里的站点标识返回对应实现业务侧只依赖接口连类名都不用知道。改造完一定要验证别信「本地跑通了」。我给自己定的自测清单是这几项验证项方法通过标准冷启动登录删掉 cookie 文件后运行能拿到会话票据Cookie 复用连续运行两次第二次不发起登录 POST失效重登手动清空 CookieStore 触发保活30 秒内恢复可用会话异常兜底断网后恢复定时任务不终止并发安全5 线程同时取客户端不出现 Cookie 覆盖其中「Cookie 复用」这条最容易被忽略很多人写完登录就完事结果每次跑任务都重新登录一遍风控日志里全是同一个账号的高频登录记录。用抓包工具或者日志计数器确认第二次启动没有发出 POST才算真的做完了缓存。还有一个技巧值得单独说把登录过程的每一步都打上带耗时的结构化日志字段包括步骤名、状态码、关键响应字段、耗时毫秒。出问题时不用重新复现翻日志就能定位到是取 Cookie 慢、加密失败还是判定逻辑误判。这套日志在我后来接手的每个爬虫类项目里都保留了几乎没有例外。从那以后我每次写模拟登录都强制走一遍「先 GET 登录页、再 POST」的两步流程哪怕看起来第一步毫无必要——因为一旦省掉出问题时你根本分不清是参数错了还是会话没建立只能从头再来一遍。希望帮到你。本文还有配套的精品资源点击获取