认证授权的设计与实现
一、前言
每个网站,小到一个H5页面,必有一个登录认证授权模块,常见的认证授权方式有哪些呢?又该如何实现呢?下面我们将来讲解SSO、OAuth等相关知识,并在实践中的应用姿势。
二、认证 (authentication) 和授权 (authorization)
这两个术语通常在安全性方面相互结合使用,尤其是在获得对系统的访问权限时。两者都是非常重要的主题,通常与网络相关联,作为其服务基础架构的关键部分。然而,这两个术语在完全不同的概念上是非常不同的。虽然它们通常使用相同的工具在相同的上下文中使用,但它们彼此完全不同。
身份验证意味着确认您自己的身份,而授权意味着授予对系统的访问权限。简单来说,身份验证是验证您的身份的过程,而授权是验证您有权访问的过程。
authentication 证明你是你,authorization 证明你有这个权限。身份验证是授权的第一步,因此始终是第一步。授权在成功验证后完成。
例子:你要登陆论坛,输入用户名张三,密码1234,密码正确,证明你张三确实是张三,这就是 authentication;再一check用户张三是个版主,所以有权限加精删别人帖,这就是 authorization。
三、单点登录(SSO)
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
举例来说,QQ音乐和腾讯新闻是腾讯公司旗下的两个不同的应用系统,如果用户在腾讯新闻登录过之后,当他访问QQ音乐时无需再次登录,那么就说明QQ音乐和腾讯新闻之间实现了单点登录。
3.1 父域Cookie
最简单是实现方式是,将 Cookie 的 domain 属性设置为当前域的父域,那么就认为它是父域 Cookie。Cookie 有一个特点,即父域中的 Cookie 被子域所共享,换言之,子域会自动继承父域中的 Cookie。
- 系统1:a.zxy.com
- 系统2:b.zxy.com
- 登录系统:login.zxy.com
sequenceDiagram
系统1->>系统1:已登录状态,登录cookie在zxy.com域
系统2->>系统2:需要登录
系统2->>登录系统:登录(携带登录cookie信息)
登录系统->>登录系统:登录验证
登录系统-->>系统2:登录成功
系统2->>系统2:访问资源
3.2 CAS
还有一种方式,那就是CAS(Central Authentication Service)(中心认证服务) 。可参考OAuth2.0,应用系统检查当前请求有没有 Ticket,如果没有,说明用户在当前系统中尚未登录,那么就将页面跳转至认证中心。由于这个操作会将认证中心的 Cookie 自动带过去,因此,认证中心能够根据 Cookie 知道用户是否已经登录过了。如果认证中心发现用户尚未登录,则返回登录页面,等待用户登录,如果发现用户已经登录过了,就不会让用户再次登录了,而是会跳转回目标 URL ,并在跳转前生成一个 Ticket,拼接在目标 URL 的后面,回传给目标应用系统。
四、OAuth
4.1 四种方式
OAuth 2.0定义了四种授权方式。
- 授权码模式(authorization code)
- 简化模式(implicit)
- 密码模式(resource owner password credentials)
- 客户端模式(client credentials)
4.1.1 授权码模式
授权码模式(authorization code)是功能最完整、流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与“服务提供商”的认证服务器进行互动。
sequenceDiagram
Resource Owner->>Client: 1. 用户访问客户端
Client->>User Agent: 2. 客户端将用户导向认证服务器
User Agent->>Authorization Server: 3. response_type=code&client_id={客户端的ID}&redirect_uri={重定向URI}&scope={权限范围}&state={state}
User Agent->>Resource Owner: 4. 用户选择是否给予客户端授权
User Agent->>Authorization Server: 5. 用户给予授权
Authorization Server-->>User Agent: 6. 重定向URL?code={code}&state={state}
User Agent-->>Client: 7. 重定向URL?code={code}&state={state}
Client->>Authorization Server: 8. grant_type=authorization_code&client_id={client_id}&code={code}&state={state}&redirect_uri={redirect_uri}
Authorization Server-->>Client: 9. expires_in access_token refresh_token scope
- response_type=code&client_id={客户端的ID}&redirect_uri={重定向URI}&scope={权限范围}&state={state}
- grant_type=authorization_code&client_id={client_id}&code={code}&state={state}&redirect_uri={redirect_uri}
4.1.2 简化模式
简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了"授权码"这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。
sequenceDiagram
Resource Owner->>Client: 1. 用户访问客户端
Client->>User Agent: 2. 客户端将用户导向认证服务器
User Agent->>Authorization Server: 3. authorize?response_type=token&client_id={客户端的ID}&redirect_uri={重定向URI}&scope={权限范围}&state={state}
User Agent->>Resource Owner: 4. 用户选择是否给予客户端授权
User Agent->>Authorization Server: 5. 用户给予授权
Authorization Server-->>User Agent: 6. expires_in access_token refresh_token scope state,并在URI的Hash部分包含了访问令牌
User Agent->>WebHosted Client Resource: 7. 浏览器向资源服务器发出请求
WebHosted Client Resource-->>User Agent: 8. 返回可以从Hash值中获取令牌的代码脚本
User Agent->>User Agent: 9. 根据脚本提取令牌
User Agent->>Client: 10. access_token
- authorize?response_type=token&client_id={客户端的ID}&redirect_uri={重定向URI}&scope={权限范围}&state={state}
- expires_in access_token refresh_token scope state,并在URI的Hash部分包含了访问令牌
4.1.3 密码模式
密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。
sequenceDiagram
Resource Owner->>Client: 1. 用户名和密码
Client->>Authorization Server: 2. grant_type=password&username={username}&password={password}&scope={权限范围}
Authorization Server-->>Client: 3. expires_in access_token refresh_token
- grant_type=password&username={username}&password={password}&scope={权限范围}
4.1.4 客户端模式
客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向"服务提供商"进行认证。严格地说,客户端模式并不属于OAuth框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求"服务提供商"提供服务,其实不存在授权问题。
sequenceDiagram
Client->>Authorization Server: 1. grant_type=client_credentials&scope={权限范围}
Authorization Server-->>Client: 2. expires_in access_token refresh_token
4.2 更新令牌
如果用户访问的时候,客户端的"访问令牌"已经过期,则需要使用"更新令牌"申请一个新的访问令牌。
sequenceDiagram
Client->>Authorization Server: 1. grant_type=refresh_token&refresh_token={refresh_token}
Authorization Server-->>Client: 2. expires_in access_token refresh_token
4.3 微信小程序登录的例子
小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。
使用的是OAuth2.0中的授权码模式。调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 、 用户在微信开放平台帐号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台帐号) 和 会话密钥 session_key。之后开发者服务器可以根据用户标识来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。
五、JWT
JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
JWT的最常见场景,一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用。
JWT由三部分组成,它们之间用圆点“.”连接。这三部分分别是:Header、Payload、Signature。因此,一个典型的JWT看起来是这个样子的:“xxx.yyy.zzz”
JWT的第一部分Header典型的由两部分组成:类型(“JWT”)和算法名称(比如:HMAC SHA256或者RSA等等)。
{
"alg": "HS256",
"typ": "JWT"
}
复制代码
JWT的第二部分Payload,也就是我们数据的存放地方,特别注意不要在里面存放敏感信息。它包含声明,声明是关于实体(通常是用户)和其他数据的声明。声明有三种类型: registered, public 和 private。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
复制代码
JWT的第三部分Signature,为了得到签名部分,你必须有编码过的header、编码过的payload、一个秘钥,签名算法是header中指定的那个,然对它们签名即可。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
复制代码
Java实现
io.jsonwebtoken
jjwt
0.9.1
复制代码
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
public class Test {
private static final Logger logger = LoggerFactory.getLogger(Test.class);
private static String secret = "zhongxy@123456";
private static ObjectMapper objectMapper = new ObjectMapper();
public static void main(String[] args) throws Exception {
UserInfo userInfo = new UserInfo(); // 自定义的登录对象
userInfo.setId(6);
userInfo.setName("测试");
logger.info("UserInfo:" + objectMapper.writeValueAsString(userInfo));
String token = generateToken(userInfo, 60 * 1000);
logger.info("token:" + token);
Object result = check(token);
logger.info("check:" + objectMapper.writeValueAsString(result));
}
// 生成token
public static String generateToken(UserInfo userInfo, long ttlSecs) {
//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(secret);
Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
//Let's set the JWT Claims
JwtBuilder builder = null;
try {
builder = Jwts.builder()
.setIssuedAt(now)
.setIssuer(objectMapper.writeValueAsString(userInfo))
.signWith(signatureAlgorithm, signingKey);
} catch (JsonProcessingException e) {
e.printStackTrace();
return null;
}
//if it has been specified, let's add the expiration
if (ttlSecs >= 0) {
long expMillis = nowMillis + ttlSecs * 1000;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
//Builds the JWT and serializes it to a compact, URL-safe string
return builder.compact();
}
// 从token中反向解析出UserInfo
public static UserInfo check(String token) {
try {
//This line will throw an exception if it is not a signed JWS (as expected)
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(secret))
.parseClaimsJws(token).getBody();
String userInfoStr = claims.getIssuer();
return objectMapper.readValue(userInfoStr, UserInfo.class);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
复制代码