移动端登录态安全设计(1):App 登录时密码到底要不要加密?为什么通常走 HTTPS?

在做 App 登录接口时,很多移动端开发都会有一个疑问:

用户输入的密码,客户端到底要不要先加密一下再传给后端?

以前很多项目里常见的做法是:

复制代码
val passwordMd5 = md5(password)
api.login(username, passwordMd5)

看起来好像更安全,因为客户端没有直接把原始密码传给后端。

但真正从企业级认证体系来看,这个理解其实容易走偏。

更常见、更标准的做法是:

复制代码
App 收集用户输入的原始密码
  ↓
通过 HTTPS/TLS 加密通道传给后端
  ↓
后端用 bcrypt / Argon2id / PBKDF2 校验密码
  ↓
登录成功后返回 accessToken / refreshToken

也就是说,App 登录时通常传的是用户输入的原始密码,但它不是通过 HTTP 明文裸奔,而是在 HTTPS/TLS 加密通道里传输。

这篇文章就专门讲清楚这个问题。


一、先把问题说清楚:什么叫"明文密码"?

很多人一听到:

复制代码
客户端传原始密码

第一反应就是:

复制代码
这不是明文传输吗?

这里要区分两个层次。

从业务代码角度看,登录请求体可能是这样的:

复制代码
{
  "username": "wu",
  "password": "123456"
}

这确实是业务层的原始密码。

但是从网络传输角度看,只要走的是 HTTPS,这个请求体会在 TLS 通道里被加密传输。

所以不是:

复制代码
App --HTTP明文--> 后端

而是:

复制代码
App --HTTPS/TLS加密通道--> 后端

这两个概念一定要分清楚。

业务层看到的是原始密码,不代表网络层就是明文裸奔。


二、为什么不建议客户端先 MD5?

很多老项目会这么做:

复制代码
val passwordMd5 = md5(password)
api.login(username, passwordMd5)

看起来好像安全了一点,因为网络请求里没有出现 123456

但问题是:后端如果认可这个 MD5 值登录,那么这个 MD5 值本身就变成了"等价密码"。

比如:

复制代码
原始密码:123456
MD5 后:e10adc3949ba59abbe56e057f20f883e

如果后端登录接口接收的是:

复制代码
{
  "username": "wu",
  "password": "e10adc3949ba59abbe56e057f20f883e"
}

那么攻击者一旦拿到这个 MD5 值,就不需要知道原始密码 123456,直接提交这个 MD5 值也能登录。

这时你只是把:

复制代码
123456

换成了:

复制代码
e10adc3949ba59abbe56e057f20f883e

本质上并没有解决登录凭证泄漏的问题。

所以客户端 MD5 不是传输安全方案。

真正负责传输安全的是 HTTPS/TLS。


三、密码安全到底分成哪几段?

密码安全不能只看"客户端有没有加密"。

完整链路应该拆成三段。

1. 传输阶段

解决的问题是:

复制代码
App 到后端之间,密码会不会被中间人看到?

这一段靠 HTTPS/TLS。

复制代码
用户输入密码
  ↓
HTTPS/TLS 加密传输
  ↓
后端收到密码

所以登录接口必须强制 HTTPS,不能允许 HTTP 明文请求。


2. 后端验证阶段

后端收到密码后,不能把密码直接存起来。

正确做法是:

复制代码
用户输入的密码
  ↓
后端使用 bcrypt / Argon2id / PBKDF2 做密码哈希校验
  ↓
和数据库里的 password_hash 比较

注意,这里不是"解密数据库密码"。

数据库里不应该保存用户原始密码。


3. 数据库存储阶段

数据库里不应该是:

复制代码
password = 123456

也不推荐是:

复制代码
password = MD5(123456)

而应该是类似:

复制代码
password = bcrypt(123456)

或者:

复制代码
password = Argon2id(123456)

这样即使数据库泄漏,攻击者拿到的也不是用户原始密码,而是一串经过密码哈希算法处理后的结果。


四、为什么密码后端不需要"还原"?

很多人会疑惑:

密码做了 MD5 / bcrypt 之后不是无法还原了吗?那登录时怎么验证?

答案是:密码验证不需要还原。

注册时:

复制代码
用户密码:123456
  ↓
bcrypt 处理
  ↓
数据库保存 password_hash

登录时:

复制代码
用户再次输入:123456
  ↓
后端用同样算法重新计算
  ↓
和数据库里的 password_hash 比较

如果匹配,说明密码正确。

也就是说:

复制代码
密码安全存储的目标,就是让后端也无法还原用户密码。

所以正规系统通常不会提供"找回原密码",而是"重置密码"。

因为系统自己也不应该知道用户原始密码。


五、客户端到底应该做什么?

移动端在登录阶段的职责其实很明确。

客户端应该做:

复制代码
1. 收集用户输入的账号密码
2. 通过 HTTPS 发给后端
3. 不在本地保存密码
4. 不打印密码日志
5. 不把密码写入崩溃上报
6. 不用 MD5 伪装成安全加密

客户端不应该做:

复制代码
1. 把密码存在 SharedPreferences / MMKV / SQLite
2. 把密码打印到 Logcat
3. 把密码放到 URL 参数里
4. 自己做 MD5 后当成登录密码
5. 把所谓加密密钥写死在 APK 里

错误示例:

复制代码
POST /login?username=wu&password=123456

密码不要放 URL。

推荐:

复制代码
POST /login
Content-Type: application/json

{
  "username": "wu",
  "password": "123456"
}

并且必须走 HTTPS。


六、后端到底应该做什么?

后端才是密码安全的核心责任方。

后端应该做:

复制代码
1. 强制登录接口走 HTTPS
2. 不保存明文密码
3. 使用 bcrypt / Argon2id / PBKDF2 存储密码哈希
4. 登录时使用 PasswordEncoder.matches() 这类安全比较方式
5. 不打印登录请求里的 password
6. 登录失败做限流 / 锁定 / 验证码 / 风控
7. 登录成功后签发 accessToken 和 refreshToken

Spring Security 中常见做法是:

复制代码
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

注册时:

复制代码
String encodedPassword = passwordEncoder.encode(rawPassword);
user.setPassword(encodedPassword);

登录时:

复制代码
boolean matched = passwordEncoder.matches(
    loginRequest.getPassword(),
    user.getPassword()
);

这里的 matches() 不是解密,而是重新计算并比较。


七、那 AES + RSA 登录加密有没有意义?

有,但不是普通项目的第一选择。

普通项目通常是:

复制代码
账号密码
  ↓
HTTPS
  ↓
后端

这已经解决传输加密问题。

但是在一些高安全场景,比如金融、支付、政企、医疗、设备控制等业务中,可能会在 HTTPS 外再做一层业务加密。

这时可以使用 AES + RSA 的混合加密。

流程大概是:

复制代码
App 生成临时 AES key
  ↓
AES-GCM 加密登录请求体
  ↓
RSA 公钥加密 AES key
  ↓
把 encryptedKey + iv + cipherText 发给后端
  ↓
后端 RSA 私钥解出 AES key
  ↓
后端 AES-GCM 解出登录请求体

也就是:

复制代码
AES 负责加密数据
RSA 负责加密 AES key

但这属于业务层额外加密,不是用来替代 HTTPS 的。

即使做了 AES + RSA,也仍然要走 HTTPS。


八、为什么 HTTPS 已经做了很多事?

HTTPS/TLS 本身底层就有类似思想:

复制代码
先通过非对称加密 / 密钥交换协商出会话密钥
  ↓
后续大量通信使用对称加密算法传输数据

也就是说,HTTPS 已经在传输层做了:

复制代码
密钥协商
身份校验
数据加密
完整性保护

所以普通 App 登录接口不需要自己再造一套"客户端 RSA 加密密码"的轮子。

否则很容易出现:

复制代码
密钥写死在客户端
加密参数设计错误
后端解密流程复杂
日志仍然泄漏密码
误以为有业务加密就可以不用 HTTPS

这些反而会制造新的安全问题。


九、真正要警惕的是日志泄漏

很多密码和 Token 泄漏,不是因为 AES、RSA、HTTPS 被破解,而是自己打日志打出去了。

比如客户端:

复制代码
Log.d("Login", "password = $password")

或者 OkHttp 日志打印了完整请求体。

后端也可能打印:

复制代码
LoginRequest(username=wu, password=123456)

这些都很危险。

登录相关日志应该做到:

复制代码
password:永远不打印
accessToken:永远不打印
refreshToken:永远不打印
Authorization:脱敏
Cookie:脱敏
验证码:不打印

可以打印:

复制代码
登录成功 / 登录失败
用户 ID
设备 ID
错误码
请求耗时
风险标签

但不要打印密码和 Token。


十、最终结论

App 登录时,密码到底要不要加密?

更准确的答案是:

复制代码
客户端通常不需要自己对密码做 MD5 / bcrypt / RSA。
客户端应该通过 HTTPS 把用户输入的原始密码发给后端。
后端负责使用 bcrypt / Argon2id / PBKDF2 做密码验证和安全存储。

所以这件事的分工是:

复制代码
客户端:
- 负责 HTTPS 传输
- 不保存密码
- 不打印密码
- 不用客户端 MD5 当安全方案

后端:
- 负责密码哈希存储
- 负责密码校验
- 负责登录失败风控
- 负责签发 Token

Token 阶段:
- 客户端负责安全保存 accessToken / refreshToken
- 后端负责 Token 校验、刷新和失效

一句话总结:

App 登录时传原始密码并不等于明文裸奔,前提是必须走 HTTPS;密码安全的重点不是客户端先 MD5,而是 HTTPS 传输 + 后端安全哈希 + 日志脱敏 + 登录风控。