# VSRA OAuth 2.0 联合登录设计方案

> 版本：v1.1
> 创建日期：2025-01-30
> 最后更新：2025-01-30

## 目录

1. [方案概述](#1-方案概述)
2. [核心流程](#2-核心流程)
3. [权限等级设计（Scope）](#3-权限等级设计scope)
4. [数据库设计](#4-数据库设计)
5. [API 端点设计](#5-api-端点设计)
6. [敏感数据加密接口](#6-敏感数据加密接口)
7. [详细流程说明](#7-详细流程说明)
8. [安全机制](#8-安全机制)
9. [代码结构](#9-代码结构)
10. [配置说明](#10-配置说明)
11. [使用指南](#11-使用指南)
12. [登录页与授权页设计](#12-登录页与授权页设计)
13. [后续迭代计划](#13-后续迭代计划)

---

## 1. 方案概述

### 1.1 背景

VSRA 作为 Visa Select Rewards App 的核心平台，需要支持第三方应用（如 GHA DISCOVERY 酒店会员系统）通过 OAuth 2.0 协议访问用户数据。本方案设计了一套标准的 OAuth 2.0 Authorization Code Grant 流程，VSRA 同时作为授权服务器（Authorization Server）和资源服务器（Resource Server）。

### 1.2 应用场景

- 用户在第三方应用（如 GHA）点击"使用 VSRA 账号登录"
- 跳转到 VSRA 授权页面，用户登录并授权
- 第三方应用获取用户的 openid、资料、卡片信息、会员信息等
- 第三方应用可查询用户对特定权益的资格

### 1.3 设计原则

- **标准化**：遵循 OAuth 2.0 RFC 6749 规范
- **安全性**：多层安全防护，包括 HTTPS、state 防 CSRF、token 签名等
- **简洁性**：采用递进式权限等级，简化权限管理
- **独立性**：OAuth 登录页独立于现有 H5/小程序登录系统

---

## 2. 核心流程

### 2.1 整体流程图

```
┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  第三方前端  │     │  VSRA 后端   │     │  VSRA 前端  │     │  第三方后端  │
│  (Partner)  │     │  (OAuth)    │     │  (OAuth页面) │     │  (Partner)  │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │                   │
       │ 1. 重定向到授权页面 │                   │                   │
       │ ─────────────────────────────────────>│                   │
       │ GET /oauth/authorize                  │                   │
       │   ?client_id=xxx                      │                   │
       │   &redirect_uri=xxx                   │                   │
       │   &scope=card                         │                   │
       │   &state=随机串                        │                   │
       │                   │                   │                   │
       │                   │                   │ 2. 显示登录页面     │
       │                   │                   │    （如未登录）     │
       │                   │                   │                   │
       │                   │                   │ 3. 用户输入手机号   │
       │                   │                   │    获取 OTP 登录   │
       │                   │                   │                   │
       │                   │<──────────────────│ 4. 验证 OTP        │
       │                   │                   │                   │
       │                   │                   │ 5. 显示授权确认页   │
       │                   │                   │   （如需用户确认）  │
       │                   │                   │                   │
       │                   │<──────────────────│ 6. 用户点击同意     │
       │                   │                   │                   │
       │ 7. 重定向回第三方   │                   │                   │
       │<──────────────────│                   │                   │
       │   redirect_uri                        │                   │
       │   ?code=AUTH_CODE                     │                   │
       │   &state=原样返回                      │                   │
       │                   │                   │                   │
       │ 8. CODE 传给后端   │                   │                   │
       │ ─────────────────────────────────────────────────────────>│
       │                   │                   │                   │
       │                   │ 9. CODE 换 Token   │                   │
       │                   │<──────────────────────────────────────│
       │                   │ POST /oauth/token                     │
       │                   │   grant_type=authorization_code       │
       │                   │   code=AUTH_CODE                      │
       │                   │   client_id & client_secret           │
       │                   │                   │                   │
       │                   │ 10. 返回 Token     │                   │
       │                   │ ──────────────────────────────────────>│
       │                   │   access_token                        │
       │                   │   refresh_token                       │
       │                   │   openid                              │
       │                   │                   │                   │
       │                   │ 11. 用 Token 调 API │                  │
       │                   │<──────────────────────────────────────│
       │                   │ GET /oauth/api/profile                │
       │                   │ Authorization: Bearer {access_token}  │
       │                   │                   │                   │
```

### 2.2 关键组件

| 组件 | 说明 |
|-----|------|
| **OAuth Client** | 第三方应用，预先注册获得 client_id 和 client_secret |
| **Authorization Code** | 一次性授权码，有效期 5 分钟 |
| **Access Token** | 访问令牌，有效期 2 小时 |
| **Refresh Token** | 刷新令牌，有效期 30 天 |
| **Scope** | 权限范围（base/profile/card/eligibility） |
| **OpenID** | 用户在特定应用的唯一标识（uid 的哈希，每个应用不同） |

---

## 3. 权限等级设计（Scope）

### 3.1 递进式权限

采用递进式权限设计，高级别 scope 自动包含低级别的所有权限：

```
base < profile < card < eligibility
```

| Scope | 级别 | 授权方式 | 包含数据 |
|-------|-----|---------|---------|
| `base` | 1 | 静默授权 | openid（uid 哈希） |
| `profile` | 2 | 需用户授权 | base + 昵称、头像、联系方式 |
| `card` | 3 | 需用户授权 | profile + 卡片列表、会员信息 |
| `eligibility` | 4 | 需用户授权 | card + 权益资格查询 |

### 3.2 静默授权

- 当 `scope=base` 时，用户登录后直接生成 CODE 并重定向，无需确认授权
- 第三方应用仅获得 openid，无法获取任何用户资料

### 3.3 用户授权

- 当 `scope > base` 时，需要显示授权确认页
- 用户可以看到第三方应用请求的权限列表
- 用户已授权过相同或更高级别 scope 时，自动跳过确认

---

## 4. 数据库设计

### 4.1 oauth_clients（第三方应用）

```sql
CREATE TABLE oauth_clients (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    client_id VARCHAR(64) NOT NULL UNIQUE,          -- 应用ID（如 cli_xxx）
    client_secret VARCHAR(255) NOT NULL,            -- 应用密钥（SHA256 哈希存储）
    name VARCHAR(100) NOT NULL,                     -- 应用名称
    logo_url VARCHAR(500) NULL,                     -- 应用Logo
    redirect_uris JSON NOT NULL,                    -- 允许的回调地址列表
    allowed_scope VARCHAR(50) DEFAULT 'base',       -- 允许申请的最高scope
    is_active TINYINT(1) DEFAULT 1,                 -- 是否启用
    description TEXT NULL,                          -- 应用描述
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);
```

### 4.2 oauth_authorization_codes（授权码）

```sql
CREATE TABLE oauth_authorization_codes (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    code VARCHAR(128) NOT NULL UNIQUE,              -- 授权码（64位随机字符串）
    client_id VARCHAR(64) NOT NULL,                 -- 应用ID
    uid VARCHAR(64) NOT NULL,                       -- 用户ID
    scope VARCHAR(50) NOT NULL,                     -- 授权的scope
    redirect_uri VARCHAR(500) NOT NULL,             -- 回调地址
    expires_at TIMESTAMP NOT NULL,                  -- 过期时间（5分钟）
    used_at TIMESTAMP NULL,                         -- 使用时间（防重放）
    created_at TIMESTAMP
);
```

### 4.3 oauth_access_tokens（访问令牌）

```sql
CREATE TABLE oauth_access_tokens (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    access_token VARCHAR(128) NOT NULL UNIQUE,      -- 访问令牌（64位随机字符串）
    client_id VARCHAR(64) NOT NULL,                 -- 应用ID
    uid VARCHAR(64) NOT NULL,                       -- 用户ID
    scope VARCHAR(50) NOT NULL,                     -- 授权的scope
    expires_at TIMESTAMP NOT NULL,                  -- 过期时间（2小时）
    revoked_at TIMESTAMP NULL,                      -- 撤销时间
    created_at TIMESTAMP
);
```

### 4.4 oauth_refresh_tokens（刷新令牌）

```sql
CREATE TABLE oauth_refresh_tokens (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    refresh_token VARCHAR(128) NOT NULL UNIQUE,     -- 刷新令牌
    access_token_id BIGINT UNSIGNED NOT NULL,       -- 关联的 access_token
    expires_at TIMESTAMP NOT NULL,                  -- 过期时间（30天）
    revoked_at TIMESTAMP NULL,                      -- 撤销时间
    created_at TIMESTAMP
);
```

### 4.5 oauth_user_consents（用户授权记录）

```sql
CREATE TABLE oauth_user_consents (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    uid VARCHAR(64) NOT NULL,                       -- 用户ID
    client_id VARCHAR(64) NOT NULL,                 -- 应用ID
    scope VARCHAR(50) NOT NULL,                     -- 已授权的最高scope
    created_at TIMESTAMP,
    updated_at TIMESTAMP,

    UNIQUE KEY (uid, client_id)
);
```

---

## 5. API 端点设计

### 5.1 OAuth 端点

| 端点 | 方法 | 说明 | 需要 CSRF |
|-----|------|------|----------|
| `/oauth/authorize` | GET | 授权页面入口 | 是 |
| `/oauth/authorize/consent` | POST | 用户确认授权 | 是 |
| `/oauth/send-otp` | POST | 发送短信验证码 | 是 |
| `/oauth/login` | POST | 登录 | 是 |
| `/oauth/switch-account` | POST | 切换账号 | 是 |
| `/oauth/token` | POST | CODE 换 Token / 刷新 Token | 否 |
| `/oauth/revoke` | POST | 撤销 Token | 否 |

### 5.2 资源 API

| 端点 | 方法 | 所需 Scope | 说明 |
|-----|------|-----------|------|
| `/oauth/api/userinfo` | GET | base | 获取 openid |
| `/oauth/api/profile` | GET | profile | 获取用户资料（脱敏） |
| `/oauth/api/cards` | GET | card | 获取卡片列表（脱敏） |
| `/oauth/api/memberships` | GET | card | 获取会员信息 |
| `/oauth/api/eligibility/{oid}` | GET | eligibility | 检查权益资格 |

### 5.3 加密资源 API（敏感数据）

需要配置第三方 RSA 公钥才能使用：

| 端点 | 方法 | 所需 Scope | 说明 |
|-----|------|-----------|------|
| `/oauth/api/profile/sensitive` | GET | profile | 获取用户资料（包含加密的完整手机号） |
| `/oauth/api/cards/sensitive` | GET | card | 获取卡片列表（包含加密的完整卡号） |

---

## 6. 敏感数据加密接口

### 6.1 设计背景

某些业务场景需要获取用户的完整敏感数据（如完整手机号、完整卡号），为确保安全，这些数据需要加密传输。

### 6.2 加密方案

**加密流程：**

```
1. 第三方注册时提供 RSA 公钥（2048-bit 以上）
2. 第三方请求加密接口
3. VSRA 用第三方的公钥加密敏感数据
4. 第三方用自己的私钥解密
```

**加密算法：**

| 项目 | 配置 |
|-----|------|
| 算法 | RSA-OAEP |
| 哈希 | SHA-256 |
| MGF | MGF1-SHA256 |
| 密钥长度 | 2048-bit 以上 |

### 6.3 数据库变更

`oauth_clients` 表新增字段：

```sql
ALTER TABLE oauth_clients ADD COLUMN rsa_public_key TEXT NULL
    COMMENT '第三方RSA公钥(PEM格式)，用于加密敏感数据';
```

### 6.4 接口对比

| 类型 | 普通接口 | 加密接口 |
|-----|---------|---------|
| 手机号 | `138****1234` | 完整手机号（加密） |
| 卡号 | `last_4: "1234"` | 完整卡号（加密） |
| RSA 公钥 | 不需要 | 必须配置 |

### 6.5 响应格式

加密接口在返回脱敏数据的同时，额外返回加密的敏感数据：

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_xxx",
        "nickname": "张三",
        "mobile_masked": "138****1234",
        "sensitive": "Base64编码的RSA加密数据",
        "encryption": {
            "algorithm": "RSA-OAEP",
            "hash": "SHA256",
            "mgf": "MGF1-SHA256"
        }
    }
}
```

**sensitive 字段解密后的内容：**

```json
{
    "mobile": "13812345678",
    "email": "user@example.com"
}
```

### 6.6 配置 RSA 公钥

使用命令行工具配置：

```bash
# 设置 RSA 公钥
php artisan oauth:client set-public-key --id=cli_xxx --key-file=/path/to/public_key.pem

# 查看客户端信息（含公钥状态）
php artisan oauth:client show --id=cli_xxx
```

---

## 7. 详细流程说明

### 7.1 授权请求 GET /oauth/authorize

**请求参数：**

| 参数 | 必填 | 说明 |
|-----|------|------|
| client_id | 是 | 应用ID |
| redirect_uri | 是 | 回调地址，需在白名单内 |
| scope | 是 | 权限范围：base/profile/card/eligibility |
| state | 是 | 随机串，防 CSRF，原样返回 |
| response_type | 否 | 固定为 `code`，可省略 |

**成功回调：**
```
{redirect_uri}?code=AUTH_CODE&state=xxx
```

**失败回调：**
```
{redirect_uri}?error=access_denied&error_description=用户拒绝授权&state=xxx
```

**错误码：**

| error | 说明 |
|-------|------|
| invalid_request | 参数错误 |
| invalid_client | client_id 无效 |
| invalid_scope | scope 超出允许范围 |
| access_denied | 用户拒绝授权 |
| unauthorized_client | redirect_uri 不在白名单 |

### 7.2 Token 请求 POST /oauth/token

#### 7.2.1 授权码换 Token

**请求：**
```json
{
    "grant_type": "authorization_code",
    "code": "AUTH_CODE",
    "client_id": "cli_xxx",
    "client_secret": "sec_xxx",
    "redirect_uri": "https://partner.com/callback"
}
```

**成功响应：**
```json
{
    "access_token": "随机64位字符串",
    "token_type": "Bearer",
    "expires_in": 7200,
    "refresh_token": "随机64位字符串",
    "scope": "card",
    "openid": "o_abc123def456"
}
```

#### 7.2.2 刷新 Token

**请求：**
```json
{
    "grant_type": "refresh_token",
    "refresh_token": "xxx",
    "client_id": "cli_xxx",
    "client_secret": "sec_xxx"
}
```

**响应格式同上。**

**错误响应：**
```json
{
    "error": "invalid_grant",
    "error_description": "授权码已过期或无效"
}
```

### 7.3 资源 API 响应

#### 7.3.1 GET /oauth/api/userinfo

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456"
    }
}
```

#### 7.3.2 GET /oauth/api/profile

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "nickname": "张三",
        "avatar": "https://cdn.vsra.com/avatar/xxx.jpg",
        "mobile": "138****1234",
        "email": "z***n@example.com"
    }
}
```

#### 7.3.3 GET /oauth/api/cards

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "cards": [
            {
                "card_id": "card_xxx",
                "last_4": "1234",
                "product_level": "Visa Infinite",
                "product_level_display": "无限卡",
                "product_name": "Visa Infinite Card",
                "issuer_name": "中国银行",
                "issuer_code": "BOC"
            }
        ]
    }
}
```

#### 7.3.4 GET /oauth/api/memberships

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "memberships": [
            {
                "partner_code": "gha",
                "partner_name": "GHA DISCOVERY",
                "level_id": "titanium",
                "level_name": "钛金会员",
                "membership_number": "GHA123456",
                "status": "active"
            }
        ]
    }
}
```

#### 7.3.5 GET /oauth/api/eligibility/{oid}

响应结构与现有 `/v1/benefit/{oid}` 接口的 `eligibleBenefit` 字段保持一致：

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "oid": "offer_gha_discovery",
        "eligibleBenefit": {
            "status": "eligible",
            "message": null,
            "has_membership": true,
            "eligibility": {
                "type": "card_level",
                "current_value": "Visa Infinite",
                "current_display": "无限卡",
                "qualifying_cards": [
                    {
                        "last_4": "1234",
                        "issuer_name": "中国银行",
                        "product_level": "Visa Infinite",
                        "product_level_display": "无限卡",
                        "is_highest": true
                    }
                ]
            },
            "matched_level": {
                "id": "titanium",
                "name": "钛金会员",
                "name_en": "Titanium Member",
                "icon": "https://...",
                "benefits": ["铂金会员权益", "行政酒廊使用权", "早餐礼遇", "专属礼宾服务"]
            },
            "action": {
                "type": "external_link",
                "url": "https://ghaloyalty.com/member/login?partnerHash=...",
                "text": "前往激活会员",
                "membership_number": "xxx"
            },
            "upgrade": null,
            "all_levels": [...]
        }
    }
}
```

---

## 8. 安全机制

### 8.1 传输安全

| 机制 | 说明 |
|-----|------|
| **HTTPS** | 所有端点强制 HTTPS |
| **state 参数** | 必填，防止 CSRF 攻击，原样返回给第三方 |

### 8.2 授权码安全

| 机制 | 说明 |
|-----|------|
| **一次性使用** | CODE 使用后立即标记 used_at，防重放 |
| **短有效期** | CODE 有效期仅 5 分钟 |
| **redirect_uri 验证** | 严格匹配预注册的回调地址 |

### 8.3 Token 安全

| 机制 | 说明 |
|-----|------|
| **client_secret 哈希存储** | 使用 SHA256 哈希存储 |
| **Token 随机生成** | 64 位随机字符串，无法预测 |
| **Token 可撤销** | 支持主动撤销 Token |

### 8.4 OpenID 安全

```php
// openid 生成算法
$openid = 'o_' . substr(hash_hmac('sha256', "{$uid}:{$client_id}", $secret), 0, 28);
```

- 每个应用的 openid 不同，无法跨应用追踪用户
- 使用 HMAC-SHA256 签名，无法反推 uid

### 8.5 登录安全

| 机制 | 说明 |
|-----|------|
| **OTP 有效期** | 5 分钟 |
| **发送频率限制** | 每小时最多 5 次 |
| **登录失败锁定** | 连续 5 次失败锁定 24 小时 |

---

## 9. 代码结构

```
app/
├── Http/
│   └── Controllers/
│       └── OAuth/
│           ├── AuthorizationController.php   # 授权页面、登录
│           ├── TokenController.php           # Token 端点
│           └── ResourceController.php        # 资源 API
├── Models/
│   └── OAuth/
│       ├── OAuthClient.php
│       ├── OAuthAuthorizationCode.php
│       ├── OAuthAccessToken.php
│       ├── OAuthRefreshToken.php
│       └── OAuthUserConsent.php
├── Services/
│   └── OAuth/
│       └── OAuthService.php                  # 核心业务逻辑
└── Console/
    └── Commands/
        └── OAuthClientManage.php             # Client 管理命令

config/
└── oauth.php                                 # OAuth 配置

database/
├── migrations/
│   └── 2025_01_30_100000_create_oauth_tables.php
└── seeders/
    └── OAuthClientSeeder.php

resources/
└── views/
    └── oauth/
        ├── layout.blade.php                  # 布局
        ├── login.blade.php                   # 登录页
        ├── authorize.blade.php               # 授权确认页
        └── error.blade.php                   # 错误页

routes/
└── web.php                                   # OAuth 路由
```

---

## 10. 配置说明

### 10.1 config/oauth.php

```php
return [
    // Token 有效期
    'authorization_code_ttl' => env('OAUTH_CODE_TTL', 300),           // 5分钟
    'access_token_ttl' => env('OAUTH_ACCESS_TOKEN_TTL', 7200),        // 2小时
    'refresh_token_ttl' => env('OAUTH_REFRESH_TOKEN_TTL', 2592000),   // 30天

    // OpenID 生成密钥
    'openid_secret' => env('OAUTH_OPENID_SECRET', env('APP_KEY')),

    // Scope 定义
    'scopes' => [
        'base' => [
            'name' => '基础标识',
            'description' => '获取您的唯一标识（openid）',
            'silent' => true,   // 静默授权
            'level' => 1,
        ],
        'profile' => [
            'name' => '个人资料',
            'description' => '获取您的昵称、头像、联系方式',
            'silent' => false,
            'level' => 2,
        ],
        'card' => [
            'name' => '卡片信息',
            'description' => '获取您的卡片列表和会员信息',
            'silent' => false,
            'level' => 3,
        ],
        'eligibility' => [
            'name' => '资格查询',
            'description' => '查询您的权益资格',
            'silent' => false,
            'level' => 4,
        ],
    ],

    // 登录配置
    'login' => [
        'otp_ttl' => 300,           // OTP 有效期
        'max_attempts' => 5,        // 最大尝试次数
        'lockout_duration' => 86400, // 锁定时长
    ],
];
```

### 10.2 环境变量

```env
# OAuth Token 有效期（可选，有默认值）
OAUTH_CODE_TTL=300
OAUTH_ACCESS_TOKEN_TTL=7200
OAUTH_REFRESH_TOKEN_TTL=2592000

# OpenID 生成密钥（可选，默认使用 APP_KEY）
OAUTH_OPENID_SECRET=your-secret-key
```

---

## 11. 使用指南

### 11.1 创建 OAuth Client

```bash
# 交互式创建
php artisan oauth:client create

# 指定参数创建
php artisan oauth:client create \
  --name="GHA DISCOVERY" \
  --redirect="https://gha.example.com/callback,http://localhost:3000/callback" \
  --scope=eligibility \
  --logo="https://example.com/logo.png" \
  --description="GHA 酒店会员权益集成"
```

**输出示例：**
```
=== OAuth Client Created Successfully ===

+---------------+------------------------------------------+
| Field         | Value                                    |
+---------------+------------------------------------------+
| Client ID     | cli_abc123...                            |
| Client Secret | sec_xyz789...                            |
| Name          | GHA DISCOVERY                            |
| Allowed Scope | eligibility                              |
| Redirect URIs | https://gha.example.com/callback         |
|               | http://localhost:3000/callback           |
+---------------+------------------------------------------+

⚠️  请妥善保存 Client Secret，它只会显示一次！
```

### 11.2 管理 Client

```bash
# 列出所有 Client
php artisan oauth:client list

# 查看详情
php artisan oauth:client show --id=cli_xxx

# 禁用
php artisan oauth:client disable --id=cli_xxx

# 启用
php artisan oauth:client enable --id=cli_xxx
```

### 11.3 第三方接入示例

#### Step 1: 发起授权请求

```javascript
// 前端跳转到授权页面
const authUrl = new URL('https://api.vsra.com/oauth/authorize');
authUrl.searchParams.set('client_id', 'cli_xxx');
authUrl.searchParams.set('redirect_uri', 'https://partner.com/callback');
authUrl.searchParams.set('scope', 'card');
authUrl.searchParams.set('state', generateRandomState()); // 随机字符串，存到 session

window.location.href = authUrl.toString();
```

#### Step 2: 处理回调

```javascript
// 回调页面
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');

// 验证 state 与发起时一致
if (state !== sessionStorage.getItem('oauth_state')) {
    throw new Error('State mismatch');
}

// 将 code 发送给后端
fetch('/api/oauth/callback', {
    method: 'POST',
    body: JSON.stringify({ code })
});
```

#### Step 3: 后端换取 Token

```php
// 第三方后端
$response = Http::post('https://api.vsra.com/oauth/token', [
    'grant_type' => 'authorization_code',
    'code' => $code,
    'client_id' => config('vsra.client_id'),
    'client_secret' => config('vsra.client_secret'),
    'redirect_uri' => config('vsra.redirect_uri'),
]);

$data = $response->json();
// {
//     "access_token": "xxx",
//     "refresh_token": "xxx",
//     "expires_in": 7200,
//     "scope": "card",
//     "openid": "o_xxx"
// }

// 存储 token
cache()->put("vsra_token:{$data['openid']}", $data, $data['expires_in']);
```

#### Step 4: 调用资源 API

```php
// 获取用户卡片
$response = Http::withToken($accessToken)
    ->get('https://api.vsra.com/oauth/api/cards');

$cards = $response->json()['data']['cards'];
```

#### Step 5: 刷新 Token

```php
// 当 access_token 过期时
$response = Http::post('https://api.vsra.com/oauth/token', [
    'grant_type' => 'refresh_token',
    'refresh_token' => $refreshToken,
    'client_id' => config('vsra.client_id'),
    'client_secret' => config('vsra.client_secret'),
]);
```

---

## 12. 登录页与授权页设计

### 12.1 设计决策

**采用独立 OAuth 登录页，原因：**

1. **小程序 webview 兼容**：webview 无法调用微信授权，只能用 SMS OTP
2. **跨项目独立**：不依赖现有 H5 React 项目
3. **完全可控**：安全性和流程完全可控
4. **同域名**：登录态管理简单（Session）

### 12.2 登录方式

- **唯一方式**：SMS OTP（手机号 + 短信验证码）
- **支持地区**：+86（中国大陆）、+852（香港）、+853（澳门）、+886（台湾）

### 12.3 页面流程

```
GET /oauth/authorize
       │
       ├─ 未登录 → 显示登录页（login.blade.php）
       │           ├─ 输入手机号
       │           ├─ 获取验证码（POST /oauth/send-otp）
       │           └─ 验证登录（POST /oauth/login）
       │                   │
       │                   └─ 登录成功 → 刷新页面
       │
       └─ 已登录 → 检查 scope
                   │
                   ├─ scope=base（静默）→ 直接重定向
                   │
                   ├─ 已授权过 → 直接重定向
                   │
                   └─ 需授权 → 显示授权页（authorize.blade.php）
                               ├─ 同意 → 重定向 + CODE
                               └─ 拒绝 → 重定向 + error
```

### 12.4 UI 参考

**登录页：**
```
┌────────────────────────────────────────┐
│              [Visa Logo]               │
├────────────────────────────────────────┤
│  [第三方应用 Logo]                      │
│  GHA DISCOVERY                         │
│  请求访问您的账号                        │
├────────────────────────────────────────┤
│  手机号登录                             │
│                                        │
│  手机号码                               │
│  [+86 ▼] [请输入手机号          ]       │
│                                        │
│  验证码                                 │
│  [请输入验证码    ] [获取验证码]         │
│                                        │
│  [        登录并授权        ]           │
└────────────────────────────────────────┘
```

**授权确认页：**
```
┌────────────────────────────────────────┐
│              [Visa Logo]               │
├────────────────────────────────────────┤
│  [第三方应用 Logo]                      │
│  GHA DISCOVERY                         │
│  请求获取以下权限                        │
├────────────────────────────────────────┤
│  该应用将获得以下权限：                   │
│                                        │
│  ✓ 个人资料                             │
│    获取您的昵称、头像、联系方式            │
│                                        │
│  ✓ 卡片信息                             │
│    获取您的卡片列表和会员信息             │
├────────────────────────────────────────┤
│  [头像] 张三                            │
│         138****1234    [切换账号]       │
├────────────────────────────────────────┤
│  [  拒绝  ]        [  同意授权  ]        │
└────────────────────────────────────────┘
```

---

## 13. 后续迭代计划

### 13.1 Phase 2：登录态共享（可选）

**目标**：H5 已登录用户访问 OAuth 页面时，无需重新登录

**方案**：
1. H5 登录时，额外设置 httpOnly Cookie（domain: .vsra.com）
2. OAuth 页面检查该 Cookie
3. 如有效，直接使用已登录用户

**前提**：
- OAuth 页面和 H5 前端在同一主域下
- 需要修改 H5 登录逻辑

### 13.2 Phase 3：小程序快捷通道（可选）

**目标**：小程序用户在 webview 中无需重新登录

**方案**：
1. 小程序生成一次性 session_ticket（存 Redis，5分钟有效）
2. 打开 webview 时带上 session_ticket
3. OAuth 页面验证 ticket，直接认定为已登录

```
小程序内 → 用户已登录 → 生成 session_ticket
         → 打开 webview: /oauth/authorize?...&session_ticket=xxx

OAuth 页面 → 验证 ticket → 跳过登录步骤
```

### 13.3 Phase 4：更多资源 API

根据业务需求，可扩展以下 API：

| API | Scope | 说明 |
|-----|-------|------|
| `/oauth/api/activities` | card | 用户活动报名记录 |
| `/oauth/api/rewards` | card | 用户奖励记录 |
| `/oauth/api/notifications` | profile | 用户通知 |

### 13.4 Phase 5：管理后台

- OAuth Client 管理界面
- Token 统计和监控
- 用户授权记录查看
- 异常登录告警

---

## 附录

### A. 错误码汇总

| error | HTTP Status | 说明 |
|-------|-------------|------|
| invalid_request | 400 | 参数缺失或格式错误 |
| invalid_client | 401 | client_id 或 client_secret 无效 |
| invalid_grant | 400 | 授权码无效、已过期或已使用 |
| invalid_scope | 400 | scope 无效或超出允许范围 |
| unauthorized_client | 401 | redirect_uri 不在白名单 |
| access_denied | 403 | 用户拒绝授权 |
| unsupported_grant_type | 400 | 不支持的 grant_type |
| unauthorized | 401 | Token 无效或已过期 |
| insufficient_scope | 403 | Token 权限不足 |

### B. 相关文档

- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749)
- [VSRA Eligibility 达标检查方案](./ELIGIBILITY_DESIGN.md)

---

*文档维护：VSRA 开发团队*
