Flask和React - 处理Spotify授权后的令牌

7

在我的应用程序中(在 Spotify 身份验证之前),我已经实现了用户登录的 JWT,如下所示:

Flask

@auth_blueprint.route('/auth/login', methods=['POST'])
def login_user():
    # get post data
    post_data = request.get_json()
    response_object = {
        'status': 'fail',
        'message': 'Invalid payload.'
    }
    if not post_data:
        return jsonify(response_object), 400
    email = post_data.get('email')
    password = post_data.get('password')
    try:
        # fetch the user data
        user = User.query.filter_by(email=email).first()
        if user and bcrypt.check_password_hash(user.password, password):
            auth_token = user.encode_auth_token(user.id)
            if auth_token:
                response_object['status'] = 'success'
                response_object['message'] = 'Successfully logged in.'
                response_object['auth_token'] = auth_token.decode()
                return jsonify(response_object), 200
        else:
            response_object['message'] = 'User does not exist.'
            return jsonify(response_object), 404
    except Exception:
        response_object['message'] = 'Try again.'
        return jsonify(response_object), 500

这些是我SQLAlchemy中的User(db.Model)方法。
def encode_auth_token(self, user_id):
        """Generates the auth token"""
        try:
            payload = {
                'exp': datetime.datetime.utcnow() + datetime.timedelta(
                    days=current_app.config.get('TOKEN_EXPIRATION_DAYS'), 
                    seconds=current_app.config.get('TOKEN_EXPIRATION_SECONDS')
                ),
                'iat': datetime.datetime.utcnow(),
                'sub': user_id
            }
            return jwt.encode(
                payload,
                current_app.config.get('SECRET_KEY'),
                algorithm='HS256'
            )
        except Exception as e:
            return e

@staticmethod
def decode_auth_token(auth_token):
        """
        Decodes the auth token - :param auth_token: - :return: integer|string
        """
        try:
            payload = jwt.decode(
                auth_token, current_app.config.get('SECRET_KEY'))
            return payload['sub']
        except jwt.ExpiredSignatureError:
            return 'Signature expired. Please log in again.'
        except jwt.InvalidTokenError:
            return 'Invalid token. Please log in again.'

React

App.jsx

  loginUser(token) {
    window.localStorage.setItem('authToken', token);
    this.setState({ isAuthenticated: true });
    this.getUsers();
    this.createMessage('Welcome', 'success');
  };

(...)

<Route exact path='/login' render={() => (
  <Form
    isAuthenticated={this.state.isAuthenticated}
    loginUser={this.loginUser}
  />
)} />

and

Form.jsx

handleUserFormSubmit(event) {
    event.preventDefault();
    const data = {
      email: this.state.formData.email,
      password: this.state.formData.password
    };
    const url = `${process.env.REACT_APP_WEB_SERVICE_URL}/auth/${formType.toLowerCase()}`;
    axios.post(url, data)
      .then((res) => {
        this.props.loginUser(res.data.auth_token);
    })

第三方授权 + 第二层应用认证

现在我想添加第二层身份验证,并在 Spotify 回调后处理令牌,如下所示:

@spotify_auth_bp.route("/callback", methods=['GET', 'POST'])
def spotify_callback():

    # Auth Step 4: Requests refresh and access tokens
    SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"

    CLIENT_ID =   os.environ.get('SPOTIPY_CLIENT_ID')
    CLIENT_SECRET = os.environ.get('SPOTIPY_CLIENT_SECRET')
    REDIRECT_URI = os.environ.get('SPOTIPY_REDIRECT_URI')

    auth_token = request.args['code']

    code_payload = {
        "grant_type": "authorization_code",
        "code": auth_token,
        "redirect_uri": REDIRECT_URI,
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
    }

    post_request = requests.post(SPOTIFY_TOKEN_URL, data=code_payload)

    # Auth Step 5: Tokens are Returned to Application
    response_data = json.loads(post_request.text)

    access_token = response_data["access_token"]
    refresh_token = response_data["refresh_token"]
    token_type = response_data["token_type"]
    expires_in = response_data["expires_in"]

    # At this point, there is to generate a custom token for the frontend
    # Either a self-contained signed JWT or a random token?
    # In case the token is not a JWT, it should be stored in the session (in case of a stateful API)
    # or in the database (in case of a stateless API)
    # In case of a JWT, the authenticity can be tested by the backend with the signature so it doesn't need to be stored at all?

    res = make_response(redirect('http://localhost/about', code=302))

    return res
注意:这是获取新Spotify令牌的可能终端点:
@spotify_auth_bp.route("/refresh_token", methods=['GET', 'POST'])
def refresh_token():
        SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
        CLIENT_ID =   os.environ.get('SPOTIPY_CLIENT_ID')
        CLIENT_SECRET = os.environ.get('SPOTIPY_CLIENT_SECRET')

        code_payload = {
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
        }

        encode = 'application/x-www-form-urlencoded'
        auth = base64.b64encode("{}:{}".format(CLIENT_ID, CLIENT_SECRET).encode())
        headers = {"Content-Type" : encode, "Authorization" : "Basic {}".format(auth)} 

        post_request = requests.post(SPOTIFY_TOKEN_URL, data=code_payload, headers=headers)
        response_data = json.loads(post_request.text)

        access_token = response_data["access_token"]
        refresh_token = response_data["refresh_token"]
        token_type = response_data["token_type"]
        expires_in = response_data["expires_in"]

        return access_token

在 Spotify 回调之后,如何处理我的令牌是最好的方式?

考虑到一旦用户登录应用程序,他将持续登录 Spotify 并需要每 60 分钟刷新 Spotify 的访问令牌:

  • 授权码是否只是服务器到服务器的流程,以保护应用程序凭据,并且在前端拥有令牌是安全的?

  • 我应该在前端保存访问令牌和刷新令牌,并拥有无状态的 JWT 吗?

  • 我应该仅保留临时访问令牌并将刷新令牌保存在数据库中,使用有状态的 JWT 吗?

  • 我应该选择一个仅在服务器端持久化的会话吗?

在这里,处理我的敏感数据的最安全的方式是什么?考虑到上述代码,又是怎样的呢?

1个回答

6

有很多问题!让我们一一解答:

授权码是仅用于服务器之间的流程,目的是保护应用凭据,因此在前端拥有令牌是安全的吗?

Authorization Code授权中,您必须使用对/token的请求(grant_type: authorization_code)交换Authorization Code以获取令牌。这需要您的client_idclient_secret,它们会被存储在服务器上(也就是说在您的React Web应用中不会公开)。在这种情况下,确实是服务器之间的流程。

我应该在前端存储访问令牌和刷新令牌,并拥有无状态的JWT吗?

在您的情况下,我建议不要。如果该令牌将用于在服务器端执行某些Spotify API请求,请在服务器端保留access_tokenrefresh_token

但那样就不再是无状态的了? 的确是。

您可以做到“无状态”的事情是什么?

如果你真的想要/需要无状态令牌,我认为你可以将access_token存储在Cookie中,并使用以下选项(这是强制性的):

  • 安全:仅通过HTTPS发送cookie
  • HttpOnly:无法从Javascript访问
  • SameSite:最好是严格的!(这取决于您是否需要CORS)

优点:

  • 它是无状态的。

缺点:

  • 它可能是一个巨大的cookie。
  • 任何能够访问您计算机的人都可以获得access_token,就像会话cookie一样。在这里过期时间很重要。还请参见:https://dev59.com/-1gR5IYBdhLWcg3wouh6#41076836
  • 还有其他问题吗?需进一步验证。

刷新令牌的情况。

我建议您将刷新令牌存储在服务器端,因为它通常是一个长期有效的令牌。

access_token过期时怎么办?

当请求带有过期的access_token时,您可以使用服务器端存储的refresh_token来刷新access_token,执行工作,并通过Set-Cookie头返回存储新的access_token 的响应。

JWT的其他注意事项

如果您一直使用JWT并将其存储在Http-Only cookie中,您可能会说您没有办法知道从React应用程序是否已登录。好吧,我已经尝试过JWT的一个很棒的技巧。
JWT由3个部分组成:头部、载荷和签名。实际上您想要保护的是JWT中的签名。确实,如果您没有正确的签名,JWT是无用的。因此,您可以拆分JWT并只将签名设置为Http-Only。
在您的情况下,它应该看起来像这样:
@app.route('/callback')
def callback():
    # (...)

    access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJSYXBoYWVsIE1lZGFlciJ9.V5exVQ92sZRwRxKeOFxqb4DzWaMTnKu-VmhW-r1pg8E'

    a11n_h, a11n_d, a11n_s = access_token.split('.')

    response = redirect('http://localhost/about', 302)
    response.set_cookie('a11n.h', a11n_h, secure=True)
    response.set_cookie('a11n.d', a11n_d, secure=True)
    response.set_cookie('a11n.s', a11n_s, secure=True, httponly=True)

    return response

你将拥有3个cookies:

  • a11n.h: 头部信息(选项:安全)
  • a11n.d: 载荷(选项:安全)
  • a11n.s: 签名(选项:安全,仅限HTTP

其结果为:

  • a11n.d cookie可以从您的React应用程序中访问(甚至可以从中获取用户信息)
  • a11n.s cookie无法从Javascript中访问
  • 在向Spotify发送请求之前,您必须在服务器端重新组装来自cookiesaccess_token

重新组装access_token的方法:

@app.route('/resource')
def resource():
    a11n_h = request.cookies.get('a11n.h') 
    a11n_d = request.cookies.get('a11n.d')
    a11n_s = request.cookies.get('a11n.s')

    access_token = a11n_h + '.' + a11n_d + '.' + a11n_s
    jwt.decode(access_token, verify=True)

希望对您有所帮助!

免责声明:

代码示例需要改进(错误处理、检查等)。它们只是为了说明流程而提供的示例。


非常感谢您的详细分析。对于这个广泛的问题,我很抱歉,但是真正的核心在于权衡利弊。我会选择您的JWT解决方案。请问您能否编辑您的答案并提供一些代码,考虑到我的上面的代码,以及您对JWT的实现?为了完整起见,因为我在问题中有JWT代码。我真的很感激,并将点赞您的回答。 - 8-Bit Borges
从您的代码中:return jwt.encode( payload, current_app.config.get('SECRET_KEY'), algorithm='HS256' )返回。 - Raphael Medaer
只需使用服务器端存储的 refresh_token 刷新 access_token。应将其存储为 cookie,存储在 /callback 处。 - 8-Bit Borges
当然...我在问题中提到了这一点。问题是,我从/callback获取刷新令牌,据我所知,没有办法通过传递id=user_id来查询数据库,因为Spotify服务器不接受/callback/<id>作为URL。我尝试过这个方法,但是遇到了死路。 - 8-Bit Borges
你应该看一下 state!来自 Spotify:可选,但强烈建议使用。 状态对于关联请求和响应非常有用。 - Raphael Medaer
显示剩余4条评论

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