使用自定义身份验证替代OAuth2来保护Google Cloud Endpoints

61

我们非常激动,因为App Engine支持Google Cloud Endpoints

尽管如此,我们尚未使用OAuth2,通常使用用户名/密码对用户进行身份验证,以便我们支持没有Google账户的客户。

我们想将API迁移到Google Cloud Endpoints,因为我们可以免费获得所有的好处(API控制台、客户端库、稳健性等等),但我们的主要问题是...

如何将自定义身份验证添加到Cloud Endpoints中,在之前检查现有API中有效的用户会话和CSRF令牌的基础上。

是否有一种优雅的方法来实现这一点,而不必向protoRPC消息添加诸如会话信息和CSRF令牌之类的内容?


7
如果您要使用自己的账户并想使用OAuth 2.0,那您需要创建自己的OAuth令牌。虽然我会提供实际答案,但简而言之,这就是需要做的事情。 - bossylobster
1
有关这个问题,Tosh和@bossylobster有什么新的进展吗?有人成功地完成了吗? - M.Sameer
目前没有新的内容,但我在这里提供了更详细的信息,@tosh,不过我想你已经知道了。https://dev59.com/72Ml5IYBdhLWcg3wDzZG#18728482 - PaulR
5个回答

17

我正在使用webapp2身份验证系统来处理我的整个应用程序。因此,我尝试将其重用于Google Cloud身份验证,并成功了!

webapp2_extras.auth使用webapp2_extras.sessions来存储身份验证信息。而且此会话可以以3种不同的格式进行存储:securecookie、datastore或memcache。

Securecookie是默认格式,也是我正在使用的格式。由于webapp2身份验证系统用于许多在生产环境中运行的GAE应用程序,我认为它足够安全。

因此,我解码此securecookie并从GAE端点重用它。我不知道这是否会产生某些安全问题(我希望不会),但也许@bossylobster可以从安全角度看看这是否可行。

我的API:

import Cookie
import logging
import endpoints
import os
from google.appengine.ext import ndb
from protorpc import remote
import time
from webapp2_extras.sessions import SessionDict
from web.frankcrm_api_messages import IdContactMsg, FullContactMsg, ContactList, SimpleResponseMsg
from web.models import Contact, User
from webapp2_extras import sessions, securecookie, auth
import config

__author__ = 'Douglas S. Correa'

TOKEN_CONFIG = {
    'token_max_age': 86400 * 7 * 3,
    'token_new_age': 86400,
    'token_cache_age': 3600,
}

SESSION_ATTRIBUTES = ['user_id', 'remember',
                      'token', 'token_ts', 'cache_ts']

SESSION_SECRET_KEY = '9C3155EFEEB9D9A66A22EDC16AEDA'


@endpoints.api(name='frank', version='v1',
               description='FrankCRM API')
class FrankApi(remote.Service):
    user = None
    token = None

    @classmethod
    def get_user_from_cookie(cls):
        serializer = securecookie.SecureCookieSerializer(SESSION_SECRET_KEY)
        cookie_string = os.environ.get('HTTP_COOKIE')
        cookie = Cookie.SimpleCookie()
        cookie.load(cookie_string)
        session = cookie['session'].value
        session_name = cookie['session_name'].value
        session_name_data = serializer.deserialize('session_name', session_name)
        session_dict = SessionDict(cls, data=session_name_data, new=False)

        if session_dict:
            session_final = dict(zip(SESSION_ATTRIBUTES, session_dict.get('_user')))
            _user, _token = cls.validate_token(session_final.get('user_id'), session_final.get('token'),
                                               token_ts=session_final.get('token_ts'))
            cls.user = _user
            cls.token = _token

    @classmethod
    def user_to_dict(cls, user):
        """Returns a dictionary based on a user object.

        Extra attributes to be retrieved must be set in this module's
        configuration.

        :param user:
            User object: an instance the custom user model.
        :returns:
            A dictionary with user data.
        """
        if not user:
            return None

        user_dict = dict((a, getattr(user, a)) for a in [])
        user_dict['user_id'] = user.get_id()
        return user_dict

    @classmethod
    def get_user_by_auth_token(cls, user_id, token):
        """Returns a user dict based on user_id and auth token.

        :param user_id:
            User id.
        :param token:
            Authentication token.
        :returns:
            A tuple ``(user_dict, token_timestamp)``. Both values can be None.
            The token timestamp will be None if the user is invalid or it
            is valid but the token requires renewal.
        """
        user, ts = User.get_by_auth_token(user_id, token)
        return cls.user_to_dict(user), ts

    @classmethod
    def validate_token(cls, user_id, token, token_ts=None):
        """Validates a token.

        Tokens are random strings used to authenticate temporarily. They are
        used to validate sessions or service requests.

        :param user_id:
            User id.
        :param token:
            Token to be checked.
        :param token_ts:
            Optional token timestamp used to pre-validate the token age.
        :returns:
            A tuple ``(user_dict, token)``.
        """
        now = int(time.time())
        delete = token_ts and ((now - token_ts) > TOKEN_CONFIG['token_max_age'])
        create = False

        if not delete:
            # Try to fetch the user.
            user, ts = cls.get_user_by_auth_token(user_id, token)
            if user:
                # Now validate the real timestamp.
                delete = (now - ts) > TOKEN_CONFIG['token_max_age']
                create = (now - ts) > TOKEN_CONFIG['token_new_age']

        if delete or create or not user:
            if delete or create:
                # Delete token from db.
                User.delete_auth_token(user_id, token)

                if delete:
                    user = None

            token = None

        return user, token

    @endpoints.method(IdContactMsg, ContactList,
                      path='contact/list', http_method='GET',
                      name='contact.list')
    def list_contacts(self, request):

        self.get_user_from_cookie()

        if not self.user:
            raise endpoints.UnauthorizedException('Invalid token.')

        model_list = Contact.query().fetch(20)
        contact_list = []
        for contact in model_list:
            contact_list.append(contact.to_full_contact_message())

        return ContactList(contact_list=contact_list)

    @endpoints.method(FullContactMsg, IdContactMsg,
                      path='contact/add', http_method='POST',
                      name='contact.add')
    def add_contact(self, request):
        self.get_user_from_cookie()

        if not self.user:
           raise endpoints.UnauthorizedException('Invalid token.')


        new_contact = Contact.put_from_message(request)

        logging.info(new_contact.key.id())

        return IdContactMsg(id=new_contact.key.id())

    @endpoints.method(FullContactMsg, IdContactMsg,
                      path='contact/update', http_method='POST',
                      name='contact.update')
    def update_contact(self, request):
        self.get_user_from_cookie()

        if not self.user:
           raise endpoints.UnauthorizedException('Invalid token.')


        new_contact = Contact.put_from_message(request)

        logging.info(new_contact.key.id())

        return IdContactMsg(id=new_contact.key.id())

    @endpoints.method(IdContactMsg, SimpleResponseMsg,
                      path='contact/delete', http_method='POST',
                      name='contact.delete')
    def delete_contact(self, request):
        self.get_user_from_cookie()

        if not self.user:
           raise endpoints.UnauthorizedException('Invalid token.')


        if request.id:
            contact_to_delete_key = ndb.Key(Contact, request.id)
            if contact_to_delete_key.get():
                contact_to_delete_key.delete()
                return SimpleResponseMsg(success=True)

        return SimpleResponseMsg(success=False)


APPLICATION = endpoints.api_server([FrankApi],
                                   restricted=False)

我认为是的,但是你必须从数据存储中获取会话而不是从安全cookie中获取。我尝试过了,但无法使数据存储的会话正常工作。 - Douglas Correa
我认为问题在于您需要Request对象来访问(数据存储格式)会话。在端点中,您无法访问Request对象。 - Korneel
理论上,您还需要请求对象来访问securecookie,但是正如您所看到的,我深入研究了webapp2代码,并发现它并不是真正需要的,只需要其中的一些信息。也许您可以对Datastore会话做同样的事情。 - Douglas Correa
我使用基于普通cookie的身份验证,但是Endpoints似乎会为不同的用户缓存cookie!这让我很头疼。 - ZiglioUK
你怎么设置cookie呢?我无法使用webapp2的auth.get_auth().set_session()函数,因为全局请求变量未设置。 - CIF
2
如何注册和登录? - user47376

2
我撰写了一个名为Authtopus的自定义Python身份验证库,可能会对寻找此类解决方案的任何人感兴趣:https://github.com/rggibson/Authtopus Authtopus支持基本的用户名和密码注册和登录,以及通过Facebook或Google进行社交登录(可能还可以添加更多的社交提供商)。根据已验证的电子邮件地址合并用户帐户,因此,如果用户首先通过用户名和密码进行注册,然后稍后使用社交登录,并且帐户的已验证电子邮件地址匹配,则不会创建单独的用户帐户。

你能提供一个Java库吗? - Harikrishnan
我很想做,但可能不会很快完成。 - rggibson
哦,好的。也许需要一些文档,这样我才能制作一个库? - Harikrishnan
尽管它可以改进,但README中提供了一些关于库如何工作的信息,并详细说明了每个端点URL所期望的参数。 - rggibson

1
根据我的理解,Google Cloud Endpoints 提供了一种实现(RESTful?)API 和生成移动客户端库的方式。在这种情况下,身份验证将使用 OAuth2。OAuth2 提供了不同的“流程”,其中一些支持移动客户端。 在使用主体和凭据(用户名和密码)进行身份验证的情况下,这似乎不是很合适。老实说,我认为你最好使用 OAuth2。 实现自定义 OAuth2 流以支持您的情况是一种可行的方法,但非常容易出错。 我还没有使用过 OAuth2,但也许可以为用户创建“API密钥”,以便他们可以通过移动客户端同时使用前端和后端。

3
OAuth2总是要求用户拥有Google账户,这对用户来说是最烦恼的问题。 - John

0

您可以使用JWT进行身份验证。解决方案在这里


0

我还没有编写它,但是它的想法是这样的:

当服务器收到登录请求时,它会在数据存储中查找用户名/密码。如果未找到该用户,服务器会响应一些包含适当消息的错误对象,例如“用户不存在”。如果找到,则将其存储在大小有限的 FIFO 类型集合(缓存)中,例如100(或1000或10000)。
在成功的登录请求中,服务器向客户端返回类似于“;LKJLK345345LKJLKJSDF53KL”的sessionid。可以是Base64编码的用户名:密码。 客户端将其存储在名为“authString”或“sessionid”(或其他不太简洁的内容)的 Cookie 中,并在30分钟(任何时间)后过期。
在登录后的每个请求中,客户端都会从 cookie 中发送 Autorization 标头。每次使用 cookie,它都会更新 -- 因此只要用户活动,它就永远不会过期。
在服务器端,我们将拥有 AuthFilter,它将检查每个请求中的 Authorization 标头是否存在(排除登录、注册、重置密码)。如果没有找到这样的标头,过滤器会向客户端返回带有状态代码401的响应(客户端向用户显示登录屏幕)。如果找到了标头,过滤器首先检查缓存中的用户是否存在,然后检查数据存储,如果找到了用户,则什么都不做(请求由适当的方法处理),否则返回401。
以上架构允许保持服务器无状态,但仍具有自动断开会话的功能。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接