在 HTML5 Web App 中使用 OAuth2

IT技术 javascript oauth-2.0
2021-03-10 22:59:02

我目前正在尝试使用 OAuth2 开发一个完全用 JavaScript 构建的移动应用程序,该应用程序与 CakePHP API 对话。看看下面的代码,看看我的应用程序当前的样子(请注意,这是一个实验,因此代码凌乱,区域缺乏结构等。)

var access_token,
     refresh_token;

var App = {
    init: function() {
        $(document).ready(function(){
            Users.checkAuthenticated();
        });
    }(),
    splash: function() {
        var contentLogin = '<input id="Username" type="text"> <input id="Password" type="password"> <button id="login">Log in</button>';
        $('#app').html(contentLogin);
    },
    home: function() {  
        var contentHome = '<h1>Welcome</h1> <a id="logout">Log out</a>';
        $('#app').html(contentHome);
    }
};

var Users = {
    init: function(){
        $(document).ready(function() {
            $('#login').live('click', function(e){
                e.preventDefault();
                Users.login();
            }); 
            $('#logout').live('click', function(e){
                e.preventDefault();
                Users.logout();
            });
        });
    }(),
    checkAuthenticated: function() {
        access_token = window.localStorage.getItem('access_token');
        if( access_token == null ) {
            App.splash();
        }
        else {
            Users.checkTokenValid(access_token);
        }
    },
    checkTokenValid: function(access_token){

        $.ajax({
            type: 'GET',
            url: 'http://domain.com/api/oauth/userinfo',
            data: {
                access_token: access_token
            },
            dataType: 'jsonp',
            success: function(data) {
                console.log('success');
                if( data.error ) {
                    refresh_token = window.localStorage.getItem('refresh_token');
                     if( refresh_token == null ) {
                         App.splash();
                     } else {
                         Users.refreshToken(refresh_token);
                    }
                } else {
                    App.home();
                }
            },
            error: function(a,b,c) {
                console.log('error');
                console.log(a,b,c);
                refresh_token = window.localStorage.getItem('refresh_token');
                 if( refresh_token == null ) {
                     App.splash();
                 } else {
                     Users.refreshToken(refresh_token);
                }
            }
        });

    },
    refreshToken: function(refreshToken){

        $.ajax({
            type: 'GET',
            url: 'http://domain.com/api/oauth/token',
            data: {
                grant_type: 'refresh_token',
                refresh_token: refreshToken,
                client_id: 'NTEzN2FjNzZlYzU4ZGM2'
            },
            dataType: 'jsonp',
            success: function(data) {
                if( data.error ) {
                    alert(data.error);
                } else {
                    window.localStorage.setItem('access_token', data.access_token);
                    window.localStorage.setItem('refresh_token', data.refresh_token);
                    access_token = window.localStorage.getItem('access_token');
                    refresh_token = window.localStorage.getItem('refresh_token');
                    App.home();
                }
            },
            error: function(a,b,c) {
                console.log(a,b,c);
            }
        });

    },
    login: function() {
        $.ajax({
            type: 'GET',
            url: 'http://domain.com/api/oauth/token',
            data: {
                grant_type: 'password',
                username: $('#Username').val(),
                password: $('#Password').val(),
                client_id: 'NTEzN2FjNzZlYzU4ZGM2'
            },
            dataType: 'jsonp',
            success: function(data) {
                if( data.error ) {
                    alert(data.error);
                } else {
                    window.localStorage.setItem('access_token', data.access_token);
                    window.localStorage.setItem('refresh_token', data.refresh_token);
                    access_token = window.localStorage.getItem('access_token');
                    refresh_token = window.localStorage.getItem('refresh_token');
                    App.home();
                }
            },
            error: function(a,b,c) {
                console.log(a,b,c);
            }
        });
    },
    logout: function() {
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
        access_token = window.localStorage.getItem('access_token');
        refresh_token = window.localStorage.getItem('refresh_token');
        App.splash();
    }
};

我有一些与我的 OAuth 实施相关的问题:

1.) 显然将 access_token 存储在 localStorage 中是不好的做法,我应该改用 cookie。谁能解释为什么?因为据我所知,这不再安全或不那么安全,因为 cookie 数据不会被加密。

更新:根据这个问题:将数据存储在 localStorage 中的Local Storage vs Cookies无论如何只能在客户端使用,并且不像 cookie 那样做任何 HTTP 请求,所以对我来说似乎更安全,或者至少似乎没有据我所知有任何问题!

2.) 关于问题 1,使用 cookie 作为过期时间,对我来说同样毫无意义,就好像您查看代码一样,在应用程序启动时发出请求以获取用户信息,如果它在服务器端已过期,需要 refresh_token。所以不确定在客户端和服务器上都有过期时间的好处,当服务器才是真正重要的时候。

3.) 如何在没有 A 的情况下获取刷新令牌,将其与原始 access_token 一起存储以备后用,以及 B) 还存储一个 client_id?有人告诉我这是一个安全问题,但我以后如何使用这些,但在仅 JS 的应用程序中保护它们?再次查看上面的代码以了解到目前为止我是如何实现的。

3个回答

看起来您正在使用资源所有者密码凭据OAuth 2.0 流程,例如提交用户名/密码以获取访问令牌和刷新令牌。

  • 令牌访问在JavaScript被曝光,令牌被莫名其妙地暴露访问的风险是由它的寿命短缓解。
  • 令牌刷新不应暴露于客户端的JavaScript。它用于获取更多访问令牌(如您在上面所做的那样)但如果攻击者能够获得刷新令牌,他们将能够随意获得更多访问令牌,直到 OAuth 服务器撤销授权为其颁发刷新令牌的客户端

考虑到这一背景,让我回答您的问题:

  1. cookie 或 localstorage 将为您提供跨页面刷新的本地持久性。将访问令牌存储在本地存储中可以为您提供更多的 CSRF 攻击保护,因为它不会像 cookie 那样自动发送到服务器。您的客户端 javascript 需要将其从 localstorage 中拉出并在每个请求中传输它。我正在开发一个 OAuth 2 应用程序,因为它是单页方法,所以我两者都不做;相反,我只是将其保存在内存中。
  2. 我同意......如果你存储在 cookie 中,它只是为了持久性而不是为了过期,当令牌过期时,服务器将响应错误。我认为您可能会创建一个过期的 cookie 的唯一原因是,您可以检测它是否已过期,而无需首先发出请求并等待错误响应。当然,您可以通过保存已知的到期时间对本地存储做同样的事情。
  3. 这是我相信的整个问题的关键......“我如何在没有 A 的情况下获得刷新令牌,将其与原始 access_token 一起存储以备后用,并且 B)还存储一个 client_id”。不幸的是,您真的不能......正如该介绍性评论中所述,拥有刷新令牌客户端会否定访问令牌的有限生命周期提供的安全性我在我的应用程序中所做的事情(我没有使用任何持久的服务器端会话状态)如下:
  • 用户向服务器提交用户名和密码
  • 服务器然后转发到OAuth的端点的用户名和密码,在你上面的例子http://domain.com/api/oauth/token,并同时接收访问令牌和刷新令牌
  • 服务器加密刷新令牌并将其设置在 cookie 中(应该是 HTTP Only)
  • 服务器仅以明文(在 JSON 响应中)和加密的 HTTP cookie使用访问令牌进行响应
  • 客户端 javascript 现在可以读取和使用访问令牌(存储在本地存储或其他任何地方)
  • 当访问令牌过期时,客户端向服务器(不是 OAuth 服务器,而是托管应用程序的服务器)提交一个新令牌请求
  • 服务器接收它创建的加密的 HTTP only cookie,对其进行解密以获取刷新令牌,请求新的访问令牌,最后在响应中返回新的访问令牌

诚然,这确实违反了您正在寻找的“仅 JS”约束。但是,a) 同样,您真的不应该在 javascript 中使用刷新令牌,并且 b) 它在登录/注销时需要非常少的服务器端逻辑,并且不需要持久的服务器端存储。

关于 CSRF 的注意事项:如评论中所述,此解决方案不解决跨站请求伪造问题有关解决这些形式的攻击的更多想法,请参阅OWASP CSRF 预防备忘单

另一种选择是根本不请求刷新令牌(不确定这是否是您正在处理的 OAuth 2 实现的一个选项;根据规范,刷新令牌是可选)并在它到期时不断重新进行身份验证。

希望有帮助!

我不明白加密 refresh_token 如何提供任何额外的安全性,因为您的服务器会很乐意解密它,转发它并将新的访问令牌返回给用户。如果有人以某种方式获得了那个持久加密的 cookie,是什么阻止他们无限期地使用您的服务器获取 API 的访问令牌?您认为为了避免刷新令牌清晰而必须通过服务器进行额外的复杂操作是否值得?
2021-04-17 22:59:02
“b)它需要非常少的服务器端逻辑”您还必须添加服务器端注销。通常只使用访问令牌,您可以简单地删除对客户端中访问令牌的引用,以便用户注销。由于您无法从客户端删除 httpOnly cookie,您还必须在服务器上实现注销端点以删除 httpOnly cookie,存储刷新令牌。添加到服务器端的逻辑并不是很多,但仍然需要牢记。
2021-04-22 22:59:02
@TomW 查看CSRF 预防,了解如何在有人以某种方式窃取httpOnlycookie的情况下进一步验证服务器上的请求的一些想法OAuth的2规格规定“刷新令牌必须只在授权服务器和客户端为之刷新令牌发行保持在运输和储存保密的,共享的。” 这就是加密的目的;在这种情况下,客户端是请求刷新令牌的 Web 服务器。
2021-04-28 22:59:02
好的评论汤姆,并感谢您向任何其他评论此问题的人指出这一点。CSRF 可能仍然是一个问题,有很多方法可以解决它。
2021-04-30 22:59:02
http only cookie 中的刷新令牌不会为应用程序购买一些针对 XSS 的保护,但会将其暴露给 CSRF 吗?如果我可以诱使用户单击恶意链接然后发布表单,那么我现在拥有带有加密刷新令牌的 cookie。使用该 cookie,我是否无法访问您的应用程序中的端点(倒数第二步)并获取访问令牌?
2021-05-07 22:59:02

完全安全的唯一方法是不存储客户端访问令牌。任何对您的浏览器具有(物理)访问权限的人都可以获得您的令牌。

1) 您对两者都不是一个好的解决方案的评估是准确的。

2)如果您仅限于客户端开发,则使用到期时间将是您的最佳选择。它不会要求您的用户频繁地使用 Oauth 重新进行身份验证,并保证令牌不会永远存在。仍然不是最安全的。

3) 获取新令牌需要执行 Oauth 工作流以获取新令牌。client_id 与 Oauth 功能的特定域相关联。

保留 Oauth 令牌的最安全方法是服务器端实现。

不确定如何绕过存储令牌,因为它需要发出请求,并且可以在网络请求下的 Web Inspector 中看到。但是访问令牌仅在 1 小时内有效,之后用户必须重新进行身份验证或使用刷新令牌(如果可用)。所以我看不出这是一个巨大的安全问题……但我很想知道客户端应用程序如何解决这个问题。
2021-04-17 22:59:02
我想你会发现“仅限客户端的应用程序”仍然利用一些服务器端代理来确保安全。以这篇文章为例:derek.io/blog/2010/how-to-secure-oauth-in-javascript
2021-05-09 22:59:02

对于纯客户端方法,如果有机会,请尝试使用“隐式流”而不是“资源所有者流”。您不会收到作为响应一部分的刷新令牌。

  1. 当用户访问页面 JavaScript 在 localStorage 中检查 access_token 并检查它 expires_in
  2. 如果丢失或过期,则应用程序将打开新选项卡并将用户重定向到登录页面,成功登录后,用户将使用访问令牌重定向回来,该令牌仅在客户端处理并使用重定向页面保存在本地存储中
  3. 主页可能对本地存储中的访问令牌具有轮询机制,并且一旦用户登录(重定向页面将令牌保存到存储)页面处理正常。

在上述方法中,访问令牌应该是长寿的(例如 1 年)。如果您担心长寿令牌,您可以使用以下技巧。

  1. 当用户访问页面 JavaScript 在 localStorage 中检查 access_token 并检查它 expires_in
  2. 如果丢失或过期,则应用程序打开隐藏的 iframe 并尝试登录用户。通常 auth 网站有一个用户 cookie 并将授权存储到客户端网站,因此登录会自动发生,iframe 内的脚本会将令牌填充到存储中
  3. 客户端的主页设置了 access_token 和 timeout 的轮询机制。如果在这短短的时间内 access_token 没有填充到存储中,则意味着我们需要打开新选项卡并设置正常的隐式流