返回文档首页

OAuth 接入指南


欢迎来到落雪咖啡屋 maimai DX 查分器的 OAuth 接入指南!

在这里,你将了解如何使用 OAuth 2.0 获取用户授权、调用查分器 API,以及通过 OpenID Connect(OIDC)完成用户登录和身份识别。

介绍

本查分器推出 OAuth 旨在替代个人 API 密钥,提供更安全、灵活的方式供开发者访问用户数据。

个人 API 密钥虽然可以访问用户数据,但是存在安全隐患:如果密钥泄露,其他人可以随意访问用户数据。而 OAuth 则提供了更细粒度的权限控制和更安全的授权流程。

开发者可以通过 OAuth 获取访问令牌(Access Token),并使用此令牌访问用户授权的数据。需要登录能力时,还可以请求 OIDC 权限并获取 ID Token,以安全地识别当前用户。用户可以随时撤销授权,确保数据安全。

接入步骤

接入前,请确保你已经申请并成为了开发者。申请时使用的开发者信息将会在 OAuth 授权页面中展示。

1. 创建应用

前往开发者面板创建一个新的 OAuth 应用。你需要提供以下信息:

  • 应用名称:你的应用名称,将在授权页面中显示。
  • 应用描述(可选):简要描述你的应用功能。
  • 应用图标(可选):上传一个应用图标,将在授权页面中显示。
  • 回调地址:授权完成后用户将被重定向到请求指定的地址。每个应用最多可以登记 10 个回调地址。
  • 应用权限:根据用途选择 API 授权或 OpenID Connect 身份认证所需的权限。

授权请求中的 redirect_uri 必须与已登记的某个回调地址完全一致。Web 应用应使用 HTTPS;本地开发可以使用 localhost127.0.0.1[::1] 的 HTTP 地址。

权限范围

应用权限分为两组:

分组权限用途
API 授权read_user_profile通过 API 读取用户信息
API 授权read_player读取玩家信息、谱面成绩和历史成绩
API 授权write_player更新玩家信息、上传或删除成绩
API 授权read_user_token读取个人 API 密钥,通常不建议申请
登录与身份openid验证用户身份并获取稳定的用户标识 sub
登录与身份profile获取用户名等基本资料,必须同时申请 openid
登录与身份email获取用户绑定的邮箱地址,必须同时申请 openid

2. 获取 OAuth 授权链接

创建应用后,你将获得一个 OAuth 授权链接。用户可以通过此链接授权你的应用访问其游戏数据。

链接格式如下:

https://maimai.lxns.net/oauth/authorize?response_type=code&client_id=[应用 ID]&redirect_uri=[回调地址]&scope=[应用权限]

常用参数如下:

参数名必填说明
response_type固定为 code
client_id创建应用后获得的应用 ID
redirect_uri本次授权使用的回调地址,必须与已登记地址完全一致
scope以空格分隔的权限列表
state推荐用于关联请求和回调,并防止跨站请求伪造
nonceOIDC 推荐绑定授权请求和 ID Token,防止重放攻击;最长 255 个字符
code_challenge使用 PKCE 时必填code_verifier 计算得到的挑战值
code_challenge_method使用 PKCE 时必填固定为 S256

仅调用查分器 API 的示例:

https://maimai.lxns.net/oauth/authorize?response_type=code&client_id=[应用 ID]&redirect_uri=[回调地址]&scope=read_player&state=[随机值]

使用查分器账号登录的示例:

https://maimai.lxns.net/oauth/authorize?response_type=code&client_id=[应用 ID]&redirect_uri=[回调地址]&scope=openid%20profile%20email&state=[随机值]&nonce=[随机值]

3. 用户授权

用户点击授权链接后,将被重定向到授权页面。在此页面,用户登录查分器账号后可以查看你的应用信息,并选择是否授权。

如果用户同意授权,将会被重定向到本次请求的回调地址,并附带一个授权码。如果应用配置为无回调地址,则会直接显示授权码(形如 JVJ6-VPTM-MGHZ)。回调中的 state 应与授权请求中发送的值完全一致。

4. 使用授权码获取访问令牌

在你的回调地址处理授权码后,你需要使用此授权码向 OAuth 服务器请求访问令牌。你可以使用以下 API 端点:

POST /api/v0/oauth/token

请求参数

参见访问令牌请求方式

响应体

字段名类型说明
access_tokenstring访问令牌,用于访问用户数据
token_typestring令牌类型,通常为 Bearer
expires_ininteger访问令牌的有效期,单位为秒
refresh_tokenstring刷新令牌,用于获取新的访问令牌
scopestring授权范围,表示应用可以访问的权限
id_tokenstringOIDC ID Token,仅在授权范围包含 openid 时返回

响应示例

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "SjiF1mnYY0qa1PEJhjeyDQPGPcBjWOKu",
  "scope": "openid profile email",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Im1haW1haS1wcm9iZXItb2lkYy0xIn0..."
}

错误响应

如果请求参数无效、授权码过期或其他错误,服务器将返回一个错误响应,包含 errorerror_description 字段。例如:

{
  "error": "invalid_grant",
  "error_description": "authorization code expired"
}

error 取值如下:

error说明
invalid_request缺少必需参数、参数无效或请求格式错误
invalid_client客户端不存在,或客户端认证(client_secret)失败
invalid_grant授权码或刷新令牌无效、已过期,或与客户端、回调地址不匹配
unsupported_grant_type不支持的 grant_type
server_error服务器内部错误

5. 使用访问令牌访问 API

使用获取到的访问令牌,你可以访问用户的游戏数据。你需要在请求头中添加 Authorization 字段,格式为 Bearer [access_token]

例如,获取用户的舞萌 DX 游戏数据:

GET /api/v0/user/maimai/player

其他接口可以参考 OAuth API 文档

6. 刷新访问令牌

如果访问令牌过期,你可以使用刷新令牌获取新的访问令牌。你需要向以下端点发送请求:

POST /api/v0/oauth/token

请求参数

参数名类型说明
client_idstring应用 ID
client_secretstring机密客户端的应用密钥;使用 PKCE 时可省略
grant_typestring授权类型,固定为 refresh_token
refresh_tokenstring从上一步获取的刷新令牌

请求示例

{
  "client_id": "e07f2ae3-795b-4368-b55f-5f27b0b3eae0",
  "client_secret": "fUluk5OJQ6OF8PGqGxs3TJ2zdZpwgDTs",
  "grant_type": "refresh_token",
  "refresh_token": "SjiF1mnYY0qa1PEJhjeyDQPGPcBjWOKu"
}

响应体

响应体与获取访问令牌时相同,将包含新的访问令牌和刷新令牌。

授权范围包含 openid 时,刷新令牌响应也会包含新的 id_token。刷新流程生成的 ID Token 不包含初次授权请求中的 nonce

使用 OpenID Connect

OIDC 使用与 OAuth 相同的授权码流程。客户端无需硬编码各个端点,可以从发现文档读取服务端元数据:

GET https://maimai.lxns.net/.well-known/openid-configuration

发现文档包含 issuerauthorization_endpointtoken_endpointuserinfo_endpointjwks_uri 等字段。OAuth 授权服务器元数据也可以从以下地址获取:

GET https://maimai.lxns.net/.well-known/oauth-authorization-server

验证 ID Token

ID Token 使用 RS256 签名。客户端应根据发现文档中的 jwks_uri 获取公钥,并至少完成以下校验:

  1. 使用 JWKS 中与 Token kid 对应的公钥验证签名和 RS256 算法。
  2. 确认 iss 与发现文档中的 issuer 完全一致。
  3. 确认 aud 包含当前应用的 client_id
  4. 确认 exp 尚未过期。
  5. 如果授权请求发送了 nonce,确认 Token 中的 nonce 与请求值一致。

sub 是当前用户在查分器中的稳定标识,客户端应使用 isssub 的组合作为外部账号标识,不要使用用户名或邮箱作为唯一标识。

获取用户信息

授权范围包含 openid 时,可以使用访问令牌调用 UserInfo 端点:

GET https://maimai.lxns.net/api/v0/oauth/userinfo
Authorization: Bearer [access_token]

响应中的声明由已授权权限决定:

权限返回的声明
openidsub
profilenamepreferred_username
emailemailemail_verified

响应示例:

{
  "sub": "12345",
  "name": "example-user",
  "preferred_username": "example-user",
  "email": "user@example.com",
  "email_verified": true
}

email_verified 会反映用户当前绑定邮箱的实际验证状态。用户尚未验证邮箱时,该字段为 false

访问令牌请求方式

授权码兑换支持应用密钥PKCE(Proof Key for Code Exchange)。公共客户端使用 PKCE,机密客户端使用应用密钥。授权请求带有 code_challenge 时,兑换授权码必须提交对应的 code_verifier;请求同时带有 client_secret 时,服务器会校验两项。未使用 PKCE 时,兑换授权码必须提交有效的 client_secret

应用密钥

机密客户端(如服务器端应用)可以使用应用密钥认证来获取访问令牌。此方式需要在请求中提供有效的 client_secret

请求参数

参数名类型说明
client_idstring应用 ID
client_secretstring应用密钥
grant_typestring授权类型,固定为 authorization_code
codestring从回调地址获取的授权码
redirect_uristring必须与授权请求及某个已登记的回调地址完全一致

请求示例

{
  "client_id": "e07f2ae3-795b-4368-b55f-5f27b0b3eae0",
  "client_secret": "fUluk5OJQ6OF8PGqGxs3TJ2zdZpwgDTs",
  "grant_type": "authorization_code",
  "code": "Oze6RZ0nPKy4JSmpI2aYxEIUmhl0l5fU",
  "redirect_uri": "http://localhost:5000/callback"
}

示例代码(Python)

以下是一个使用应用密钥获取访问令牌的示例代码,演示如何处理 OAuth 授权流程:

from flask import Flask, request, session
import requests
import urllib.parse
import secrets
import os

app = Flask(__name__)
# 使用 secrets.token_hex(32) 生成后写入环境变量
app.secret_key = os.environ["FLASK_SECRET_KEY"]

# 应用信息
CLIENT_ID = "e07f2ae3-795b-4368-b55f-5f27b0b3eae0"
CLIENT_SECRET = "fUluk5OJQ6OF8PGqGxs3TJ2zdZpwgDTs"
REDIRECT_URI = "http://localhost:5000/callback"

# OAuth 接口地址
AUTHORIZE_URL = "https://maimai.lxns.net/oauth/authorize"
TOKEN_URL = "https://maimai.lxns.net/api/v0/oauth/token"
PLAYER_API_URL = "https://maimai.lxns.net/api/v0/user/maimai/player"

@app.route("/")
def home():
    scope = ["read_player"]
    state = secrets.token_urlsafe(16)
    session["oauth_state"] = state
    query = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": " ".join(scope),
        "state": state
    }
    url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(query)}"
    return f'<a href="{url}">点击授权</a>'

@app.route("/callback")
def callback():
    code = request.args.get("code")
    if not code:
        return "授权失败,未获取到授权码", 400
    if request.args.get("state") != session.pop("oauth_state", None):
        return "授权失败,state 校验未通过", 400

    # 获取访问码
    resp = requests.post(TOKEN_URL, data={
        "grant_type": "authorization_code",
        "code": code,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "redirect_uri": REDIRECT_URI
    })
    token = resp.json()["access_token"]

    # 调用 API
    player = requests.get(PLAYER_API_URL, headers={
        "Authorization": f"Bearer {token}"
    }).json()

    return player

if __name__ == "__main__":
    app.run()

PKCE

PKCE(Proof Key for Code Exchange)是一种增强 OAuth 2.0 安全性的机制,特别适用于公共客户端(如移动应用、单页应用等),可以防止授权码被截获和重放攻击。

PKCE 通过在授权请求中添加一个随机生成的 code_verifiercode_challenge 来实现。以下是 PKCE 的基本流程:

  1. 生成 Code Verifier:客户端生成一个随机字符串,称为 code_verifier
  2. 生成 Code Challenge:客户端使用 code_verifier 生成一个 code_challenge,通常是通过 SHA-256 哈希算法。
  3. 发送授权请求:在授权请求中,客户端将 code_challengecode_challenge_method(通常为 S256)作为参数发送。
  4. 获取授权码:用户授权后,服务器将 code 返回给客户端。
  5. 交换访问令牌:客户端使用 code_verifiercode 向服务器请求访问令牌。

请求参数

参数名类型说明
client_idstring应用 ID
grant_typestring授权类型,固定为 authorization_code
codestring从回调地址获取的授权码
redirect_uristring必须与授权请求及某个已登记的回调地址完全一致
code_verifierstring生成授权请求时保存的原始验证字符串

请求示例

{
  "client_id": "e07f2ae3-795b-4368-b55f-5f27b0b3eae0",
  "grant_type": "authorization_code",
  "code": "Oze6RZ0nPKy4JSmpI2aYxEIUmhl0l5fU",
  "redirect_uri": "http://localhost:5000/callback",
  "code_verifier": "randomly_generated_code_verifier"
}

示例代码(Python)

以下是一个使用 PKCE 的示例代码,演示如何生成 code_verifiercode_challenge,并在授权请求中使用它们:

from flask import Flask, request, session
import requests
import urllib.parse
import secrets
import hashlib
import base64
import os

app = Flask(__name__)
# 使用 secrets.token_hex(32) 生成后写入环境变量
app.secret_key = os.environ["FLASK_SECRET_KEY"]

# 应用信息(公共客户端,无 secret)
CLIENT_ID = "e07f2ae3-795b-4368-b55f-5f27b0b3eae0"
REDIRECT_URI = "http://localhost:5000/callback"

# OAuth 接口地址
AUTHORIZE_URL = "https://maimai.lxns.net/oauth/authorize"
TOKEN_URL = "https://maimai.lxns.net/api/v0/oauth/token"
PLAYER_API_URL = "https://maimai.lxns.net/api/v0/user/maimai/player"

# 生成 code_verifier 和 code_challenge
def generate_code_verifier():
    return secrets.token_urlsafe(64)

def generate_code_challenge(verifier):
    digest = hashlib.sha256(verifier.encode()).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

@app.route("/")
def home():
    scope = ["read_player"]

    # 生成随机 code_verifier
    code_verifier = generate_code_verifier()
    code_challenge = generate_code_challenge(code_verifier)
    state = secrets.token_urlsafe(16)
    session["oauth_state"] = state
    session["code_verifier"] = code_verifier

    query = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": " ".join(scope),
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
        "state": state
    }
    url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(query)}"
    return f'<a href="{url}">点击授权</a>'

@app.route("/callback")
def callback():
    code = request.args.get("code")
    if not code:
        return "授权失败,未获取到授权码", 400
    if request.args.get("state") != session.pop("oauth_state", None):
        return "授权失败,state 校验未通过", 400
    code_verifier = session.pop("code_verifier", None)
    if not code_verifier:
        return "授权失败,code_verifier 不存在", 400

    # 用 code_verifier 换 token
    resp = requests.post(TOKEN_URL, data={
        "grant_type": "authorization_code",
        "code": code,
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "code_verifier": code_verifier
    })
    token_data = resp.json()
    access_token = token_data["access_token"]

    # 调用 API
    player = requests.get(PLAYER_API_URL, headers={
        "Authorization": f"Bearer {access_token}"
    }).json()

    return player

if __name__ == "__main__":
    app.run()

目录