【问题标题】:Storing API keys and secrets in Google AppScript user property在 Google AppS 脚本用户属性中存储 API 密钥和机密
【发布时间】:2020-05-01 10:20:29
【问题描述】:

我对 Google AppScript 很陌生,正在尝试编写一个连接到自定义 REST API 的连接器。对于该 API,我需要一个 API 密钥(或秘密),即每个用户。由于在脚本中以纯文本形式存储秘密并不是最好的主意,因此我想将其存储在 Google PropertyService 中并从那里检索它。像这样:

var userProperties = PropertiesService.getUserProperties();
var apiKey = userProperties.getProperty('MY_SECRET')

但我不明白的是,用户如何先存储密钥?我还没有找到用户(在这种情况下是我)可以查看或编辑属性的任何地方。然后我发现了这个不错的introduction to user properties,它在脚本容器中创建了一个菜单,允许用户手动输入密码。

const API_KEY = 'API_KEY';

var ui = SpreadsheetApp.getUi();
var userProperties = PropertiesService.getUserProperties();


function onOpen(){
  ui.createMenu('API Keys')
    .addItem('Set API Key', 'userPromptApiKey')
    .addItem('Delete API Key', 'deleteApiKey')
  .addToUi();
}


function userPromptApiKey(){
  var userValue = ui.prompt('API Key ', ui.ButtonSet.OK);
  // ToDo: add current key to the prompt
  userProperties.setProperty(API_KEY, userValue.getResponseText());
}


function deleteApiKey(){
  userProperties.deleteProperty(API_KEY)
}

问题是,我的脚本没有绑定到任何容器(没有电子表格,没有文档)。相反,我想稍后在 Google DataStudio 中使用它。这就是为什么

SpreadsheetApp.getUi();

不起作用。关于如何处理的任何想法或建议?有没有其他推荐的方法来处理这些秘密?

【问题讨论】:

    标签: javascript google-apps-script properties user-input api-key


    【解决方案1】:

    现在,几周后我学到了很多东西。首先,您需要区分 UI 和逻辑脚本。其次,无论是容器绑定还是独立脚本。

    容器绑定脚本绑定到 Google 电子表格、Google Doc 或任何其他允许用户交互的 UI。在这种情况下,您可以在代码中访问 UI,并将自定义菜单添加到 UI,一旦用户单击该菜单,它将调用脚本中的方法。缺点是您需要知道它是电子表格还是文档,因为 UI 类不同。您还需要指示用户使用自定义菜单输入他或她的凭据。网上有个very nice instruction。以下代码片段受指令启发。确保为 onOpen 创建一个触发器。

    var ui = SpreadsheetApp.getUi();
    var userProperties = PropertiesService.getUserProperties();
    
    const API_KEY = 'api.key';
    
    function onOpen(){
      ui.createMenu('Credentials & Authentication')
        .addItem('Set API key', 'setKey')
        .addItem('Delete API key', 'resetKey')
        .addItem('Delete all credentials', 'deleteAll')
      .addToUi();
    }
    
    function setKey(){
      var scriptValue = ui.prompt('Please provide your API key.' , ui.ButtonSet.OK);
      userProperties.setProperty(API_KEY, scriptValue.getResponseText());
    }
    
    function resetKey(){
      userProperties.deleteProperty(API_KEY);
    }
    
    function deleteAll(){
      userProperties.deleteAllProperties();
    }
    

    对于独立脚本,您需要找到任何其他方式来连接到 UI。在我的情况下,我正在实施一个custom connector for Google Data Studio,在线也有a very nice example。还有一个非常详细的instruction on authentication 和一个API reference on authentication。这个custom connector for Kaggle 也很有帮助。它在Google Data Studio GitHub 上是开源的。以下演示代码受这些示例的启发。看看getCredentialsvalidateCredentialsgetAuthTyperesetAuthisAuthValidsetCredentials

    var cc = DataStudioApp.createCommunityConnector();
    
    const URL_DATA = 'https://www.myverysecretdomain.com/api';
    const URL_PING = 'https://www.myverysecretdomain.com/ping';
    const AUTH_USER = 'auth.user'
    const AUTH_KEY = 'auth.key';
    const JSON_TAG = 'user';
    
    String.prototype.format = function() {
      // https://coderwall.com/p/flonoa/simple-string-format-in-javascript
      a = this;
      for (k in arguments) {
        a = a.replace("{" + k + "}", arguments[k])
      }
      return a
    }
    
    function httpGet(user, token, url, params) {
      try {
        // this depends on the URL you are connecting to
        var headers = {
          'ApiUser': user,
          'ApiToken': token,
          'User-Agent': 'my super freaky Google Data Studio connector'
        };
    
        var options = {
          headers: headers
        };
    
        if (params && Object.keys(params).length > 0) {
          var params_ = [];
          for (const [key, value] of Object.entries(params)) {
            var value_ = value;
            if (Array.isArray(value))
              value_ = value.join(',');
    
            params_.push('{0}={1}'.format(key, encodeURIComponent(value_)))
          }
    
          var query = params_.join('&');
          url = '{0}?{1}'.format(url, query);
        }
    
        var response = UrlFetchApp.fetch(url, options);
    
        return {
          code: response.getResponseCode(),
          json: JSON.parse(response.getContentText())
        }  
      } catch (e) {
        throwConnectorError(e);
      }
    }
    
    function getCredentials() {
      var userProperties = PropertiesService.getUserProperties();
      return {
        username: userProperties.getProperty(AUTH_USER),
        token: userProperties.getProperty(AUTH_KEY)
      }
    }
    
    function validateCredentials(user, token) {
      if (!user || !token) 
        return false;
    
      var response = httpGet(user, token, URL_PING);
    
      if (response.code == 200)
        console.log('API key for the user %s successfully validated', user);
      else
        console.error('API key for the user %s is invalid. Code: %s', user, response.code);
    
      return response;
    }  
    
    function getAuthType() {
      var cc = DataStudioApp.createCommunityConnector();
      return cc.newAuthTypeResponse()
        .setAuthType(cc.AuthType.USER_TOKEN)
        .setHelpUrl('https://www.myverysecretdomain.com/index.html#authentication')
        .build();
    }
    
    function resetAuth() {
      var userProperties = PropertiesService.getUserProperties();
      userProperties.deleteProperty(AUTH_USER);
      userProperties.deleteProperty(AUTH_KEY);
    
      console.info('Credentials have been reset.');
    }
    
    function isAuthValid() {
      var credentials = getCredentials()
      if (credentials == null) {
        console.info('No credentials found.');
        return false;
      }
    
      var response = validateCredentials(credentials.username, credentials.token);
      return (response != null && response.code == 200);
    }
    
    function setCredentials(request) {
      var credentials = request.userToken;
      var response = validateCredentials(credentials.username, credentials.token);
    
      if (response == null || response.code != 200) return { errorCode: 'INVALID_CREDENTIALS' };
    
      var userProperties = PropertiesService.getUserProperties();
      userProperties.setProperty(AUTH_USER, credentials.username);
      userProperties.setProperty(AUTH_KEY, credentials.token);
    
      console.info('Credentials have been stored');
    
      return {
        errorCode: 'NONE'
      };
    }
    
    function throwConnectorError(text) {
      DataStudioApp.createCommunityConnector()
        .newUserError()
        .setDebugText(text)
        .setText(text)
        .throwException();
    }
    
    function getConfig(request) {
      // ToDo: handle request.languageCode for different languages being displayed
      console.log(request)
    
      var params = request.configParams;
      var config = cc.getConfig();
    
      // ToDo: add your config if necessary
    
      config.setDateRangeRequired(true);
      return config.build();
    }
    
    function getDimensions() {
      var types = cc.FieldType;
    
      return [
        {
          id:'id',
          name:'ID',
          type:types.NUMBER
        },
        {
          id:'name',
          name:'Name',
          isDefault:true,
          type:types.TEXT
        },
        {
          id:'email',
          name:'Email',
          type:types.TEXT
        }
      ];
    }
    
    function getMetrics() {
      return [];
    }
    
    function getFields(request) {
      Logger.log(request)
    
      var fields = cc.getFields();
    
      var dimensions = this.getDimensions();
      var metrics = this.getMetrics();
      dimensions.forEach(dimension => fields.newDimension().setId(dimension.id).setName(dimension.name).setType(dimension.type));  
      metrics.forEach(metric => fields.newMetric().setId(metric.id).setName(metric.name).setType(metric.type).setAggregation(metric.aggregations));
    
      var defaultDimension = dimensions.find(field => field.hasOwnProperty('isDefault') && field.isDefault == true);
      var defaultMetric = metrics.find(field => field.hasOwnProperty('isDefault') && field.isDefault == true);
    
      if (defaultDimension)
        fields.setDefaultDimension(defaultDimension.id);
      if (defaultMetric)
        fields.setDefaultMetric(defaultMetric.id);
    
      return fields;
    }
    
    function getSchema(request) {
      var fields = getFields(request).build();
      return { schema: fields };
    }
    
    function convertValue(value, id) {  
      // ToDo: add special conversion if necessary
      switch(id) {      
        default:
          // value will be converted automatically
          return value[id];
      }
    }
    
    function entriesToDicts(schema, data, converter, tag) {
    
      return data.map(function(element) {
    
        var entry = element[tag];
        var row = {};    
        schema.forEach(function(field) {
    
          // field has same name in connector and original data source
          var id = field.id;
          var value = converter(entry, id);
    
          // use UI field ID
          row[field.id] = value;
        });
    
        return row;
      });
    }
    
    function dictsToRows(requestedFields, rows) {
      return rows.reduce((result, row) => ([...result, {'values': requestedFields.reduce((values, field) => ([...values, row[field]]), [])}]), []);
    }
    
    function getParams (request) { 
      var schema = this.getSchema();
      var params;
    
      if (request) {
        params = {};
    
        // ToDo: handle pagination={startRow=1.0, rowCount=100.0}
      } else {
        // preview only
        params = {
          limit: 20
        }
      }
    
      return params;
    }
    
    function getData(request) {
      Logger.log(request)
    
      var credentials = getCredentials()
      var schema = getSchema();
      var params = getParams(request);
    
      var requestedFields;  // fields structured as I want them (see above)
      var requestedSchema;  // fields structured as Google expects them
      if (request) {
        // make sure the ordering of the requested fields is kept correct in the resulting data
        requestedFields = request.fields.filter(field => !field.forFilterOnly).map(field => field.name);
        requestedSchema = getFields(request).forIds(requestedFields);
      } else {
        // use all fields from schema
        requestedFields = schema.map(field => field.id);
        requestedSchema = api.getFields(request);
      }
    
      var filterPresent = request && request.dimensionsFilters;
      //var filter = ...
      if (filterPresent) {
        // ToDo: apply request filters on API level (before the API call) to minimize data retrieval from API (number of rows) and increase speed
        // see https://developers.google.com/datastudio/connector/filters
    
        // filter = ...   // initialize filter
        // filter.preFilter(params);  // low-level API filtering if possible
      }
    
      // get HTTP response; e.g. check for HTTT RETURN CODE on response.code if necessary
      var response = httpGet(credentials.username, credentials.token, URL_DATA, params);  
    
      // get JSON data from HTTP response
      var data = response.json;
    
      // convert the full dataset including all fields (the full schema). non-requested fields will be filtered later on  
      var rows = entriesToDicts(schema, data, convertValue, JSON_TAG);
    
      // match rows against filter (high-level filtering)
      //if (filter)
      //  rows = rows.filter(row => filter.match(row) == true);
    
      // remove non-requested fields
      var result = dictsToRows(requestedFields, rows);
    
      console.log('{0} rows received'.format(result.length));
      //console.log(result);
    
      return {
        schema: requestedSchema.build(),
        rows: result,
        filtersApplied: filter ? true : false
      };
    }
    

    如果这些都不符合您的要求,请按照@kessy 的其他答案中的建议使用WebApp

    【讨论】:

    • 你是个传奇,伙计。您并不经常在 SO 上搜索,并且您找到的第一个结果不仅与您的情况 90% 相关,而且具有很好解释的工作代码来引导。赞!
    【解决方案2】:

    您需要一个 UI 来从用户那里获取输入数据。

    您可以创建一个Web App 来构建一个接口来获取密钥。

    此外,如果您正在构建脚本但尚未发布,您可以在发布之前对密钥进行硬编码。

    【讨论】:

    • 好主意。我慢慢谈到这一点,我将在脚本本身(REST API 检索)和与 Google Data Studio 或电子表格本身的集成之间进行拆分。在开发过程中,我将使用脚本中的秘密。以后必须由外部提供;通过电子表格或 Google Data Studio 连接器配置。
    • 我在Google DataStudio Connector Authentication 上找到了一个很好的文档,它很好地解释了处理。在这里,UI 正在处理用户界面,要求用户输入一个键,然后将其提供给连接器。连接器需要存储、检索和验证密钥/秘密/凭证。
    猜你喜欢
    • 1970-01-01
    • 2021-06-14
    • 1970-01-01
    • 2019-09-17
    • 2023-03-02
    • 2015-12-29
    • 1970-01-01
    • 2021-03-18
    • 1970-01-01
    相关资源
    最近更新 更多