通过Google和Facebook将社交登录添加到您的单页应用程序

SPA社交登录:通过Google和Facebook验证用户身份

我们越来越多地看到使用单页体系结构开发的Web应用程序,其中整个应用程序以JavaScript的形式加载到浏览器中,然后与所有与服务器的交互都使用基于HTTP的API(返回JSON文档)进行。 通常,这些应用程序将需要某种程度的用户限制的交互,例如,用于存储用户配置文件详细信息。 如果这是在传统的基于HTML的应用程序中实现的相对简单的任务,则在需要验证每个API请求的单页应用程序中则比较棘手。

本文将演示一种使用Passport.js库的技术,该技术使用各种提供程序来实现社交登录,并将其引入到以后的API调用的基于令牌的身份验证中。

可以从我们的GitHub存储库下载本文的所有源代码。

为什么要对SPA使用社交登录?

在Web应用程序上实现登录机制时,需要考虑许多问题。

  • 您的UI应该如何处理身份验证本身?
  • 您应该如何存储用户信息?
  • 您应该如何最好地保护用户凭据?

在着手编写登录门户之前,需要考虑这些以及更多问题。 但是,有更好的方法。

许多站点(主要是社交网络)都允许您使用它们的平台来对自己的应用程序进行身份验证。 这可以使用多种不同的API来实现-OAuth 1.0OAuth 2.0OpenIDOpenID Connect等。

使用这些社交登录技术来实现您的登录流程具有许多优势。

  • 您不再负责渲染用户界面以进行身份​​验证。
  • 您不再负责存储和保护敏感的用户详细信息。
  • 用户可以使用一个登录名来访问多个站点。
  • 如果用户认为自己的密码已被盗用,则可以将其重置一次,并在许多站点中受益。
  • 通常,提供身份验证功能的服务将提供其他详细信息。 例如,它可以用于自动注册以前从未使用过您网站的用户,或者允许您代表他们向其个人资料发布更新。

为什么要对API使用基于令牌的身份验证?

每当客户端需要访问您的API时,您将需要某种方法来确定他们是谁以及是否允许访问。 有几种方法可以实现此目的,但是主要的选择是:

  • 基于会话的身份验证
  • 基于Cookie的身份验证
  • 基于令牌的身份验证

基于会话的身份验证要求API服务采用某种方式将会话与客户端相关联。 这通常很容易设置,但是如果您要在多个服务器之间部署API,则可能会受到影响。 您还受服务器用于会话管理和到期的机制的支配,这可能无法控制。

基于Cookie的地方是,您只需在Cookie中存储一些标识符即可,该标识符用于自动识别API请求。 这意味着您首先需要某种机制来设置cookie,并且冒着在后续请求中泄漏它的风险,因为cookie会自动包含在对同一主机的所有(合适的)请求中。

基于令牌的是基于cookie的身份验证的一种变体,但是将更多控制权交给了您。 本质上,您生成令牌的方式与基于cookie的身份验证系统中的方式相同,但是您自己会在请求中包括令牌-通常在“授权”标头中,或者直接在URL中。 这意味着您完全可以控制令牌的存储,令牌将包含令牌,依此类推。

注意:即使HTTP标头被称为“授权”,我们实际上仍在使用它进行认证。 这是因为我们使用它来确定客户端是“谁”,而不是客户端被允许“做什么”。

用于生成令牌的策略也很重要。 这些令牌可以是参考令牌,这意味着它们不过是服务器用来查找真实详细信息的标识符。 或完整的令牌,这意味着令牌已包含所有需要的信息。

参考令牌具有显着的安全优势,因为绝对不会泄漏用户凭证的客户端。 但是,这会降低性能,因为您需要在每次发出单个请求时将令牌解析为实际的凭证。

完整的令牌则相反。 它们会将用户凭据公开给任何能够理解令牌的人,但是由于令牌是完整的,因此在查找令牌时不会降低性能。

通常,将使用JSON Web令牌标准来实现完全令牌,因为其中有一些余地可以提高令牌的安全性。 具体而言,JWT允许对令牌进行加密签名,这意味着您可以保证令牌未被篡改。 还规定对它们进行加密,这意味着没有加密密钥,令牌甚至无法被解码。

如果您想进一步了解如何在Node中使用JWT,请查看我们的教程: 在Node.js中使用JSON Web令牌

使用完整令牌的另一个缺点是大小。 例如,可以使用长度为36个字符的UUID来实现参考令牌。 相反,JWT可以很容易地长数百个字符。

在本文中,我们将使用JWT令牌来演示它们如何工作。 但是,当您自己实现此功能时,您将需要确定是要使用引用令牌还是完整令牌,以及将使用哪种机制。

什么是护照?

Passport是Node.js的一组模块,这些模块在Web应用程序中实现身份验证。 它非常容易地插入许多基于Node的Web服务器,并且采用模块化结构来实现您所需的登录机制,而不会造成太大的麻烦。

Passport是功能强大的模块套件,可满足各种身份验证要求。 使用这些,我们可以有一个可插拔的设置,允许对不同的端点使用不同的身份验证要求。 所使用的身份验证系统可以很简单,例如一直检查URL中的特殊值,直到完全依靠第三方提供商来为我们完成所有工作。

在本文中,我们将使用passport-google-oauthpassport-facebookpassport-jwt模块,使我们能够为API端点实现社交登录和基于JWT令牌的身份验证。

护照-jwt模块将用于要求某些终结点(我们需要身份验证才能访问的实际API终结点)在请求中包含有效的JWT。 Passport-google-oauth和passport-facebook接口模块将用于提供分别针对Google和Facebook进行身份验证的终结点,然后生成一个JWT,该JWT可用于访问应用程序中的其他终结点。

为单页应用程序实现社交登录

从这里开始,我们将逐步完成一个简单的单页应用程序并在其中实现社交登录。 该应用程序是使用Express编写的,提供了一个安全的和一个不安全的端点的简单API。 如果愿意,可以从https://github.com/sitepoint-editors/social-logins-spa检出此源代码。 可以通过在下载的源代码中执行npm install (下载所有依赖项),然后通过执行node src/index.jsnpm install来构建此应用程序。

为了成功使用该应用程序,您将需要向Google和Facebook注册社交登录凭据,并使该凭据可用于该应用程序。 演示应用程序的README文件中提供了完整说明。 这些作为环境变量访问。 因此,该应用程序可以按以下方式运行:

# Linux / OS X
$ export GOOGLE_CLIENTID=myGoogleClientId
$ export GOOGLE_CLIENTSECRET=myGoogleClientSecret
$ export FACEBOOK_CLIENTID=myFacebookClientId
$ export FACEBOOK_CLIENTSECRET=myFacebookClientSecret
$ node src/index.js
# Windows
> set GOOGLE_CLIENTID=myGoogleClientId
> set GOOGLE_CLIENTSECRET=myGoogleClientSecret
> set FACEBOOK_CLIENTID=myFacebookClientId
> set FACEBOOK_CLIENTSECRET=myFacebookClientSecret
> node src/index.js

该过程的最终结果是将令牌身份验证支持(使用JSON Web令牌)添加到我们的安全端点,然后添加社交登录支持(使用Google和Facebook)以获得令牌,以供应用程序的其余部分使用。 这意味着您需要向社交服务提供商进行一次身份验证,然后进行身份验证,然后将生成的JWT用于将来对应用程序的所有API调用。

JWT对于我们的场景而言是一个特别好的选择,因为它们完全独立并且仍然安全。 JWT由JSON有效负载和加密签名组成。 有效负载包含已认证用户,认证系统和令牌有效期的详细信息。 然后,签名可以确保它不能被恶意的第三方伪造-只有拥有签名密钥的人才能生成令牌。

在阅读本文时,您会经常看到对作为应用程序一部分的config.js模块的引用。 这用于配置应用程序,并使用Node-convict模块进行外部配置。 本文通篇使用的配置如下:

  • http.port –应用程序运行http.port的端口。 该默认值为3000,并使用“ PORT”环境变量覆盖。
  • authentication.google.clientId –用于Google身份验证的Google客户端ID。 使用“ GOOGLE_CLIENTID”环境变量将其提供给应用程序
  • authentication.google.clientSecret –用于Google身份验证的Google客户端密码。 使用“ GOOGLE_CLIENTSECRET”环境变量将其提供给应用程序。
  • authentication.facebook.clientI –用于Facebook身份验证的Facebook客户端ID。 使用“ FACEBOOK_CLIENTID”环境变量将其提供给应用程序
  • authentication.facebook.clientSecret用于Facebook身份验证的Facebook客户端密码。 使用“ FACEBOOK_CLIENTSECRET”环境变量将其提供给应用程序。
  • authentication.token.secret –用于签署用于我们的认证令牌的JWT的机密。 默认为“ mySuperSecretKey”。
  • authentication.token.issuer –存储在JWT内的Issuer。 在一个身份验证服务服务于许多应用程序的情况下,这表明哪个服务发布了令牌。
  • authentication.token.audience –存储在JWT中的受众。 在一个身份验证服务服务于许多应用程序的情况下,这表明令牌打算用于哪种服务。

整合护照

在可以将其用于您的应用程序之前,Passport需要进行少量的设置。 这只不过是确保已安装模块并初始化Express应用程序中的中间件。

此阶段我们需要的模块是passport模块,然后要设置中间件,我们只需将其添加到Express应用程序中即可。

// src/index.js
const passport = require('passport');
.....
app.use(passport.initialize());

如果您要按照Passport网站上的说明进行操作,则可以通过使用passport.session()调用来设置会话支持。 我们没有在应用程序中使用任何会话支持,因此这是不必要的。 这是因为我们正在实现无状态API,因此我们将对每个请求提供身份验证,而不是将其持久保存在会话中。

为安全端点实现JWT令牌认证

使用Passport设置JWT令牌认证相对简单。 我们将使用passport-jwt模块,该模块为我们完成了所有艰苦的工作。 该模块查找“ Authorization”标头,其中值以“ JWT”开头,并将标头的其余部分视为用于认证的JWT令牌。 然后,它将对JWT进行解码,并使存储在其中的值可供您自己的代码操纵(例如,进行用户查找)。 如果JWT无效(例如,签名无效,令牌已过期),则该请求将未经身份验证,而无需您自己的代码进行任何额外的参与。

然后,配置JWT令牌身份验证的情况如下:

// src/authentication/jwt.js
const passport = require('passport');
const passportJwt = require('passport-jwt');
const config = require('../config');
const users = require('../users');

const jwtOptions = {
  // Get the JWT from the "Authorization" header.
  // By default this looks for a "JWT " prefix
  jwtFromRequest: passportJwt.ExtractJwt.fromAuthHeader(),
  // The secret that was used to sign the JWT
  secretOrKey: config.get('authentication.token.secret'),
  // The issuer stored in the JWT
  issuer: config.get('authentication.token.issuer'),
  // The audience stored in the JWT
  audience: config.get('authentication.token.audience')
};

passport.use(new passportJwt.Strategy(jwtOptions, (payload, done) => {
  const user = users.getUserById(parseInt(payload.sub));
  if (user) {
      return done(null, user, payload);
  }
  return done();
}));

在上面,我们有几个内部模块可以利用:

  • config.js –包含我们整个应用程序的配置属性。 可以假定已经配置好这些值,并且可以随时使用这些值
  • users.js –这是应用程序的用户存储。 这样就可以加载和创建用户-在这里,我们只是通过用户的内部ID加载用户。

在这里,我们为JWT解码器配置了一个已知的秘密,发行者和受众,并且我们通知该策略它应该从Authorization标头中获取JWT。 如果发行者或听众中的任何一个与JWT中存储的不匹配,则身份验证将失败。 尽管这是一个非常简单的防伪保护,但它为我们提供了另一个层次的防伪保护。

令牌解码完全由passport-jwt模块处理,我们需要做的就是提供与最初用于生成令牌的配置相对应的配置。 因为JWT是标准,所以遵循该标准的任何模块都可以完美地协同工作。

令牌成功解码后,会将其作为有效负载传递给我们的回调。 在这里,我们只是尝试从令牌中查找“主题”所标识的用户。 实际上,您可能需要进行额外的检查,例如确保令牌未被吊销。

如果找到了用户,我们会将其提供给Passport,然后它将以req.user身份req.user其余请求处理req.user 。 如果找不到该用户,则我们不向Passport提供任何用户,Passport将认为身份验证失败。

现在可以将其连接到请求处理程序,以便请求需要身份验证才能成功:

// src/index.js
app.get('/api/secure',
  // This request must be authenticated using a JWT, or else we will fail
  passport.authenticate(['jwt'], { session: false }),
  (req, res) => {
    res.send('Secure response from ' + JSON.stringify(req.user));
  }
);

上面的第3行是使Passport处理请求的魔力。 这导致Passport运行我们刚刚在传入请求上配置的“ jwt”策略,或者允许它继续进行,否则立即失败。

通过运行应用程序(通过执行node src/index.js )并尝试访问此资源,我们可以看到实际的效果:

$ curl -v http://localhost:3000/api/secure
> GET /api/secure HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.51.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< X-Powered-By: Express
< Date: Tue, 13 Jun 2017 07:53:10 GMT
< Connection: keep-alive
< Content-Length: 12
<
Unauthorized

我们没有提供任何Authorization标头,并且它不允许我们继续进行。
但是,如果要提供有效的Authorization标头,则会获得成功的响应:

$ curl -v http://localhost:3000/api/secure -H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0OTczNDAzNzgsImV4cCI6MTQ5NzM0Mzk3OCwiYXVkIjoic29jaWFsLWxvZ2lucy1zcGEiLCJpc3MiOiJzb2NpYWwtbG9naW5zLXNwYSIsInN1YiI6IjAifQ.XlVnG59dX-SykXTJqCmvz_ALvzPW-yGZKOJEGFZ5KUs"
> GET /api/secure HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.51.0
> Accept: */*
> Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0OTczNDAzNzgsImV4cCI6MTQ5NzM0Mzk3OCwiYXVkIjoic29jaWFsLWxvZ2lucy1zcGEiLCJpc3MiOiJzb2NpYWwtbG9naW5zLXNwYSIsInN1YiI6IjAifQ.XlVnG59dX-SykXTJqCmvz_ALvzPW-yGZKOJEGFZ5KUs
>
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: text/html; charset=utf-8
< Content-Length: 60
< ETag: W/"3c-2im1YD4hSDFtwS8eVcEUzt3l5XQ"
< Date: Tue, 13 Jun 2017 07:54:37 GMT
< Connection: keep-alive
<
Secure response from {"id":0,"name":"Graham","providers":[]}

为了执行此测试,我通过访问https://www.jsonwebtoken.io并在此处填写表格来手动生成JWT。 我使用的“有效负载”是

{
  "iat": 1497340378, // Tuesday, 13 June 2017 07:52:58 UTC
  "exp": 1497343978, // Tuesday, 13 June 2017 08:52:58 UTC
  "aud": "social-logins-spa",
  "iss": "social-logins-spa",
  "sub": "0"
}

从配置中获取的“签名密钥”是“ mySuperSecretKey”。

支持代币生成

现在我们只能使用有效的令牌访问资源,我们需要一种实际生成令牌的方法。
这是使用jsonwebtoken模块完成的,构建一个包含正确详细信息并使用与上面所用相同的密钥签名的JWT。

// src/token.js
const jwt = require('jsonwebtoken');
const config = require('./config');

// Generate an Access Token for the given User ID
function generateAccessToken(userId) {
  // How long will the token be valid for
  const expiresIn = '1 hour';
  // Which service issued the token
  const issuer = config.get('authentication.token.issuer');
  // Which service is the token intended for
  const audience = config.get('authentication.token.audience');
  // The signing key for signing the token
  const secret = config.get('authentication.token.secret');

  const token = jwt.sign({}, secret, {
    expiresIn: expiresIn,
    audience: audience,
    issuer: issuer,
    subject: userId.toString()
  });

  return token;
}

请注意,在生成JWT时,我们为受众,发行者和秘密使用完全相同的配置设置。 我们还指定JWT的有效期为一小时。 这可能是您认为适合应用程序的任何时期,甚至是从配置中撤出的时期,以便可以轻松进行更改。

在这种情况下,未指定JWT ID,但是可以将其用于生成令牌的完全唯一的ID,例如使用UUID。 然后,这为您提供了一种方式来撤消令牌并将撤消ID的集合存储在数据存储中,并在通过Passport策略处理JWT时检查JWT ID是否不在列表中。

社交登录提供商

现在,我们已经具有生成令牌的能力,我们需要一种让用户实际登录的方法。这是社交登录提供程序进入的地方。我们将添加将用户重定向到社交登录提供程序的功能,然后成功生成了JWT令牌,并将其提供给浏览器的JavaScript引擎以用于将来的请求。
我们已经准备好了几乎所有的部件,我们只需要将它们连接在一起即可。

Passport中的社交登录提供程序分为两个部分。 首先,需要使用适当的插件为社交登录提供程序实际配置Passport。 其次,需要具有指向用户的快速路由,以启动身份验证,并在身份验证成功时将用户重定向回。

我们将在一个新的子浏览器窗口中打开这些URL,我们将在完成后将其关闭,并能够在打开该窗口的窗口内调用JavaScript方法。 这意味着该过程对用户而言是相对透明的-最多他们会看到一个新窗口打开,要求他们提供凭据,但是充其量,除了他们现在已经登录之外,他们可能什么都看不到。

浏览器端将需要包括两个部分。 弹出窗口的视图,以及在主窗口中处理该窗口的JavaScript。 可以很容易地做到这一点,以与任何框架集成,但是出于简单的原因,在本示例中,我们将使用原始JavaScript。

JavaScript的主页只需要这样的东西:

// src/public/index.html
let accessToken;

function authenticate(provider) {
  window.authenticateCallback = function(token) {
    accessToken = token;
  };

  window.open('/api/authentication/' + provider + '/start');
}

这会在窗口中注册一个全局函数对象(名为authenticateCallback ),该对象将存储访问令牌,然后打开我们的路由以启动身份验证,我们正在/api/authentication/{provider}/start上进行访问。

然后,可以通过您希望启动身份验证的任何方式来触发此功能。 通常这是标题区域中的登录链接,但是详细信息完全取决于您的应用程序。

第二部分是成功认证后要呈现的视图。 在这种情况下,为了简化起见,我们使用Mustache,但这将使用最适合您的任何视图技术。

<!-- src/public/authenticated.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Authenticated</title>
  </head>
  <body>
    Authenticated successfully.

    <script type="text/javascript">
      window.opener.authenticateCallback('{{token}}');
      window.close();
    </script>
  </body>
</html>

在这里,我们只是有一点JavaScript在此窗口的打开器(即主应用程序窗口)上从上方调用authenticateCallback方法,然后关闭自己。

此时,无论您想要什么目的,JWT令牌都将在主应用程序窗口中可用。

实施Google身份验证

将通过使用passport-google-oauth模块对Google进行身份验证。 需要提供三个信息:

  • 客户编号
  • 客户机密
  • 重定向网址

通过在Google Developer Console上注册您的应用程序可以获得客户端ID和密码。 重定向URL是应用程序内部的URL,当用户使用Google凭据登录后,会将其发送回该URL。 这将取决于应用程序的部署方式和位置,但是现在我们将对其进行硬编码。

然后,我们用于Google身份验证的Passport配置将如下所示:

// src/authentication/google.js
const passport = require('passport');
const passportGoogle = require('passport-google-oauth');
const config = require('../config');
const users = require('../users');

const passportConfig = {
  clientID: config.get('authentication.google.clientId'),
  clientSecret: config.get('authentication.google.clientSecret'),
  callbackURL: 'http://localhost:3000/api/authentication/google/redirect'
};

if (passportConfig.clientID) {
  passport.use(new passportGoogle.OAuth2Strategy(passportConfig, function (request, accessToken, refreshToken, profile, done) {
    // See if this user already exists
    let user = users.getUserByExternalId('google', profile.id);
    if (!user) {
      // They don't, so register them
      user = users.createUser(profile.displayName, 'google', profile.id);
    }
    return done(null, user);
  }));
}

成功通过身份验证后,如果将用户重定向回我们,我们将在Google系统内提供其ID和一些个人资料信息。 我们首先尝试查看该用户之前是否已登录。
如果是这样,那么我们获取他们的用户记录,我们就完成了。 如果没有,我们将为其注册一个新帐户,然后使用该新帐户。 这为我们提供了一种透明的机制,该机制可以在首次登录时完成用户注册。 如果您选择的话,我们可以采取不同的方法,但是现在没有必要了。

下一部分是设置路由处理程序来管理此登录。 这些看起来像这样:

// src/index.js
function generateUserToken(req, res) {
  const accessToken = token.generateAccessToken(req.user.id);
  res.render('authenticated.html', {
    token: accessToken
  });
}

app.get('/api/authentication/google/start',
  passport.authenticate('google', { session: false, scope: ['openid', 'profile', 'email'] }));
app.get('/api/authentication/google/redirect',
  passport.authenticate('google', { session: false }),
  generateUserToken);

请注意/api/authentication/google/start/api/authentication/gogle/redirect 。 如上所述, /start变体是我们打开的URL, /redirect变体是Google成功将用户重定向回的URL。 然后,这将渲染我们的认证视图,如上所示,提供生成的JWT供其使用。

实施Facebook身份验证

现在我们有了第一个社交登录提供商,让我们扩展并添加第二个。 这次将是使用passport-facebook模块passport-facebook

该模块实际上与Google模块相同,需要相同的配置和相同的设置。 唯一真正的区别是,它是一个不同的模块,并且使用不同的URL结构进行访问。

为了配置Facebook身份验证,您还需要一个客户端ID,客户端密码和重定向URL。
可以通过在Facebook开发者控制台中创建Facebook应用程序来获取客户端ID和客户端密钥(Facebook称为App ID和App Secret)。
您需要确保将“ Facebook登录”产品添加到您的应用程序中,以使其正常工作。

我们用于Facebook身份验证的Passport配置将是:

// src/authentication/facebook.js
const passport = require('passport');
const passportFacebook = require('passport-facebook');
const config = require('../config');
const users = require('../users');

const passportConfig = {
  clientID: config.get('authentication.facebook.clientId'),
  clientSecret: config.get('authentication.facebook.clientSecret'),
  callbackURL: 'http://localhost:3000/api/authentication/facebook/redirect'
};

if (passportConfig.clientID) {
  passport.use(new passportFacebook.Strategy(passportConfig, function (accessToken, refreshToken, profile, done) {
    let user = users.getUserByExternalId('facebook', profile.id);
    if (!user) {
      user = users.createUser(profile.displayName, 'facebook', profile.id);
    }
    return done(null, user);
  }));
}

这几乎与Google相同,只不过用了“ facebook”一词。 和URL路由类似:

// src/index.js
app.get('/api/authentication/facebook/start',
  passport.authenticate('facebook', { session: false }));
app.get('/api/authentication/facebook/redirect',
  passport.authenticate('facebook', { session: false }),
  generateUserToken);

在这里,我们不需要指定要使用的范围,因为默认设置已经足够了。 否则,Google和Facebook之间的配置几乎相同。

摘要

使用社交登录提供程序可以非常轻松快捷地将用户登录和注册添加到您的应用程序中。 使用浏览器重定向将用户发送到社交登录提供者,然后再返回到您的应用程序这一事实,即使将其集成到更传统的应用程序中相对容易,也很难将其集成到单个页面应用程序中。

本文展示了一种将这些社交登录提供程序集成到您的单页应用程序中的方法,该方法希望既易于使用,又易于扩展到您可能希望使用的将来的提供程序。
Passport有许多模块可与不同的提供商一起使用,这是找到合适的模块并以与上面对Google和Facebook相同的方式进行配置的一种情况。

James Kolce对此文章进行了同行评审。 感谢所有SitePoint的同行评审员,使SitePoint内容达到最佳状态

From: https://www.sitepoint.com/spa-social-login-google-facebook/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值