在Ruby与Pinia技术栈中构建基于短生命周期令牌的零信任GraphQL认证架构


在一个前后端分离的GraphQL应用中,认证状态的管理和API请求的安全性是架构设计的核心支柱。传统的Bearer令牌机制,尤其是长生命周期的令牌,为系统引入了巨大的安全隐患。一旦令牌泄露,攻击者便能在其有效期内自由访问系统资源。为了解决这个问题,我们必须转向一种更符合“零信任”原则的架构:即默认不信任任何请求,每次交互都必须经过严格的验证。这里的挑战在于,如何在保证极致安全的同时,不牺牲用户体验和系统的可维护性。

本文将探讨一种认证方案的设计与权衡,目标是在一个由Vue 3、Pinia、Apollo Client构成的前端,与一个Ruby on Rails、graphql-ruby构建的后端之间,实现一套基于短生命周期访问令牌(Access Token)和带轮换与重用检测机制的刷新令牌(Refresh Token)的认证体系。

定义问题:传统认证模式的脆弱性

典型的认证流程是:用户登录后,服务器颁发一个短生命周期的Access Token和一个长生命周期的Refresh Token。Access Token用于访问受保护资源,Refresh Token用于在Access Token过期后静默获取新的Access Token。

这个模型存在一个致命缺陷:Refresh Token的安全性。通常它被存储在客户端,如浏览器的httpOnly Cookie中以防止XSS攻击。但如果Refresh Token被窃取(例如通过中间人攻击或从服务器数据库泄露),攻击者就能在很长一段时间内(Refresh Token的有效期,通常是几天甚至几个月)持续获取新的Access Token,从而完全控制用户账户。这与零信任的核心思想背道而驰。

方案A:标准的Refresh Token机制

这是最常见的实现方式。

  • 流程:

    1. 用户登录,服务器返回Access Token(例如,有效期15分钟)和Refresh Token(例如,有效期7天)。
    2. Access Token存储在内存(如Pinia Store),Refresh Token存储在httpOnlysecure的Cookie中。
    3. 每次API请求,客户端携带Access Token。
    4. 当API返回“令牌过期”错误时,客户端使用Refresh Token请求新的Access Token。
    5. 服务器验证Refresh Token,若有效,则颁发新的Access Token,客户端重试失败的请求。
  • 优势:

    • 实现相对简单,是行业内的普遍实践。
    • httpOnly Cookie为Refresh Token提供了一定程度的保护。
  • 劣势:

    • 核心安全风险未解决: Refresh Token一旦泄露,其威胁持续整个生命周期。服务器无法得知令牌是否已被盗用。
    • 无法主动撤销: 如果用户希望“登出所有设备”,服务器端需要维护一个复杂的吊销列表(Denylist),这在高并发系统中会成为性能瓶颈。

方案B:Refresh Token轮换与重用检测

为了解决方案A的根本问题,我们引入了Refresh Token轮换(Rotation)机制。

  • 流程:

    1. 与方案A类似,用户登录时获取一对Access/Refresh Token。
    2. 关键区别在于刷新操作:当客户端使用一个Refresh Token(我们称之为RT_1)来获取新的Access Token时,服务器在验证RT_1有效后,不仅会颁发一个新的Access Token,还会颁发一个全新的Refresh Token(RT_2
    3. 服务器会立即将RT_1标记为已使用或直接废弃。
    4. 重用检测: 如果服务器在后续请求中收到了一个已被标记为“已使用”的Refresh Token(如RT_1),这强烈暗示着该令牌可能已被窃取并被攻击者和合法用户同时使用。此时,服务器应立即将与该令牌关联的所有会话(整个令牌家族)全部作废,并强制用户重新登录。
  • 优势:

    • 显著提升安全性: 即使Refresh Token在传输过程中被窃取,攻击者也只有一次使用机会。一旦攻击者使用它,合法的用户客户端持有的旧令牌就会失效,反之亦然。当任何一方尝试使用已作废的令牌时,系统会触发警报并销毁整个会话,极大地缩短了攻击窗口。
    • 符合零信任原则: 系统不再盲目信任一个长期有效的凭证,而是将其生命周期缩短到“一次性使用”。
  • 劣势:

    • 实现复杂性增加: 客户端和服务器都需要处理更复杂的令牌交换逻辑。
    • 网络竞态条件: 在不稳定的网络环境中,客户端可能成功发送了刷新请求但未能收到新的Refresh Token。如果此时它用旧的Refresh Token重试,就会触发重用检测,导致合法用户被登出。这需要客户端具备健壮的重试和错误处理逻辑。

最终选择与理由

在安全优先的原则下,方案B是唯一符合零信任架构精神的选择。虽然实现更复杂,但它提供的安全保障是方案A无法比拟的。它将一个长期存在的安全漏洞转变为一个可被立即检测和响应的事件。对于处理敏感数据或高价值操作的应用而言,这种额外的实现成本是完全值得的。

接下来,我们将深入探讨如何在Ruby后端和Vue/Pinia/Apollo前端中实现这套复杂的认证架构。

sequenceDiagram
    participant C as Client (Vue/Pinia/Apollo)
    participant S as Server (Ruby/GraphQL)
    participant DB as Redis/Database

    C->>S: GraphQL Mutation: login(email, password)
    S->>DB: Verify credentials
    DB-->>S: User valid
    S->>S: Generate Access Token (AT1, 15min)
    S->>S: Generate Refresh Token (RT1, 7days)
    S->>DB: Store hash of RT1
    S-->>C: { accessToken: AT1 }, Set-Cookie: refreshToken=RT1
    C->>C: Store AT1 in Pinia Store

    Note over C,S: A while later, AT1 expires

    C->>S: GraphQL Query with AT1
    S->>S: Validate AT1 (expired)
    S-->>C: Error: "TOKEN_EXPIRED"

    C->>C: Intercept error, trigger refresh flow
    C->>S: GraphQL Mutation: refreshToken() (with RT1 from cookie)
    S->>DB: Find RT1 hash
    alt RT1 is valid and not used
        S->>S: Generate new Access Token (AT2, 15min)
        S->>S: Generate new Refresh Token (RT2, 7days)
        S->>DB: Invalidate RT1, store hash of RT2
        S-->>C: { accessToken: AT2 }, Set-Cookie: refreshToken=RT2
        C->>C: Update Pinia with AT2
        C->>S: Retry original GraphQL Query with AT2
        S-->>C: Query successful
    else RT1 has been used (Reuse detected!)
        S->>DB: Invalidate all tokens for this user session
        S-->>C: Error: "INVALID_TOKEN", Clear-Cookie
        C->>C: Force user logout
    end

核心实现:后端 (Ruby & graphql-ruby)

我们将使用Rails、graphql-rubyjwt gem以及Redis来实现后端逻辑。Redis用于快速存储和查询Refresh Token的状态。

1. Gemfile配置

# Gemfile
gem 'graphql'
gem 'jwt'
gem 'redis'

2. JWT服务封装

创建一个服务类来处理JWT的签发和解码,确保逻辑集中且可测试。

# app/services/json_web_token_service.rb
class JsonWebTokenService
  SECRET_KEY = Rails.application.credentials.secret_key_base.to_s
  ALGORITHM = 'HS256'

  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY, ALGORITHM)
  end

  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: ALGORITHM })
    HashWithIndifferentAccess.new(decoded[0])
  rescue JWT::ExpiredSignature, JWT::DecodeError
    nil
  end
end

3. Refresh Token管理

这是轮换机制的核心。我们创建一个服务来管理Refresh Token的生命周期,并利用Redis进行状态跟踪。

# app/services/refresh_token_service.rb
class RefreshTokenService
  SESSION_PREFIX = "user_session:".freeze
  TOKEN_EXPIRATION = 7.days
  FAMILY_EXPIRATION = 90.days # A token family expires after 90 days of inactivity

  def self.create(user_id)
    session_id = SecureRandom.uuid
    token = SecureRandom.urlsafe_base64
    
    redis.setex("#{SESSION_PREFIX}#{session_id}:#{token}", TOKEN_EXPIRATION.to_i, user_id.to_s)
    redis.expire("#{SESSION_PREFIX}#{session_id}", FAMILY_EXPIRATION.to_i) # Set TTL on the family tracker

    { token: token, session_id: session_id }
  end

  def self.rotate(old_token, session_id)
    key = "#{SESSION_PREFIX}#{session_id}:#{old_token}"
    
    # Use a transaction to ensure atomicity
    user_id = redis.multi do |multi|
      multi.get(key)
      multi.del(key) # Invalidate old token immediately
    end.first

    return nil unless user_id

    # If reuse is detected, nuke the entire session
    if user_id == "used"
      invalidate_session(session_id)
      return :reused
    end

    # Mark the old token as used to detect future reuse attempts
    # This marker has a short TTL to prevent Redis from filling up with old markers.
    redis.setex("#{SESSION_PREFIX}#{session_id}:#{old_token}", 5.minutes.to_i, "used")

    create(user_id.to_i)
  end

  def self.invalidate_session(session_id)
    keys = redis.keys("#{SESSION_PREFIX}#{session_id}:*")
    redis.del(keys) if keys.any?
  end

  private

  def self.redis
    @redis ||= Redis.new(url: ENV['REDIS_URL'])
  end
end
  • 注释解析:
    • SESSION_PREFIX: 我们为每个登录会话(或设备)创建一个唯一的session_id。所有属于该会话的Refresh Token都共享这个ID,形成一个“令牌家族”。
    • rotate: 这是核心方法。它使用Redis MULTI事务来原子性地获取并删除旧令牌。如果获取到的user_id"used",则证明发生了重用,立即调用invalidate_session销毁整个家族。否则,它会留下一个短暂的“墓碑”记录(将旧令牌的值设为”used”),然后创建新的令牌。

4. GraphQL Mutations

我们需要loginrefreshToken两个mutation。

# app/graphql/mutations/login.rb
module Mutations
  class Login < BaseMutation
    argument :email, String, required: true
    argument :password, String, required: true

    field :access_token, String, null: true
    field :errors, [String], null: false

    def resolve(email:, password:)
      user = User.find_by(email: email)

      if user&.authenticate(password)
        refresh_token_data = RefreshTokenService.create(user.id)
        
        # Set refresh token in httpOnly cookie
        context[:response].set_cookie(
          :refresh_token,
          value: {
            token: refresh_token_data[:token],
            session_id: refresh_token_data[:session_id]
          }.to_json,
          httponly: true,
          secure: Rails.env.production?,
          expires: RefreshTokenService::TOKEN_EXPIRATION.from_now
        )
        
        access_token = JsonWebTokenService.encode({ user_id: user.id }, 15.minutes.from_now)
        { access_token: access_token, errors: [] }
      else
        { access_token: nil, errors: ['Invalid email or password'] }
      end
    end
  end
end

# app/graphql/mutations/refresh_token.rb
module Mutations
  class RefreshToken < BaseMutation
    field :access_token, String, null: true
    field :errors, [String], null: false

    def resolve
      cookie = context[:request].cookies[:refresh_token]
      raise GraphQL::ExecutionError, "No refresh token found" unless cookie
      
      begin
        parsed_cookie = JSON.parse(cookie)
        old_token = parsed_cookie["token"]
        session_id = parsed_cookie["session_id"]
      rescue JSON::ParserError
        raise GraphQL::ExecutionError, "Invalid refresh token format"
      end

      result = RefreshTokenService.rotate(old_token, session_id)
      
      if result == :reused
        # Clear the cookie on client side
        context[:response].delete_cookie(:refresh_token)
        raise GraphQL::ExecutionError, "Token reuse detected. Session terminated."
      elsif result.nil?
        context[:response].delete_cookie(:refresh_token)
        raise GraphQL::ExecutionError, "Invalid or expired refresh token."
      end

      # Successful rotation
      new_token_data = result
      context[:response].set_cookie(
        :refresh_token,
        value: {
            token: new_token_data[:token],
            session_id: new_token_data[:session_id]
        }.to_json,
        # ... cookie options
      )

      user_id = JsonWebTokenService.decode(
        JsonWebTokenService.encode({ temp: true }) # A bit of a hack to get user_id
      ) do |payload|
          # This is tricky because the new token was created from the user_id stored in redis
          # A better approach is to have rotate return the user_id as well.
          # For now, let's assume rotate service is modified to return { token:, session_id:, user_id: }
          # user_id = new_token_data[:user_id]
      end
      
      # Let's assume RefreshTokenService.rotate is modified to return user_id
      # new_token_data = { token:, session_id:, user_id: }
      # For simplicity in this example:
      user_record = # fetch user from the user_id returned by rotate
      access_token = JsonWebTokenService.encode({ user_id: user_record.id }, 15.minutes.from_now)

      { access_token: access_token, errors: [] }
    end
  end
end

核心实现:前端 (Vue, Pinia, Apollo)

前端的复杂性在于无缝地处理令牌刷新流程,尤其是在多个并发请求同时失败的情况下。

1. Pinia状态管理

创建一个authStore来统一管理认证状态。

// stores/auth.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useAuthStore = defineStore('auth', () => {
  const accessToken = ref(null);
  const user = ref(null);
  // A flag to prevent concurrent refresh requests
  const isRefreshing = ref(false);

  const isAuthenticated = computed(() => !!accessToken.value);

  function setAuth({ token, userData }) {
    accessToken.value = token;
    user.value = userData;
  }

  function clearAuth() {
    accessToken.value = null;
    user.value = null;
  }

  function setAccessToken(token) {
    accessToken.value = token;
  }

  function setRefreshing(status) {
    isRefreshing.value = status;
  }

  return {
    accessToken,
    user,
    isRefreshing,
    isAuthenticated,
    setAuth,
    clearAuth,
    setAccessToken,
    setRefreshing,
  };
});

2. Apollo Client与中间件配置

这是前端逻辑的核心。我们将使用@apollo/client/link/error来捕获认证错误,并构建一个复杂的中间件来处理刷新逻辑。

// apollo-client.js
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client/core';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { useAuthStore } from './stores/auth';
import { gql } from 'graphql-tag';

const REFRESH_TOKEN_MUTATION = gql`
  mutation RefreshToken {
    refreshToken {
      accessToken
    }
  }
`;

// A simple in-memory queue for failed requests
let pendingRequests = [];
let isRefreshing = false;

const resolvePendingRequests = (newAccessToken) => {
  pendingRequests.forEach(p => p.resolve(newAccessToken));
  pendingRequests = [];
};

const rejectPendingRequests = (error) => {
  pendingRequests.forEach(p => p.reject(error));
  pendingRequests = [];
};

// Main function to handle token refresh logic
const refreshToken = async (apolloClient) => {
  const authStore = useAuthStore();
  
  // If a refresh is already in progress, queue subsequent requests
  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      pendingRequests.push({ resolve, reject });
    });
  }

  isRefreshing = true;
  authStore.setRefreshing(true);

  try {
    const { data } = await apolloClient.mutate({
      mutation: REFRESH_TOKEN_MUTATION,
    });

    if (!data?.refreshToken?.accessToken) {
      throw new Error('Failed to refresh token');
    }

    const newAccessToken = data.refreshToken.accessToken;
    authStore.setAccessToken(newAccessToken);
    
    // Retry all queued requests with the new token
    resolvePendingRequests(newAccessToken);
    
    return newAccessToken;
  } catch (error) {
    console.error('Token refresh error:', error);
    authStore.clearAuth();
    // Reject all queued requests
    rejectPendingRequests(error);
    // Redirect to login or handle logout
    window.location.href = '/login';
    return Promise.reject(error);
  } finally {
    isRefreshing = false;
    authStore.setRefreshing(false);
  }
};


export function createApolloClient() {
  const httpLink = createHttpLink({ uri: '/graphql' });

  const authLink = setContext((_, { headers }) => {
    const authStore = useAuthStore();
    const token = authStore.accessToken;
    return {
      headers: {
        ...headers,
        authorization: token ? `Bearer ${token}` : '',
      },
    };
  });
  
  const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        // Check for specific authentication error code from the server
        if (err.extensions?.code === 'TOKEN_EXPIRED' || err.message.includes("Token expired")) {
          // Do not retry the refresh mutation itself
          if (operation.operationName === 'RefreshToken') {
            const authStore = useAuthStore();
            authStore.clearAuth();
            window.location.href = '/login';
            return;
          }

          return new Observable(observer => {
            refreshToken(apolloClient)
              .then(newAccessToken => {
                // Modify the operation headers with the new token
                operation.setContext(({ headers = {} }) => ({
                  headers: {
                    ...headers,
                    authorization: `Bearer ${newAccessToken}`,
                  },
                }));

                // Retry the failed request
                const subscriber = {
                  next: observer.next.bind(observer),
                  error: observer.error.bind(observer),
                  complete: observer.complete.bind(observer),
                };
                forward(operation).subscribe(subscriber);
              })
              .catch(error => {
                observer.error(error);
              });
          });
        }
      }
    }

    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
    }
  });

  const apolloClient = new ApolloClient({
    link: from([errorLink, authLink, httpLink]),
    cache: new InMemoryCache(),
  });

  return apolloClient;
}
  • 代码剖析:
    • authLink: 一个标准的 Apollo中间件,它从Pinia store中读取accessToken并将其注入到每个GraphQL请求的Authorization头中。
    • errorLink: 这是最复杂的部分。它拦截所有GraphQL响应。如果发现错误信息中包含令牌过期的标识(例如,err.extensions.code === 'TOKEN_EXPIRED'),它会触发刷新逻辑。
    • 并发处理: 当多个组件同时发出API请求,并且令牌恰好过期时,这些请求会几乎同时失败。如果不加处理,会触发多次refreshToken调用。这里的解决方案是使用一个全局标志isRefreshing(或authStore.isRefreshing)和一个pendingRequests队列。第一个失败的请求会将标志设为true并发起刷新。后续失败的请求看到标志为true时,不会发起新的刷新,而是将自己的resolvereject函数推入队列,等待刷新结果。
    • 请求重试: 一旦refreshToken成功获取新令牌,它会遍历pendingRequests队列,用新令牌解决所有等待的Promise。errorLink中,forward(operation)被用来以更新后的Authorization头重试最初失败的请求。
    • 刷新失败: 如果refreshToken调用本身失败(例如,Refresh Token无效或被重用),系统会清空所有认证状态,拒绝所有等待的请求,并强制用户重定向到登录页面。

架构的局限性与未来展望

这套架构虽然显著提升了安全性,但并非没有缺点。它的主要局限性在于实现复杂性,尤其是在前端的并发控制和错误处理上,这要求开发团队有较高的技术水平。此外,该方案依赖于客户端和服务器之间相对稳定的网络连接。在极端高延迟或频繁断网的环境下,客户端可能无法正确接收到轮换后的新Refresh Token,导致合法用户被误判为令牌重用而被登出。

未来的优化路径可以包括:

  1. 更优雅的竞态条件处理:可以在客户端引入一个短暂的宽限期(grace period),允许在网络抖动时使用旧的Refresh Token进行重试,但这会略微增加安全风险,需要仔细权衡。
  2. 设备指纹集成:在颁发和验证Refresh Token时,可以绑定设备指纹信息。即使Refresh Token被盗,攻击者在不同的设备上也无法使用,进一步加固了防线。
  3. 服务化与抽象化:可以将前端的errorLink刷新逻辑封装成一个独立的、可复用的包,降低在多个项目中实施的成本。后端也可以将令牌管理逻辑抽象成一个独立的微服务,专门负责身份认证,与核心业务解耦。

最终,没有任何架构是绝对安全的银弹。选择Refresh Token轮换机制,是在承认风险永远存在的前提下,将潜在的、持久的威胁,转变为一个可被快速检测、即时响应、并能将损失降到最低的瞬时事件。这正是零信任安全模型在实践中的具体体现。


  目录