Web网页应用集成Google API

简单了解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)

🌰: Google Oauth Playground

特点

  1. 授权码模式是功能最完整、流程最严密的授权模式。这种方式是最常用的流程,安全性也最高,
  2. 它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。
  3. 这样的前后端分离,可以避免令牌泄漏。

适用场景:目前主流的第三方验证都是采用这种模式

一些重要的参数

  • ClientId: 客户端ID,标识符
  • ClientSecret: 结合clientId,授权服务器会校验是否注册过
  • RedirectURIs: 认证授权后重定向的URI,如果是授权码模式,会将code以参数模式在URI上返回
  • Scope: 授权包含的范围

集成Google Drive

前提条件

  1. 申请Google API使用权,推荐注册一个Google cloud project,另一种方法需要审核比较长时间
  2. 创建授权凭据,需要填写指定的重定向URI,获得app的唯一认证凭证,包含clientId等

Javascript Web网页应用集成Google Drive Api

适用于客户端 Web 应用的 OAuth 2.0 | Authorization | Google for Developers

工作流程

  1. 应用打开一个 Google 网址,该网址使用查询参数来识别您的应用以及该应用所需的 API 访问权限类型。
  2. 在当前浏览器窗口或弹出式窗口中打开该网址。用户可以通过 Google 进行身份验证并授予请求的权限。
  3. 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。

工作流程

  1. 重定向到 Google 的 OAuth 2.0 服务器,应用打开一个 Google 网址,该网址使用查询参数来识别您的应用以及该应用所需的 API 访问权限类型。
  2. Google 提示用户同意,在当前浏览器窗口或弹出式窗口中打开该网址。用户可以通过 Google 进行身份验证并授予请求的权限。
  3. 处理 OAuth 2.0 服务器响应,Google 将用户重定向回您的应用。该重定向包含授权代码,应用可以用它来换取访问令牌和刷新令牌。可以在安全的地方保存刷新令牌,可以和用户邮箱进行绑定等
  4. 用授权代码来刷新令牌和访问令牌,应用会验证该访问令牌,然后使用此令牌发出 API 请求。
  5. 当令牌过期时,应用会使用刷新令牌获取访问令牌。

实际使用

注意:

请慎重考虑是否要向该页面上的所有资源(尤其是社交插件和分析等第三方脚本)发送授权凭据。为避免此问题,我们建议服务器先处理请求,然后再重定向到另一个不包含响应参数的网址。

我们在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();
    }
};
相关推荐
惜.己15 分钟前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
长天一色1 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2341 小时前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript
读心悦1 小时前
如何在 Axios 中封装事件中心EventEmitter
javascript·http
神之王楠2 小时前
如何通过js加载css和html
javascript·css·html
余生H2 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
流烟默2 小时前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch
茶卡盐佑星_3 小时前
meta标签作用/SEO优化
前端·javascript·html
与衫3 小时前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql
金灰3 小时前
HTML5--裸体回顾
java·开发语言·前端·javascript·html·html5