Scala 写 Web 服务,安全性常常被框架的优雅抽象「顺带解决」——直到线上被打穿。本篇不讲泛泛的安全口号,而是把认证、授权、密码学与加固逐条落到可运行的 Scala 代码上:Play、akka-http、http4s 都能套用,tagless-final 与 ZIO 的抽象则让鉴权逻辑可测试、可替换、可审计。
目录
- 1. 认证与授权基础
- 2. JWT 深入解析
- 3. OAuth2 与 OIDC 实战
- 4. 密码存储与哈希
- 5. 会话与 Cookie 安全
- 6. 授权模型 RBAC 与 ABAC
- 7. 常见 Web 漏洞与防护
- 8. 密钥与配置管理
- 9. 安全测试与生产加固
- 10. 速查表与一句话记忆
- 延伸阅读
1. 认证与授权基础
Authentication(认证)回答「你是谁」,Authorization(授权)回答「你能做什么」。两者混用是绝大多数越权漏洞的根因:把「已登录」当成「有权限」,于是 IDOR(越权访问他人资源)遍地开花。会话与 Token 之争,本质是状态放在服务端还是凭证里。
- 认证:验证身份凭证,产出 Principal(主体)这个不可变值
- 授权:基于 Principal + 资源 + 动作,判定 Allow 或 Deny
- 会话 Session:服务端存状态,客户端只拿不透明随机 ID
- Token:状态编码进凭证本身,服务端无状态或弱状态
- 无状态不等于无撤销:Token 必须有吊销通道,否则登出形同虚设
- 认证失败与授权失败必须区分:401 未认证,403 已认证但无权限
final case class Principal(
userId: String,
tenantId: String,
roles: Set[String],
scopes: Set[String]
)
sealed trait Decision
object Decision {
case object Allow extends Decision
final case class Deny(reason: String) extends Decision
}
// 认证层只负责把凭证换成 Principal,其余一概不管
trait Authenticator[F[_]] {
def authenticate(token: String): F[Either[String, Principal]]
}
工程要点:把 Principal 作为请求上下文的不可变值一路向下传递,授权判定只依赖它;绝不要在业务代码里再读一次 Cookie 或 Header 去「顺便判断身份」,那正是绕过统一鉴权的起点。
2. JWT 深入解析
JWT 是 header.payload.signature 三段 Base64URL,用点号连接。header 声明算法与密钥标识,payload 放 claims,signature 覆盖前两段的原始字节。关键认知:JWT 默认只是签名,不是加密——payload 任何人都能 Base64 解出,绝不能放敏感数据。
- 标准 claims:iss 签发者、sub 主体、aud 受众、exp 过期、nbf 生效、iat 签发时间、jti 唯一 ID
- exp 必须校验,且服务端与客户端时钟要同步(NTP 偏差是「莫名 401」的常客)
- access token 短命(5~15 分钟),refresh token 长命且可轮换
- 撤销只能靠黑名单:把 jti 或 token 指纹写入 Redis,TTL 设为 token 剩余寿命
- 常见漏洞:alg=none 降级、RS256 公钥当 HS256 密钥的密钥混淆、kid 注入
| 算法 | 类型 | 密钥分发 | 适用场景 |
|---|---|---|---|
| HS256 | 对称 HMAC | 共享密钥,需安全分发 | 单体服务、内部调用 |
| RS256 | 非对称 RSA | 私钥签名,公钥验签 | 多服务、开放平台 |
| ES256 | 非对称 ECDSA | 同上,密钥更短 | 移动端、低带宽 |
import pdi.jwt._
import java.time.Instant
val claim = JwtClaim(
issuer = Some("https://auth.example.com"),
subject = Some(userId),
audience = Some(Set("api")),
expiration = Some(Instant.now.plusSeconds(900).getEpochSecond),
issuedAt = Some(Instant.now.getEpochSecond),
jwtId = Some(java.util.UUID.randomUUID().toString)
)
// HS256:对称密钥,务必 32 字节以上随机值
val hsToken = Jwt.encode(claim, hmacSecret, JwtAlgorithm.HS256)
// RS256:私钥签名,服务只拿公钥验签,杜绝密钥扩散
val rsToken = Jwt.encode(claim, privateKey, JwtAlgorithm.RS256)
val verified = Jwt.decode(rsToken, publicKey, Seq(JwtAlgorithm.RS256))
// 撤销:登出时把 jti 写入黑名单,TTL 与 exp 对齐
def revoke(jti: String, ttlSeconds: Long): Unit =
redis.setex(s"jwt:blacklist:$jti", ttlSeconds, "1")
工程要点:验签时必须显式传入允许的算法白名单(Seq(JwtAlgorithm.RS256)),让库去匹配 header 里的 alg;只校验签名不校验 iss/aud/exp 的代码,等于把 token 借用漏洞直接送人。
3. OAuth2 与 OIDC 实战
OAuth2 是授权框架(拿访问权),OIDC 是在其上加的认证层(拿身份,多一个 id_token)。生产上只应使用授权码模式 + PKCE;隐式模式与密码模式已被 OAuth 2.1 废弃。客户端凭证模式用于服务间无用户场景。
- 授权码 + PKCE:前端生成 code_verifier,传 SHA-256 摘要作 code_challenge
- 回调时必须带原始 verifier,授权服务器比对,防授权码拦截重放
- state 参数防 CSRF,nonce 参数防 id_token 重放,二者都要随机且一次性
- scope 最小化:只申请用得到的,如 openid profile email
- 资源服务器校验:本地 JWT 验签 或 RFC 7662 token introspection
- Keycloak 自托管、Auth0 与 Okta 托管,三者都兼容标准发现端点
import java.security.{MessageDigest, SecureRandom}
import java.util.Base64
val rnd = new SecureRandom()
val bytes = new Array[Byte](32)
rnd.nextBytes(bytes)
// PKCE:verifier 随机且只存本地,challenge 走授权请求
val verifier = Base64.getUrlEncoder.withoutPadding.encodeToString(bytes)
val challenge = Base64.getUrlEncoder.withoutPadding.encodeToString(
MessageDigest.getInstance("SHA-256").digest(verifier.getBytes("UTF-8"))
)
val authorizeUrl =
s"https://idp.example.com/auth?response_type=code" +
s"&client_id=$clientId&redirect_uri=$redirectUri" +
s"&scope=openid%20profile&state=$state&nonce=$nonce" +
s"&code_challenge=$challenge&code_challenge_method=S256"
工程要点:redirect_uri 必须在服务端做精确匹配白名单,任何前缀匹配或通配符都会被用来窃取授权码;token introspection 有网络开销,高并发下应缓存结果并把 TTL 设得远小于 token 剩余寿命。
4. 密码存储与哈希
密码存储只有一条铁律:用慢哈希 + 每用户独立盐。MD5、SHA-1、SHA-256 这类快哈希即使加盐也能被 GPU 每秒跑上亿次,等于明文。salt 防彩虹表,pepper 防数据库拖库后的离线爆破——pepper 存应用侧(KMS 或环境变量),不进数据库。
| 算法 | 抗 GPU | 推荐参数 | 备注 |
|---|---|---|---|
| bcrypt | 中 | cost 12 至 14 | 通用稳妥,输入上限 72 字节 |
| scrypt | 高 | N=32768 r=8 p=1 | 用内存换抗并行 |
| Argon2id | 最高 | m=64MB t=3 p=4 | 新项目首选 |
import org.mindrot.jbcrypt.BCrypt
import java.security.MessageDigest
// bcrypt:cost 12 约 250ms,硬件升级后提高 cost 并渐进重算
def hashPassword(raw: String, pepper: String): String =
BCrypt.hashpw(raw + pepper, BCrypt.gensalt(12))
def verify(raw: String, pepper: String, stored: String): Boolean =
BCrypt.checkpw(raw + pepper, stored)
// 若必须自研比较(如校验 HMAC 指纹),一定要 constant-time
def safeEquals(a: Array[Byte], b: Array[Byte]): Boolean =
MessageDigest.isEqual(a, b)
工程要点:永远不要用 == 或 String.equals 比较摘要与签名,短路比较会泄漏前缀匹配长度,构成时序侧信道;登录失败信息要统一成「用户名或密码错误」,避免暴露账号是否存在。
5. 会话与 Cookie 安全
会话 ID 是最高价值凭证,一旦被 XSS 偷走就等同于账号。Cookie 的三个属性构成第一道防线:HttpOnly 阻断 JS 读取,Secure 只走 HTTPS,SameSite 限制跨站携带。CSRF 的本质是浏览器自动带上 Cookie,所以防的是「跨站发起」,不是「跨站读取」。
- HttpOnly:JS 拿不到,XSS 无法直接窃取会话
- Secure:仅 HTTPS 传输,防中间人嗅探
- SameSite=Lax 默认够用,Strict 更严但会断外链跳转后的登录态
- 登录成功后必须轮换 session id,防会话固定攻击
- CSRF:SameSite 之外再加同步令牌或双重提交 Cookie
- 双重提交:Cookie 存随机值,请求头带同值,服务端比对
import org.http4s.ResponseCookie
import org.http4s.SameSite
val sessionCookie = ResponseCookie(
name = "sid",
content = newSessionId,
httpOnly = true,
secure = true,
sameSite = Some(SameSite.Lax),
path = Some("/"),
maxAge = Some(3600)
)
// 双重提交校验:Header 与 Cookie 必须一致且为 constant-time 比较
def checkCsrf(header: String, cookie: String): Boolean =
header.nonEmpty && MessageDigest.isEqual(
header.getBytes("UTF-8"),
cookie.getBytes("UTF-8")
)
工程要点:纯 Token 放在 Authorization 头时天然免疫 CSRF,但若把 token 放进 Cookie 就立刻需要全套 CSRF 防护;两套机制不要混用,否则会出现「以为有防护、其实被绕过」的盲区。
6. 授权模型 RBAC 与 ABAC
RBAC 按角色授权,简单可审计,适合权限维度少的系统;ABAC 按属性(主体、资源、环境)动态判定,表达力强但难调试。实践中常用RBAC 打底 + ABAC 补充:角色决定大方向,属性决定行级过滤(如只能看本租户数据)。
- RBAC:User 到 Role 到 Permission 三层,权限用 resource:action 命名
- ABAC:策略即函数,输入 Principal 与 Resource,输出 Decision
- 行级权限:租户隔离必须落到 SQL where 条件,不能只在应用层过滤
- tagless-final:把鉴权抽象成 Authz[F] 代数,实现可替换、可测试
- http4s:用 Kleisli 中间件在路由前统一拦截
- ZIO:用 ZLayer 注入 Authz,在 effect 里短路失败
import cats.Applicative
import cats.syntax.all._
trait Authz[F[_]] {
def authorize(p: Principal, action: String, resource: Resource): F[Decision]
}
object Authz {
def rbac[F[_]: Applicative]: Authz[F] = new Authz[F] {
def authorize(p: Principal, action: String, resource: Resource): F[Decision] = {
val required = s"${resource.kind}:$action"
if (p.roles.contains(required)) Applicative[F].pure(Decision.Allow)
else Applicative[F].pure(Decision.Deny(s"missing $required"))
}
}
// ABAC 补充:租户与资源归属必须同时成立
def tenantScoped[F[_]: Applicative](delegate: Authz[F]): Authz[F] = new Authz[F] {
def authorize(p: Principal, action: String, resource: Resource): F[Decision] =
if (resource.tenantId != p.tenantId)
Applicative[F].pure(Decision.Deny("cross-tenant access"))
else delegate.authorize(p, action, resource)
}
}
工程要点:鉴权失败默认拒绝(fail closed),异常路径也必须是 Deny;把 Authz 写成代数后,用 Authz.rbac[Id] 就能在单测里穷举权限矩阵,无需起服务。
7. 常见 Web 漏洞与防护
OWASP Top 10 里与 Web 服务直接相关的主要是注入、XSS、SSRF 与错误配置。Scala 的类型系统与库生态能挡住大半,但前提是不用字符串拼 SQL、不把用户输入当 URL、不放任 CORS 通配。
- SQL 注入:一律参数化查询,doobie 的 sql 插值天然安全
- XSS:输出编码而非输入过滤,模板默认转义,富文本用白名单清洗
- SSRF:出站请求走域名与协议白名单,禁跳转到内网与云元数据地址
- 反序列化:禁止 Java 原生序列化,JSON 用 circe 显式 derive
- CORS:不要 Access-Control-Allow-Origin 加通配符再带凭证
- 安全响应头:HSTS、CSP、X-Content-Type-Options、Referrer-Policy
// 参数化查询:占位符由驱动处理,注入无从谈起
val q = sql"SELECT id, email FROM users WHERE email = $email AND tenant_id = $tid"
q.query[User].option
// SSRF 防护:协议与主机双白名单,且禁止跟随重定向到内网
val allowedHosts = Set("api.partner.com", "cdn.example.com")
def isAllowed(uri: java.net.URI): Boolean =
uri.getScheme == "https" && allowedHosts.contains(uri.getHost)
// 安全响应头:CSP 用 nonce 而非 unsafe-inline
val csp = "default-src 'self'; script-src 'self' 'nonce-abc123'; object-src 'none'"
工程要点:CORS 与 CSRF 是两件事——CORS 管「浏览器是否允许 JS 读响应」,CSRF 管「浏览器是否允许带 Cookie 发请求」,配了 CORS 不等于防了 CSRF;CSP 上线先用 Content-Security-Policy-Report-Only 观察一周再强制。
8. 密钥与配置管理
密钥进代码仓库是安全事故的头号来源。正确姿势是:密钥只存在于 KMS 或 Vault,应用通过短期凭证或注入的环境变量获取;配置分环境隔离,日志全面脱敏。
- 密钥绝不入库:代码、镜像、CI 日志三处都不许出现
- 轮换:签名密钥保留 kid,新旧并行一段时间再下线旧钥
- KMS 与 Vault:动态凭证、租约到期自动失效,减少长期密钥
- 环境隔离:dev、staging、prod 的密钥与数据库完全独立
- 日志脱敏:Authorization 头、password、token、身份证一律打码
- 配置项缺失要启动即失败,不要回退到硬编码默认值
// 启动即校验,缺失就崩,避免带病运行
val jwtSecret: String =
sys.env.getOrElse("JWT_SECRET", sys.error("JWT_SECRET is required"))
// 日志脱敏:只留前 6 位与后 4 位,便于排查又不可还原
def mask(token: String): String =
if (token.length <= 12) "***"
else s"${token.take(6)}...${token.takeRight(4)}"
// 密钥轮换:验签时按 kid 查表,支持新旧公钥并存
val publicKeys: Map[String, java.security.PublicKey] = loadKeysFromVault()
def keyFor(kid: String): Option[java.security.PublicKey] = publicKeys.get(kid)
工程要点:Vault 的租约到期会让密钥失效,应用必须实现重新拉取而非缓存到进程生命周期结束;轮换窗口至少覆盖最长 token 寿命,否则旧 token 会集体失效导致全量掉线。
9. 安全测试与生产加固
安全不是一次性动作而是持续流程:依赖漏洞每天扫、渗透测试定期做、限流与审计常态化。Scala 生态可直接接入 OWASP Dependency-Check 与 sbt 插件,把扫描卡在 CI 阶段。
- 依赖扫描:sbt-dependency-check 或 OWASP DC,CVSS 7 以上阻断合并
- 渗透测试:至少覆盖认证绕过、越权、注入、SSRF 四类
- 防爆破:按账号与 IP 双维度限流,失败次数指数退避
- 审计日志:谁在何时对哪个资源做了什么,成功失败都记
- 合规:密码强度、日志留存周期、数据出境,按行业要求落地
- 应急:密钥泄露有明确吊销与全量重置预案
// 登录限流:同一账号 5 分钟内失败 5 次即锁定,Redis 计数
def tooManyFailures(userId: String): Boolean =
redis.incr(s"login:fail:$userId") > 5 && {
redis.expire(s"login:fail:$userId", 300)
true
}
// 审计日志:结构化输出,绝不记录凭证原文
final case class AuditEvent(
actor: String, action: String, resource: String,
result: String, ip: String, at: java.time.Instant
)
def audit(e: AuditEvent): Unit =
logger.info(s"audit actor=${e.actor} action=${e.action} " +
s"resource=${e.resource} result=${e.result} ip=${e.ip}")
工程要点:限流计数必须放在 Redis 这类共享存储上,单机内存计数在多实例部署下等于没有限流;审计日志要写独立通道或独立索引,避免被业务日志淹没而丢失取证能力。
10. 速查表与一句话记忆
| 场景 | 推荐做法 |
|---|---|
| 服务间鉴权 | RS256 或 ES256,公钥验签,绝不共享对称密钥 |
| 用户登录态 | 短命 access token 加可轮换 refresh token |
| 密码存储 | Argon2id 或 bcrypt cost 12,加盐再加 pepper |
| 跨站防护 | SameSite=Lax 加同步令牌,双重提交兜底 |
| 权限判定 | RBAC 打底,ABAC 补租户与行级过滤,默认拒绝 |
| 密钥管理 | KMS 或 Vault 动态凭证,kid 支持轮换 |
| 漏洞防护 | 参数化查询、输出编码、出站白名单、CSP 与 HSTS |
一句话记忆:认证只回答你是谁,授权才决定你能做什么;凭证短命可撤销,密码慢哈希加盐,密钥不进代码库,权限默认拒绝。
延伸阅读
- Play、akka-http、http4s 三大框架的路由、中间件与认证入口
- 微服务间的凭证传递、网关鉴权与信任边界划分
- 用代数抽象把 Authz 写成可替换、可测试的能力接口
- ZLayer 注入鉴权服务与 ZIO 中间件的短路失败写法
- 配置分环境隔离与密钥注入的工程实践
- doobie 参数化查询与行级权限的 SQL 落地
- 权限矩阵与认证流程的单元测试与集成测试
- 安全专题 — 全站安全相关文章索引
- Scala 专题 — Scala 语言与工程化完整路线
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。