简单了解Auth2.0
概念
OAuth2.0是一种关于在线授权的网络标准。
OAuth 2.0 无需共享用户的信息,允许授权第三方应用访问他们存储在另外的服务上的数据。Web 是 OAuth 2 的主要平台,它规范了如何处理对第三方客户端类型(基于浏览器的应用程序、服务器端 Web 应用程序、本机/移动应用程序、连接设备等)的委托访问。
角色
- 资源服务器(Resource server):服务提供商存放用户生成的资源的服务器。接受并验证来自客户端的访问令牌,并返回适当的资源。
- 客户端(Client):是需要访问受保护资源的系统。要访问资源,客户端必须持有适当的访问令牌。
- 授权服务器(Authorization server):第三方服务专门用来处理认证的服务器。
- 授权服务器在成功验证资源所有者并获得授权后,向客户端发放访问令牌。授权服务器主要有两个端点:授权端点(处理用户的交互式身份验证和同意)和令牌端点(参与机器对机器交互)。
OAuth2.0的作用就是让"客户端"安全可控地获取"用户"的授权,与"服务提供商"进行交互。
工作流程
(A) 用户打开客户端,客户端要求给予授权
(B) 用户同意给予授权
(C) 客户端向授权服务器验证身份并提交授权许可,申请访问令牌
(D) 授权服务器对客户进行身份验证,并验证授权许可。如果有效,则发出访问令牌
(E) 客户端向资源服务器请求受保护的资源,并通过出示访问令牌进行身份验证
(F) 服务器请求受保护的资源,并通过出示访问令牌进行身份验证,返回请求的保护资源
OAuth在Client与Resource Server之间,设置了一个授权层(authorization layer)。Client不能直接登录Resource Server,只能登录授权层,以此将用户与Client区分开来。Client登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。Client登录授权层以后,"服务提供商"根据令牌的权限范围和有效期,向Client开放用户储存的资料
Access Token和Refresh Token
Access Token
访问令牌是由资源所有者授予的,一般是字符串, 用于访问受保护资源的凭证,有效期很短
Refresh Token
刷新令牌是可以用于获取访问令牌的凭证。 刷新令牌由授权服务器发送给客户端,用于在当前访问令牌失效时获取新的相同范围的访问令牌,一般只在第一次返回,且刷新令牌也会过期
授权类型
在 OAuth 2.0 中,授权是客户端为获得资源访问授权而必须执行的一组步骤。授权框架提供了多种授权类型来应对不同的场景,这里只列出常用的:
简化模式
直接返回访问令牌。在隐式流程中,授权服务器可以将访问令牌作为回调 URI 中的参数或作为对表单发布的响应返回。
所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。
有些 Web 应用是纯前端应用,没有后端,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌,这种方式没有授权码这个中间步骤,所以称为(授权码)"隐藏式"(implicit)
特点
简化模式不通过第三方应用程序的服务器,直接在浏览器中向授权服务器申请令牌,跳过了"授权码"这个步骤,所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。
缺点
这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证
授权码模式
授权服务器向客户端返回一次性授权码,然后将其交换为访问令牌
步骤
(A)用户访问客户端,后者将前者导向授权服务器。
(B)用户选择是否给予客户端授权。
(C)用户给予授权,授权服务器将用户导向客户端事先指定的"重定向URI"(redirection URI),同时附上一个授权码。
(D)客户端收到授权码,附上早先的"重定向URI",向授权服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
(E)授权服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)
特点
- 授权码模式是功能最完整、流程最严密的授权模式。这种方式是最常用的流程,安全性也最高,
- 它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。
- 这样的前后端分离,可以避免令牌泄漏。
适用场景:目前主流的第三方验证都是采用这种模式
一些重要的参数
ClientId
: 客户端ID,标识符ClientSecret
: 结合clientId,授权服务器会校验是否注册过RedirectURIs
: 认证授权后重定向的URI,如果是授权码模式,会将code以参数模式在URI上返回Scope
: 授权包含的范围
集成Google Drive
前提条件
- 申请Google API使用权,推荐注册一个Google cloud project,另一种方法需要审核比较长时间
- 创建授权凭据,需要填写指定的重定向URI,获得app的唯一认证凭证,包含clientId等
Javascript Web网页应用集成Google Drive Api
适用于客户端 Web 应用的 OAuth 2.0 | Authorization | Google for Developers
工作流程
- 应用打开一个 Google 网址,该网址使用查询参数来识别您的应用以及该应用所需的 API 访问权限类型。
- 在当前浏览器窗口或弹出式窗口中打开该网址。用户可以通过 Google 进行身份验证并授予请求的权限。
- Google 将用户重定向回您的应用。该重定向包含一个访问令牌,您的应用会验证该访问令牌,然后使用此令牌发出 API 请求。当令牌过期时,应用会重复该过程
示例
在UI代码库中构造Google Client,Google 处理后续的流程,会返回一个Access Token(1小时过期)
以React代码示例:
ini
const [oauth, setOAuth] = useState(null);
const [scriptLoadedSuccessfully, setScriptLoadedSuccessfully] = useState(false);
const [oauthSuccessfully, setOauthSuccessfully] = useState(false);
useEffect(() => {
const scriptTag = document.createElement('script');
scriptTag.src = "<https://accounts.google.com/gsi/client>";
scriptTag.async = true;
scriptTag.defer = true;
scriptTag.onload = () => {
setScriptLoadedSuccessfully(true);
const tokenClient = window.google.accounts.oauth2.initTokenClient({
client_id: <GOOGLE_AUTHORIZED_CLIENT_ID>,
scope: <SCOPE>,
response_type: 'token',
callback: (resp: GoogleAccessToken) => {
//get token expire Unix time
googleOAuthClient.validAccessToken(resp.access_token).then((res) => {
resp.expires_at = res.exp
//store in local storage
window.localStorage.setItem("googleAccountToken", JSON.stringify(resp))
setOauthSuccessfully(true)
})
},
})
setOAuth(tokenClient)
}
scriptTag.onerror = () => {
setScriptLoadedSuccessfully(false);
};
document.body.appendChild(scriptTag);
return () => {
document.body.removeChild(scriptTag);
}
}, []);
//用户未登录/登陆过期,弹出用户登陆框:
oauth.requestAccessToken({prompt: 'consent'})
在请求Google Drive Api时使用Access Token就可以,这里因为会接收到第三方请求,会涉及到跨域请求,需要添加对应的 Content-Security-Policy
配置
限制
每次访问令牌过期时,要求应用程序的用户重新进行身份验证,用户需要重新同意授权,用户体验不好
服务器端Web应用
针对网络服务器应用使用 OAuth 2.0 | Authorization | Google for Developers
服务器端Web应用可以存储用户第一次同意后返回的Refresh token,后续调用Google api时,可以在用户无感知的情况下获取更新可用的Access Token。
工作流程
- 重定向到 Google 的 OAuth 2.0 服务器,应用打开一个 Google 网址,该网址使用查询参数来识别您的应用以及该应用所需的 API 访问权限类型。
- Google 提示用户同意,在当前浏览器窗口或弹出式窗口中打开该网址。用户可以通过 Google 进行身份验证并授予请求的权限。
- 处理 OAuth 2.0 服务器响应,Google 将用户重定向回您的应用。该重定向包含授权代码,应用可以用它来换取访问令牌和刷新令牌。可以在安全的地方保存刷新令牌,可以和用户邮箱进行绑定等
- 用授权代码来刷新令牌和访问令牌,应用会验证该访问令牌,然后使用此令牌发出 API 请求。
- 当令牌过期时,应用会使用刷新令牌获取访问令牌。
实际使用
注意:
请慎重考虑是否要向该页面上的所有资源(尤其是社交插件和分析等第三方脚本)发送授权凭据。为避免此问题,我们建议服务器先处理请求,然后再重定向到另一个不包含响应参数的网址。
我们在GCP中创建client和代码中构造Google Auth Client请求时会设置Redirect URI,我们的做法是让他回调到我们APP的特定路由,传递信息后马上关闭页面。
参考代码:
针对网络服务器应用使用 OAuth 2.0 | Authorization | Google for Developers
Java需要引入的包:
arduino
implementation 'com.google.api-client:google-api-client:1.31.1'
implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1'
implementation 'com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0'
使用官方建议的包与Google Client进行交互:
java
import com.google.api.client.googleapis.auth.oauth2.*;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.services.drive.Drive;
import com.google.api.services.drive.model.File;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auth.oauth2.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.*;
import java.io.IOException;
import java.util.*;
@Slf4j
@Component
public class GoogleOauthUtils {
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private static final String SCOPE = "<https://www.googleapis.com/auth/drive.metadata.readonly>";
private static final String ACCESS_TYPE = "offline";
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
private final GoogleAuthorizationCodeFlow flow;
//构造授权请求
public GoogleOauthUtils(@Value("${google-oauth-credentials.clientId}") String clientId,
@Value("${google-oauth-credentials.clientSecret}") String clientSecret,
@Value("${google-oauth-credentials.redirectUri}") String redirectUri) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
this.flow = new GoogleAuthorizationCodeFlow.Builder(
new NetHttpTransport(), JSON_FACTORY, clientId, clientSecret, Collections.singleton(SCOPE))
.setAccessType(ACCESS_TYPE)
.build();
}
//生存Google Auth服务端网址传递给前端
public String generateOauthUrl() {
return flow.newAuthorizationUrl().setRedirectUri(redirectUri).build();
}
//使用Token换取Refresh token
public GoogleTokenResponse requestToken(String code) throws IOException {
return flow.newTokenRequest(code)
.setRedirectUri(redirectUri)
.execute();
}
//初始化Google Drive客户端
public Optional<Drive> initDriveService(String refreshToken) {
try {
UserCredentials credentials = initUserCredentialsAndRefreshAccessToken(refreshToken);
AccessToken accessToken = credentials.getAccessToken();
GoogleCredentials googleCredentials = new GoogleCredentials(accessToken);
HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(googleCredentials);
Drive service = new Drive.Builder(new NetHttpTransport(), JSON_FACTORY, requestInitializer).build();
return Optional.of(service);
} catch (GoogleOauthException exception) {
log.info("Google verify user token error", exception);
return Optional.empty();
}
}
//和Google Drive交互,根据Filed Id获取文件信息
public Optional<File> getFileName(Drive service, String fileId) {
try {
File file = service.files().get(fileId).setSupportsAllDrives(true).execute();
return Optional.of(file);
} catch (IOException exception) {
log.info("Get file name from Google api error" + exception.getMessage());
return Optional.empty();
}
}
//通过Refresh token获取Access token
public UserCredentials initUserCredentialsAndRefreshAccessToken(String refreshToken) throws GoogleOauthException {
try {
UserCredentials credentials = UserCredentials.newBuilder()
.setClientId(clientId)
.setClientSecret(clientSecret)
.setRefreshToken(refreshToken)
.build();
credentials.refreshIfExpired();
return credentials;
} catch (IOException exception) {
throw new GoogleOauthException("Google verify user token error", exception);
}
}
}
重要提示 - 只有在第一次授权时才会返回 refresh_token
,且刷新令牌也会过期
其中的业务要求:
App在不请求google api时也会显示普通的信息,但希望实时监听用户登录Google后,原页面部分重新渲染,显示出调用Google drive API的部分,所以需要一个东西来监听用户是否成功授权。
解决的方法是另起一个小窗口来定向到Google 的 OAuth 2.0 服务器,让用户选择登录,并在登陆成功后通过回调URI跳到指定路由,使用postmessage 给同源不同页面发送信息,重新请求api,来重新渲染出需要调用Google drive的部分。
这里我使用postMessage和window.open + window.opener实现前端的交互,将code传递给后端
window.open + window.opener
对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage() 方法分发一个MessageEvent 消息。接收消息的窗口可以根据需要自由处理此事件。传递给 window.postMessage() 的参数(比如 message)将通过消息事件对象暴露给接收消息的窗口。
安全问题: 当你使用 postMessage 将数据发送到其他窗口时,始终指定精确的目标 origin,而不是*。
使用
用.closed
属性过滤掉已经被关闭的 Tab 窗口以及父级tab是否还存在,然后发送认证成功后的授权码
ini
const GoogleOauthCallback = () => {
//获取到父级Window对象
const parent = window.opener;
const authCode = new URLSearchParams(window.location.search).get("code");
const message = "MyCode:" + authCode;
if (parent && !parent.closed) {
parent.postMessage(message, window.location.origin);
}
window.close();
return <></>;
};
export default GoogleOauthCallback;
接收消息的一方会先校验是否同源,然后将code解析后发送给后端来换取token
csharp
const receiveCodeAndSaveToken = async (event) => {
if (event.origin !== window.location.origin) return;
if (typeof event.data === "string" && event.data.startsWith("MyCode:")) {
const code = event.data.split("OauthCode:")[1];
const params = {
authorizationCode: code
};
await exchangeToken(params);
await checkUserIsAuthorized();
}
};