Web安全认证
一、HTTP Basic Auth
每次请求 API 时都提供用户的 username 和 password。
Basic Auth 是配合 RESTful API 使用的最简单的认证方式,只需提供用户名密码即可,但由于有把用户名密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少。因此,在开发对外开放的 RESTful API 时,尽量避免采用 HTTP Basic Auth。
Tomcat 自带的 HTTPBasic 认证:
当用户名密码输入错误后,会返回 401 Unauthorized
表明认证失败,无法访问应用。
当认证成功后,请求头中会多一组字段:
这就是你访问应用的凭据。其中 cm9vdDpyb290
是一个 Base64 编码后的字符串,可以经过 Base64 解码还原,安全级别很低。
二、OAuth
OAuth(开放授权)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一 web 服务上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。
OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的第三方系统(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth 让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。
我们熟知的通过 qq/微信/微博等登录第三方平台也是这种认证场景。
OAuth 1.0 版本发布后有许多安全漏洞,所以在 OAuth2.0 里面完全废止了 OAuth1.0,它关注客户端开发者的简易性,要么通过组织在资源拥有者和 HTTP 服务商之间的被批准的交互动作代表用户,要么允许第三方应用代表用户获得访问的权限。
2.1 OAuth2 认证和授权过程中的三个角色
服务提供方
提供受保护的服务和资源的,用户在这里面存了很多东西。
用户
存了东西(照片,资料等)在服务提供方的人。
客户端
服务调用方,它要访问服务提供方的资源,需要在服务提供方进行注册,不然服务提供方不认它。
2.2 OAuth2 认证和授权的过程
- 用户想操作存放在服务提供方的资源;
- 用户登录客户端,客户端向服务提供方请求一个临时 token;
- 服务提供方验证客户端的身份后,给它一个临时 token;
- 客户端获得临时 token 之后,将用户引导至服务提供方的授权页面,并请求用户授权。(在这个过程中会将临时 token 和客户端的
回调链接/接口
发送给服务提供方。很明显,服务提供方到时会回来 call 这个接口在用户认证并授权之后) - 用户输入用户名密码登录,登录成功之后,可以授权客户端访问服务提供方的资源;
- 授权成功后,服务提供方将用户引导至客户端的网页(call 第 4 步里面的回调链接/接口);
- 客户端根据临时 token 从服务提供方那里获取正式的 access token;
- 服务提供方根据临时 token 以及用户的授权情况授予客户端access token;
- 客户端使用 access token 访问用户存放在服务提供方的受保护的资源。
这种基于 OAuth 的认证机制适用于个人消费者类的互联网产品,如社交类 APP 等应用,但是不太适合拥有自有认证权限管理的企业应用。
2.3 access token
获取 access token 的方法(Grant Type)有下面四种,每一种都有适用的应用场景:
Authorization Code (授权码模式)
结合普通服务器端应用使用。
- 用户访问客户端,后者将前者导向认证服务器,假设用户给予授权,认证服务器将用户导向客户端事先指定的"重定向 URI"(redirection URI),同时附上一个授权码。
- 客户端收到授权码,附上早先的"重定向 URI",向认证服务器申请令牌:
GET /oauth/token?response_type=code&client_id=test&redirect_uri=重定向页面链接
。请求成功返回 code 授权码,一般有效时间是 10 分钟。 - 认证服务器核对了授权码和重定向 URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。
POST /oauth/token?response_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=重定向页面链接
。请求成功返回 access Token 和 refresh Token。
Implicit(简化模式)
- 结合移动应用或 Web App 使用。
- Access Token 直接从授权服务器返回(只有前端渠道)
- 不支持refresh tokens
- 假定资源所有者和公开客户应用在同一个设备上
- 最容易受安全攻击
Resource Owner Password Credentials
- 适用于受信任客户端应用,例如同个组织的内部或外部应用。
- 使用用户名密码登录的应用,例如桌面 App
- 使用用户名/密码作为授权方式从授权服务器上获取 access token
- 一般不支持refresh token
- 假定资源拥有者和公开客户在相同设备上
Client Credentials
- 适用于客户端调用主服务API型应用(比如百度 API Store,不同项目之间的微服务互相调用)
- 只有后端渠道,使用客户凭证获取一个 access token
- 因为客户凭证可以使用对称或者非对称加密,该方式支持共享密码或者证书
三、Cookie-session Auth
认证机制就是为一次请求认证在服务端创建一个 Session 对象,同时在客户端的浏览器端创建了一个 Cookie 对象;通过客户端带上来 Cookie 对象来与服务器端的 session 对象匹配来实现状态管理的。默认的,当我们关闭浏览器的时候,cookie 会被删除。但可以通过修改 cookie 的 expire time 使 cookie 在一定时间内有效。
但是这种基于 cookie-session 的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于 session 认证应用的问题就会暴露出来。
基于 session 认证所显露的问题:
Session
每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言 session 都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
扩展性:分布式或多服务器环境中适应性不好
用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。不过,现在某些服务器可以通过设置粘性 Session,来做到每台服务器之间的 Session 共享。
CSRF
因为是基于 cookie 来进行用户识别的, cookie 如果被截获,用户就会很容易受到跨站请求伪造的攻击。
四、Token Auth
基于 token 的鉴权机制类似于 http 协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于 token 认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
4.1 流程
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过验证发送给用户一个 token
- 客户端存储 token,并在每次请求时附送上这个 token 值
- 服务端验证 token 值,并返回数据
这个 token 必须要在每次请求时传递给服务端,它应该保存在请求头里。另外,服务端要支持 CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了 Access-Control-Allow-Origin。
4.2 Token Auth 的优点
支持跨域访问
Cookie 是不允许垮域访问的,这一点对 Token 机制是不存在的,前提是传输的用户认证信息通过 HTTP 头传输。
无状态(服务端可扩展行)
Token 机制在服务端不需要存储 session 信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie 或本地介质存储状态信息.
更适用 CDN
可以通过内容分发网络请求你服务端的所有资料(如:javascript、HTML、图片等),而你的服务端只要提供 API 即可。
去耦
不需要绑定到一个特定的身份验证方案。Token 可以在任何地方生成,只要在你的 API 被调用的时候,你可以进行 Token 生成调用即可。
更适用于移动应用
当你的客户端是一个原生平台(iOS、Android、Windows 8 等)时,Cookie 是不被支持的(你需要通过 Cookie 容器进行处理),这时采用 Token 认证机制就会简单得多。
CSRF
跨站请求伪造 Cross-site request forgery。
因为不再依赖于 Cookie,所以你就不需要考虑对 CSRF(跨站请求伪造)的防范。
性能
一次网络往返时间(通过数据库查询 session 信息)总比做一次 HMACSHA256 计算的 Token 验证和解析要费时得多。
不需要为登录页面做特殊处理
如果你使用 Protractor 做功能测试的时候,不再需要为登录页面做特殊处理.
基于标准化
你的 API 可以采用标准化的 JSON Web Token(JWT)。这个标准已经存在多个后端库(.NET、Ruby、Java、Python、PHP)和多家公司的支持(如:Firebase、Google、Microsoft)
对 Token 认证的五点认识:
- 一个 Token 就是一些信息的集合;
- 在 Token 中包含足够多的信息,以便在后续请求中减少查询数据库的几率;
- 服务端需要对 cookie 和 HTTP Authrorization Header 进行 Token 信息的检查;
- 基于上一点,你可以用一套 token 认证代码来面对浏览器类客户端和非浏览器类客户端;
- 因为 token 是被签名的,所以我们可以认为一个可以解码认证通过的 token 是由我们系统发放的,其中带的信息是合法有效的。
五、基于 JWT 的 Token 认证机制
5.1 JWT
JWT 是一种用于双方之间传递安全信息的简洁的、URL 安全的表述性声明规范。
JWT 作为一个开放的标准(RFC 7519),定义了一种简洁的、自包含的方法用于通信双方之间以 Json 对象的形式安全的传递信息。因为数字签名的存在,这些信息是可信的,JWT 可以使用HMAC 算法或者是 RSA 的公私秘钥对进行签名。
简洁(Compact)
可以通过 URL,POST 参数或者在 HTTP header 发送,因为数据量小,传输速度也很快。
自包含(Self-contained)
负载中包含了所有用户所需要的信息,避免了多次查询数据库。
5.2 JWT 的主要应用场景
身份认证
在这种场景下,一旦用户完成了登陆,在接下来的每个请求中包含 JWT,可以用来验证用户身份以及对路由、服务和资源的访问权限进行验证。由于它的开销非常小,可以轻松的在不同域名的系统中传递,所有目前在单点登录(SSO)中比较广泛的使用了该技术。
信息交换
在通信的双方之间使用 JWT 对数据进行编码是一种非常安全的方式,由于它的信息是经过签名的,可以确保发送者发送的信息是没有经过伪造(篡改)的。
5.3 JWT的结构
Header
jwt 的头部承载两部分信息:
- 声明类型,这里是 jwt
- 声明签名算法,通常直接使用 HMAC SHA256
完整的头部就像下面这样的JSON:
{ "typ": "JWT",
"alg": "HS256"
}然后将头部进行 base64 编码,构成了第一部分。
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
payload
有效载荷就是存放有效信息的地方,这些有效信息包含三个部分:
标准中注册的声明(Reserved claims)
这些 claim 是 JWT 预先定义的,在 JWT 中并不会强制使用它们,而是推荐使用
iss:jwt签发者
sub:jwt所面向的用户
aud:接收 jwt 的一方
exp:jwt的过期时间,这个过期时间必须要大于签发时间
nbf:定义在什么时间之前,该jwt都是不可用的.
iat:jwt 的签发时间
jti:jwt 的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。
私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为 base64 是编码方式,有对应的解码方式,意味着该部分信息可以归类为明文信息。
定义一个 payload:
{ "sub": "1234567890",
"name": "John Doe",
"admin": true
}然后将其进行 base64 编码,得到 Jwt 的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
signature
jwt 的第三部分是一个签证信息,这个签证信息 signature 由三部分组成:
- header(base64后的)
- payload(base64后的)
- secret
创建签名需要使用编码后的 header 和 payload 以及一个秘钥,使用 header 中指定签名算法进行签名。例如如果希望使用 HMAC SHA256 算法,那么签名应该使用下列方式创建:
HMACSHA256( base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)注意:
secret 是保存在服务器端的,jwt 的签发生成也是在服务器端的,secret 就是用来进行 jwt 的签发和 jwt 的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个 secret, 那就意味着客户端是可以自我签发 jwt 了。
碰到 JWT token 可以去 JWT 官网解密看看,下面这是官网解密出来的数据,可以很清楚的看到它的三部分内容:
5.4 认证过程
登录
- 第一次认证:第一次登录,用户从浏览器输入用户名/密码,提交后到服务器的登录处理的 Action 层(Login Action);
- Login Action 调用认证服务进行用户名密码认证,如果认证通过,Login Action 层调用用户信息服务获取用户信息(包括完整的用户信息及对应权限信息);
- 返回用户信息后,Login Action 从配置文件中获取 Token 签名生成的秘钥信息,进行 Token 的生成;
- 生成 Token 的过程中可以调用第三方的 JWT Lib 生成签名后的 JWT 数据;
- 完成 JWT 数据签名后,将其设置到 COOKIE 对象中,并重定向到首页,完成登录过程;
请求认证
基于 Token 的认证机制会在每一次请求中都带上完成签名的 Token 信息,这个 Token 信息可能在 COOKIE 中,也可能在 HTTP 的 Authorization 头中。
- 客户端(APP客户端或浏览器)通过 GET 或 POST 请求访问资源(页面或调用 API);
- 认证服务作为一个 Middleware HOOK 对请求进行拦截,首先在 cookie 中查找 Token 信息,如果没有找到,则在 HTTP Authorization Head 中查找;
- 如果找到 Token 信息,则根据配置文件中的签名加密秘钥,调用 JWT Lib 对 Token 信息进行解密和解码;
- 完成解码并验证签名通过后,对 Token 中的 exp、nbf、aud 等信息进行验证;
- 全部通过后,根据获取的用户的角色权限信息,进行对请求的资源的权限逻辑判断;
- 如果权限逻辑判断通过则通过 Response 对象返回;否则则返回 HTTP 401。
5.5 如何使用 JWT
在身份鉴定的实现中,传统方法是在服务端存储一个 session,给客户端返回一个 cookie,而使用 JWT 之后,当用户使用它的认证信息登陆系统之后,会返回给用户一个 JWT,用户只需要本地保存该 token(通常使用 local storage,也可以使用 cookie)即可。
当用户希望访问一个受保护的路由或者资源的时候,通常应该在 Authorization
头部使用 Bearer
模式添加 JWT,其内容看起来是下面这样:
Authorization: Bearer <token>
因为用户的状态在服务端的内存中是不存储的,所以这是一种无状态的认证机制。服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为。由于 JWT 是自包含的,因此减少了需要查询数据库的需要。
JWT 的这些特性使得我们可以完全依赖其无状态的特性提供数据 API 服务,甚至是创建一个下载流服务。因为 JWT 并不使用 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)。
5.6 JWT 的 Java 实现
Java 中对 JWT 的支持可以考虑使用 JJWT 开源库。JJWT 实现了 JWT、JWS、JWE 和 JWA RFC 规范。
maven 依赖
<dependency> <groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.6.0</version>
</dependency>生成 Token 码
import javax.crypto.spec.SecretKeySpec; import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import io.jsonwebtoken.*;
import java.util.Date;
//Sample method to construct a JWT
private String createJWT(String id, String issuer, String subject, long ttlMillis) {
//The JWT signature algorithm we will be using to sign the token
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
//We will sign our JWT with our ApiKey secret
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(apiKey.getSecret());
Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
//Let's set the JWT Claims
JwtBuilder builder = Jwts.builder().setId(id)
.setIssuedAt(now)
.setSubject(subject)
.setIssuer(issuer)
.signWith(signatureAlgorithm, signingKey);
//if it has been specified, let's add the expiration
if (ttlMillis >= 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
//Builds the JWT and serializes it to a compact, URL-safe string
return builder.compact();
}解码和验证Token码
import javax.xml.bind.DatatypeConverter; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Claims;
//Sample method to validate and read the JWT
private void parseJWT(String jwt) {
//This line will throw an exception if it is not a signed JWS (as expected)
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(apiKey.getSecret()))
.parseClaimsJws(jwt).getBody();
System.out.println("ID: " + claims.getId());
System.out.println("Subject: " + claims.getSubject());
System.out.println("Issuer: " + claims.getIssuer());
System.out.println("Expiration: " + claims.getExpiration());
}
5.7 基于 JWT 的 Token 认证的安全问题
确保验证过程的安全性
如何保证用户名/密码验证过程的安全性?
因为在验证过程中,需要用户输入用户名和密码,在这一过程中,用户名、密码等敏感信息需要在网络中传输。因此,在这个过程中建议采用 HTTPS,通过 SSL 加密传输,以确保通道的安全性。
如何防范 XSS Attacks
XSS 攻击代码过滤
移除任何会导致浏览器做非预期执行的代码,这个可以采用一些库来实现(如:js 下的 js-xss,JAVA 下的 XSS HTMLFilter,PHP 下的 TWIG);如果你是将用户提交的字符串存储到数据库的话(也针对 SQL 注入攻击),你需要在前端和服务端分别做过滤。
采用 HTTP-Only Cookies
通过设置 Cookie 的参数:
HttpOnly; Secure
来防止通过 JavaScript 来访问 Cookie。
在 Java 中设置 cookie 是 HttpOnly,升级 Tomcat7.0,它已经实现了 Servlet3.0。
或者通过这样来设置://设置cookie response.addHeader("Set-Cookie", "uid=112; Path=/; HttpOnly");
//设置多个cookie
response.addHeader("Set-Cookie", "uid=112; Path=/; HttpOnly");
response.addHeader("Set-Cookie", "timeout=30; Path=/test; HttpOnly");
//设置https的cookie
response.addHeader("Set-Cookie", "uid=112; Path=/; Secure; HttpOnly");如何防范 Replay Attacks
所谓重放攻击就是攻击者发送一个目的主机已接收过的包,来达到欺骗系统的目的,主要用于身份认证过程。比如在浏览器端通过用户名/密码验证获得签名的 Token 被木马窃取。即使用户登出了系统,黑客还是可以利用窃取的 Token 模拟正常请求,而服务器端对此完全不知道,以为 JWT 机制是无状态的。
针对这种情况,有几种常用做法可以用作参考:
时间戳 + 共享秘钥
这种方案,客户端和服务端都需要知道:
- User ID
- 共享秘钥
客户端:
auth_header = JWT.encode({ user_id: 123,
iat: Time.now.to_i, # 指定token发布时间
exp: Time.now.to_i + 2 # 指定token过期时间为2秒后,2秒时间足够一次HTTP请求,同时在一定程度确保上一次token过期,减少replay attack的概率;
}, "<my shared secret>")
RestClient.get("http://api.example.com/", authorization: auth_header)服务端:
class ApiController < ActionController::Base attr_reader :current_user
before_action :set_current_user_from_jwt_token
def set_current_user_from_jwt_token
# Step 1:解码JWT,并获取User ID,这个时候不对Token签名进行检查
# the signature. Note JWT tokens are *not* encrypted, but signed.
payload = JWT.decode(request.authorization, nil, false)
# Step 2: 检查该用户是否存在于数据库
@current_user = User.find(payload['user_id'])
# Step 3: 检查Token签名是否正确.
JWT.decode(request.authorization, current_user.api_secret)
# Step 4: 检查 "iat" 和"exp" 以确保这个Token是在2秒内创建的.
now = Time.now.to_i
if payload['iat'] > now || payload['exp'] < now
# 如果过期则返回401
end
rescue JWT::DecodeError
# 返回 401
end
end时间戳 + 共享秘钥 + 黑名单(类似 Zendesk 的做法)
客户端
auth_header = JWT.encode({ user_id: 123,
jti: rand(2 << 64).to_s, # 通过jti确保一个token只使用一次,防止replace attack
iat: Time.now.to_i, # 指定token发布时间.
exp: Time.now.to_i + 2 # 指定token过期时间为2秒后
}, "<my shared secret>")
RestClient.get("http://api.example.com/", authorization: auth_header)服务端
def set_current_user_from_jwt_token # 前面的步骤参考上面
payload = JWT.decode(request.authorization, nil, false)
@current_user = User.find(payload['user_id'])
JWT.decode(request.authorization, current_user.api_secret)
now = Time.now.to_i
if payload['iat'] > now || payload['exp'] < now
# 返回401
end
# 下面将检查确保这个JWT之前没有被使用过
# 使用Redis的原子操作
# The redis 的键: <user id>:<one-time use token>
key = "#{payload['user_id']}:#{payload['jti']}"
# 看键值是否在redis中已经存在. 如果不存在则返回nil. 如果存在则返回“1”. .
if redis.getset(key, "1")
# 返回401
#
end
# 进行键值过期检查
redis.expireat(key, payload['exp'] + 2)
end
如何防范 MITM (Man-In-The-Middle)Attacks
所谓 MITM 攻击,就是在客户端和服务器端的交互过程被监听,比如像可以上网的咖啡馆的 WIFI 被监听或者被黑的代理服务器等。
针对这类攻击的办法使用 HTTPS,包括针对分布式应用,在服务间传输像 cookie 这类敏感信息时也采用 HTTPS;所以云计算在本质上是不安全的。