作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
华金·希德的头像

Joaquin Cid

Joaquin是一名全栈开发人员,在WebMD和Getty Images等公司拥有超过12年的工作经验.

Previously At

Velocity Partners
Share

在本教程中,我们将构建一个Node.使用Firebase认证REST API来管理用户和角色. In addition, 我们将看到如何使用API来授权(或不授权)哪些用户可以访问特定的资源.

Introduction

几乎每个应用程序都需要某种程度的授权系统. 在某些情况下,使用 Users table is enough, but often, 我们需要一个更细粒度的权限模型,以允许某些用户访问某些资源,并限制他们访问其他资源. 构建一个支持后者的系统并不简单,而且可能非常耗时. In this tutorial, 我们将学习如何使用Firebase构建基于角色的身份验证API, 这将帮助我们快速启动和运行.

Role-based Auth

在这个授权模型中, access is granted to roles, instead of specific users, 用户可以有一个或多个,这取决于您如何设计权限模型. 另一方面,资源需要特定的角色来允许用户执行它.

基于角色的Auth与插图

Firebase

Firebase Authentication

In a nutshell, Firebase身份验证是一个可扩展的基于令牌的身份验证系统,并提供了与最常见的提供商(如Google)的开箱即用集成, Facebook, and Twitter, among others.

它使我们能够使用自定义声明,我们将利用它来构建灵活的基于角色的API. 这些声明可以被认为是Firebase用户角色,将直接映射到我们的应用程序所支持的角色.

我们可以在声明中设置任何JSON值(例如.g., { role: 'admin' } or { role: 'manager' }).

Once set, 自定义声明将包含在生成的Firebase令牌中, 我们可以读这个值来控制访问.

它还提供了非常慷慨的免费配额,在大多数情况下都绰绰有余.

Firebase Functions

Functions是一个完全托管的无服务器平台服务. 我们只需要在Node中编写代码.js and deploy it. Firebase负责按需扩展基础设施、服务器配置等. 在我们的例子中,我们将使用它来构建我们的API,并通过HTTP向web公开它.

Firebase allows us to set express.js 应用程序作为不同路径的处理程序——例如,你可以创建一个Express应用程序并将其挂接到 /mypath,并且所有到达此路由的请求都将由 app configured.

从函数的上下文中, 你可以访问整个Firebase身份验证API, using the Admin SDK.

这就是我们创建用户API的方式.

What We’ll Build

所以在我们开始之前,让我们看看我们将构建什么. 我们将创建一个带有以下端点的REST API:

Http VerbPathDescriptionAuthorization
GET/usersLists all users只有管理员和管理员可以访问
POST/usersCreates new user只有管理员和管理员可以访问
GET/users/:idGets the :id user管理员、管理员和与:id相同的用户都可以访问
PATCH/users/:idUpdates the :id user管理员、管理员和与:id相同的用户都可以访问
DELETE/users/:idDeletes the :id user管理员、管理员和与:id相同的用户都可以访问

每个端点都将处理身份验证, validate authorization, 执行相应的操作, 最后返回一个有意义的HTTP代码.

我们将创建验证令牌所需的身份验证和授权函数,并检查声明是否包含执行操作所需的角色.

Building the API

为了构建API,我们需要:

  • A Firebase project
  • firebase-tools installed

First, log in to Firebase:

firebase login

接下来,初始化一个Functions项目:

firebase init

? 您想为此文件夹设置哪些Firebase CLI特性? ...
(O)功能:配置和部署云功能

? 为此目录选择默认Firebase项目:{your-project}

? 你想用什么语言来写云函数? TypeScript

? 你想使用TSLint来捕获可能的错误并强制执行样式吗? Yes

? 你想现在用npm安装依赖吗? Yes

此时,您将拥有一个Functions文件夹,它具有创建Firebase Functions所需的最小设置.

At src/index.ts there’s a helloWorld 示例,您可以取消注释以验证函数是否正常工作. Then you can cd functions and run npm run serve. 该命令将编译代码并启动本地服务器.

你可以在 http://localhost:5000/{your-project}/us-central1/helloWorld

A fresh Firebase app

注意,函数在定义为它的名称的路径上公开 'index.ts: 'helloWorld'.

创建Firebase HTTP功能

Now let’s code our API. 我们将创建一个http Firebase函数并将其挂接 /api path.

First, install npm install express.

On the src/index.ts we will:

  • 初始化firebase-admin SDK模块 admin.initializeApp();
  • 设置一个Express应用程序作为我们的 api http endpoint
从'firebase-functions'中导入* as函数;
从'firebase-admin'中导入*作为admin;
从“express”中导入* as express;

admin.initializeApp();

const app = express();

导出const API =函数.http.onRequest(app);

Now, all requests going to /api will be handled by the app instance.

接下来我们要做的是配置 app 实例来支持CORS并添加JSON主体解析器中间件. 这样我们就可以从任何URL发出请求并解析JSON格式的请求.

我们将首先安装所需的依赖项.

NPM install——save cors body-parser
NPM install——save-dev @types/cors

And then:

//...
从'cors'中导入* as cors;
从'body-parser'中导入*作为bodyParser;

//...
const app = express();
app.use(bodyParser.json());
app.使用(cors({origin: true}));

导出const API =函数.http.onRequest(app);

最后,我们将配置路由 app will handle.

//...
import {routesConfig} from./users/routes-config';
 
//…
app.使用(cors({origin: true}));
routesConfig(app)

导出const API =函数.http.onRequest(app);

Firebase Functions允许我们将Express应用设置为处理程序, 在你设置的路径之后的任何路径 functions.http.onRequest(app);—in this case, api-也将由 app. 这允许我们编写特定的端点,例如 api/users 并为每个HTTP动词设置处理程序,这是我们接下来要做的.

Let’s create the file src/users/routes-config.ts

Here, we’ll set a create handler at POST '/users'

从“express”中导入{Application};
import { create} from "./controller";

导出routesConfig(app: Application) {
   app.post('/users',
       create
   );
}

Now, we’ll create the src/users/controller.ts file.

In this function, 我们首先验证所有字段都在请求体中, and next, 我们创建用户并设置自定义声明.

We are just passing { role } in the setCustomUserClaims-其他字段已经由Firebase设置.

如果没有错误发生,则返回201代码和 uid of the user created.

import {Request, Response} from“express”;
从firebase-admin中导入* as admin

导出异步函数创建(req: Request, res: Response) {
   try {
       const {displayName, password, email, role} = req.body

       if (!displayName || !password || !email || !role) {
           return res.status(400).send({message: 'Missing fields'})
       }

       const { uid } = await admin.auth().createUser({
           displayName,
           password,
           email
       })
       await admin.auth().setCustomUserClaims(uid, {role})

       return res.status(201).send({ uid })
   } catch (err) {
       返回handleError(res, err)
   }
}

函数handleError(res: Response, err: any) {
   return res.status(500).send({ message: `${err.code} - ${err.message}` });
}

现在,让我们通过添加授权来保护处理程序. 要做到这一点,我们将向我们的 create endpoint. With express.js,您可以设置将按顺序执行的处理程序链. 在处理程序中,您可以执行代码并将其传递给 next() 处理程序或返回响应. 我们要做的是首先验证用户,然后验证它是否被授权执行.

On file src/users/routes-config.ts:

//...
导入{isAuthenticated}../auth/authenticated";
导入{isAuthorized}../auth/authorized";

导出routesConfig(app: Application) {
   app.post('/users',
       isAuthenticated,
       isAuthorized({hasRole: ['admin', 'manager']}),
       create
   );
}

Let’s create the files src/auth/authenticated.ts.

在这个函数上,我们将验证 authorization 请求头中的承载令牌. Then we’ll decode it with admin.auth().verifyidToken() and persist the user’s uid, role, and email in the res.locals 变量,稍后我们将使用它来验证授权.

在令牌无效的情况下,我们向客户端返回401响应:

import {Request, Response} from“express”;
从firebase-admin中导入* as admin

导出异步函数isAuthenticated(req: Request, res: Response, next: function) {
   Const {authorization} =权限.headers

   if (!authorization)
       return res.status(401).send({message: 'Unauthorized'});

   if (!authorization.startsWith('Bearer'))
       return res.status(401).send({message: 'Unauthorized'});

   Const split = authorization.split('Bearer ')
   if (split.length !== 2)
       return res.status(401).send({message: 'Unauthorized'});

   const token = split[1]

   try {
       const decodedToken: admin.auth.DecodedIdToken =等待admin.auth().verifyIdToken(token);
       console.log("decodedToken", JSON.stringify(decodedToken))
       res.locals = { ...res.locals, uid: decodedToken.uid, role: decodedToken.role, email: decodedToken.email }
       return next();
   }
   catch (err) {
       console.error(`${err.code} -  ${err.message}`)
       return res.status(401).send({message: 'Unauthorized'});
   }
}

Now, let’s create a src/auth/authorized.ts file.

在这个处理程序中,我们从中提取用户信息 res.locals 我们设置先前并验证它是否具有执行操作所需的角色,或者在操作允许同一用户执行的情况下, 我们验证请求参数上的ID与auth令牌中的ID是否相同. 如果用户没有所需的角色,我们将返回403.

import {Request, Response} from“express”;

export function isAuthorized(opts: { hasRole: Array<'admin' | 'manager' | 'user'>, allowSameUser?: boolean }) {
   return (req: Request, res: Response, next: Function) => {
       Const {role, email, uid} = res.locals
       const { id } = req.params

       if (opts.allowSameUser && id && uid === id)
           return next();

       if (!role)
           return res.status(403).send();

       if (opts.hasRole.includes(role))
           return next();

       return res.status(403).send();
   }
}

使用这两个方法,我们将能够对请求进行身份验证并在给定的情况下对它们进行授权 role in the incoming token. 这很棒,但由于Firebase不允许我们从项目中设置自定义声明 console,我们将无法执行这些端点. 为了绕过这个问题,我们可以从Firebase身份验证控制台创建一个根用户

从Firebase身份验证控制台创建用户

并在代码中设置一个电子邮件比较. 现在,当触发来自该用户的请求时,我们将能够执行所有操作.

//...
  Const {role, email, uid} = res.locals
  const { id } = req.params

  If (email === 'your-root-user-email@domain . '.com')
    return next();
//...

现在,让我们将其余的CRUD操作添加到 src/users/routes-config.ts.

用于获取或更新单个用户的操作 :id 参数被发送,我们也允许相同的用户执行操作.

导出routesConfig(app: Application) {
   //..
   // lists all users
   app.get('/users', [
       isAuthenticated,
       isAuthorized({hasRole: ['admin', 'manager']}),
       all
   ]);
   // get :id user
   app.get('/users/:id', [
       isAuthenticated,
       isAuthorized({hasRole: ['admin', 'manager'], allowSameUser: true}),
       get
   ]);
   // updates :id user
   app.patch('/users/:id', [
       isAuthenticated,
       isAuthorized({hasRole: ['admin', 'manager'], allowSameUser: true}),
       patch
   ]);
   // deletes :id user
   app.delete('/users/:id', [
       isAuthenticated,
       isAuthorized({hasRole: ['admin', 'manager']}),
       remove
   ]);
}

And on src/users/controller.ts. In these operations, 我们利用管理SDK与Firebase身份验证进行交互并执行相应的操作. As we did previously on create 操作时,对每个操作返回一个有意义的HTTP代码.

对于更新操作,我们验证所有存在的字段并覆盖它们 customClaims 在请求中发送的那些:

//..

export async function all(req: Request, res: Response) {
    try {
        const listUsers =等待admin.auth().listUsers()
        const users = listUsers.users.map(mapUser)
        return res.status(200).send({ users })
    } catch (err) {
        返回handleError(res, err)
    }
}

function mapUser(user: admin).auth.UserRecord) {
    const customClaims = (user.customClaims || {role: "})作为{role?: string }
    const role = customClaims.role ? customClaims.role : ''
    return {
        uid: user.uid,
        email: user.email || '',
        displayName: user.displayName || '',
        role,
        lastSignInTime: user.metadata.lastSignInTime,
        creationTime: user.metadata.creationTime
    }
}

导出异步函数get(req: Request, res: Response) {
   try {
       const { id } = req.params
       const user = await admin.auth().getUser(id)
       return res.status(200).send({user: mapUser(user)})
   } catch (err) {
       返回handleError(res, err)
   }
}

export async function patch(req: Request, res: Response) {
   try {
       const { id } = req.params
       const {displayName, password, email, role} = req.body

       if (!id || !displayName || !password || !email || !role) {
           return res.status(400).send({message: 'Missing fields'})
       }

       await admin.auth().updateUser(id, {displayName, password, email})
       await admin.auth().setCustomUserClaims(id, {role})
       const user = await admin.auth().getUser(id)

       return res.status(204).send({user: mapUser(user)})
   } catch (err) {
       返回handleError(res, err)
   }
}

导出异步函数remove(req: Request, res: Response) {
   try {
       const { id } = req.params
       await admin.auth().deleteUser(id)
       return res.status(204).send({})
   } catch (err) {
       返回handleError(res, err)
   }
}

//...

现在我们可以在本地运行这个函数了. 要做到这一点,首先你需要 set up the account key 以便能够在本地连接认证API. Then run:

npm run serve

Deploy the API

Great! 现在我们已经使用Firebase的基于角色的身份验证API编写了应用程序, 我们可以将它部署到网络上并开始使用它. 使用Firebase进行部署非常简单,我们只需要运行 firebase deploy. 一旦部署完成,我们就可以通过发布的URL访问我们的API.

执行firebase deploy命令

您可以在这里查看API URL http://console.firebase.google.com/u/0/project/{项目}/功能/列表.

Firebase控制台的API URL

就我而言,它是[http://us-central1-joaq-lab].cloudfunctions.net/api].

Consuming the API

Once our API is deployed, 在本教程中,我们有几种方法来使用它, 我将介绍如何通过Postman或从Angular应用中使用它.

如果我们输入List All Users URL (/api/users),我们将得到以下结果:

Firebase Authentication API

这样做的原因是从浏览器发送请求时, 我们正在执行一个没有验证头的GET请求. 这意味着我们的API实际上是按预期工作的!

为了生成这样的令牌,我们的API是通过令牌来保护的, 我们需要调用Firebase的客户端SDK并使用有效的用户/密码凭证登录. When successful, Firebase将在响应中发送一个令牌,然后我们可以将其添加到我们想要执行的任何后续请求的标头中.

From an Angular App

在本教程中,我将介绍从Angular应用中使用API的重要部分. 可以访问完整的存储库 here, 如果你需要一个循序渐进的教程,教你如何创建Angular应用并配置@angular/fire来使用, it you can check this post.

回到签到,我们会有 SignInComponent with a

让用户输入用户名和密码.

//...
 

   
//...

And on the class, we signInWithEmailAndPassword using the AngularFireAuth service.

 //... 

 form: FormGroup = new FormGroup({
   email: new FormControl("),
   password: new FormControl(")
 })

 constructor(
   私有auth: AngularFireAuth
 ) { }

 async signIn() {
   try {
     Const {email, password} = this.form.value
     await this.afAuth.auth.signInWithEmailAndPassword(电子邮件、密码)
   } catch (err) {
     console.log(err)
   }
 }

 //..

此时,我们可以登录到Firebase项目.

通过Angular应用登录

从Angular应用中登录时的API响应

当我们在DevTools中检查网络请求时, 我们可以看到Firebase在验证我们的用户和密码后返回一个令牌.

我们将使用这个令牌将我们的头请求发送到我们构建的API. 将令牌添加到所有请求的一种方法是使用 HttpInterceptor.

这个文件显示了如何从 AngularFireAuth 并将其添加到header的请求中. 然后,我们在AppModule中提供拦截器文件.

http-interceptors /鉴定标识.interceptor.ts

@Injectable({providedIn: 'root'})
导出类authtokenttpinterceptor实现HttpInterceptor {

   constructor(
       private auth: AngularFireAuth
   ) {

   }

   intercept(req: HttpRequest, next: HttpHandler): Observable> {
       return this.auth.idToken.pipe(
           take(1),
           switchMap(idToken => {
               let clone = req.clone()
               if (idToken) {
                   clone = clone.clone({ headers: req.headers.set('Authorization', 'Bearer ' + idToken)});
               }
               return next.handle(clone)
           })
       )

   }
}

导出const authtokenttpinterceptorprovider = {
   提供:HTTP_INTERCEPTORS,
   useClass: AuthTokenHttpInterceptor,
   multi: true
}

app.module.ts

@NgModule({
 //..
 providers: [
   AuthTokenHttpInterceptorProvider
 ]
 //...
})
export class AppModule { }

一旦设置了拦截器,我们就可以从 httpClient. For example, here’s a UsersService 其中我们调用列表all users,根据用户ID获取用户,创建用户,并更新用户.

//…

导出类型CreateUserRequest = {displayName:字符串, password: string, email: string, role: string }
导出类型UpdateUserRequest = {uid: string} & CreateUserRequest

@Injectable({
 providedIn: 'root'
})
export class UserService {

 private baseUrl = '{your-functions-url}/api/users'

 constructor(
   private http: HttpClient
 ) { }
  get users$(): Observable {
   return this.http.get<{ users: User[] }>(`${this.baseUrl}`).pipe(
     map(result => {
       return result.users
     })
   )
 }

 user$(id: string): Observable {
   return this.http.get<{ user: User }>(`${this.baseUrl}/${id}`).pipe(
     map(result => {
       return result.user
     })
   )
 }

 创建(用户:CreateUserRequest) {
   return this.http.post(`${this.baseUrl}`, user)
 }

 编辑(user: UpdateUserRequest) {
   return this.http.patch(`${this.baseUrl}/${user.uid}`, user)
 }

}

Now, 我们可以通过调用API来获取登录用户的ID,并列出组件中的所有用户,如下所示:

//...

  

Me

  • {{user.displayName}}
    {{user.email}}
    {{user.role?.toUpperCase()}}

All Users

  • {{user.displayName}}
    {{user.email}} {{user.uid}}
    {{user.role?.toUpperCase()}}
//...
//...

 users$: Observable
 user$: Observable

 constructor(
   private userService: userService
   private userForm: UserFormService
   private modal: NgbModal,
   私有auth: AngularFireAuth
 ) { }

 ngOnInit() {
   this.users$ = this.userService.users$

   this.user$ = this.afAuth.user.pipe(
     filter(user => !!user),
     switchMap(user => this.userService.user$(user.uid))
   )
 }

//...

And here’s the result.

Angular应用中的所有用户

注意,如果我们使用一个用户登录 role=user,则只呈现Me部分.

具有user角色的用户所访问的用户资源视图

我们会在网络检查员那里查到403. 这是由于我们之前在API上设置的限制,只允许“Admins”列出所有用户.

网络检查器中出现403错误

现在,让我们添加“创建用户”和“编辑用户”功能. 为了做到这一点,我们先创建a UserFormComponent and a UserFormService.





@Component({
  selector: 'app-user-form',
  templateUrl: './user-form.component.html',
  styleUrls: ['./user-form.component.scss']
})
导出类UserFormComponent实现OnInit {

  form = new FormGroup({
    uid: new FormControl(''),
    email: new FormControl("),
    displayName: new FormControl("),
    password: new FormControl("),
    role: new FormControl(''),
  });
  title$: Observable;
  user$: Observable<{}>;

  constructor(
    public modal: NgbActiveModal;
    private userService: userService
    private userForm: UserFormService
  ) { }

  ngOnInit() {
    this.title$ = this.userForm.title$;
    this.user$ = this.userForm.user$.pipe(
      tap(user => {
        if (user) {
          this.form.patchValue(user);
        } else {
          this.form.reset({});
        }
      })
    );
  }

  dismiss() {
    this.modal.把(“模态驳回”);
  }

  save() {
    const {displayName, email, role, password, uid} = this.form.value;
    this.modal.close({displayName, email, role, password, uid});
  }

}
从“@angular/core”中导入{Injectable};
从'rxjs'中导入{BehaviorSubject};
从'rxjs/operators'中导入{map};

@Injectable({
  providedIn: 'root'
})
导出类UserFormService {

  _BS = new BehaviorSubject({title: ", user: {}});

  constructor() { }

  edit(user) {
    this._BS.next({title: 'Edit User', User});
  }

  create() {
    this._BS.next({title: 'Create User', User: null});
  }

  get title$() {
    return this._BS.asObservable().pipe(
      map(uf => uf.title)
    );
  }

  get user$() {
    return this._BS.asObservable().pipe(
      map(uf => uf.user)
    );
  }
}

回到主组件,让我们添加按钮来调用这些操作. 在这种情况下,“编辑用户”将仅对登录用户可用. 如果需要,可以继续添加编辑其他用户的功能!

//...

    

Me

//...

All Users

//...
//...
  create() {
    this.userForm.create();
    const modalRef = this.modal.open(UserFormComponent);
    modalRef.result.then(user => {
      this.userService.create(user).subscribe(_ => {
        console.log('user created');
      });
    }).catch(err => {

    });
  }

  edit(userToEdit) {
    this.userForm.edit(userToEdit);
    const modalRef = this.modal.open(UserFormComponent);

    modalRef.result.then(user => {
      this.userService.edit(user).subscribe(_ => {
        console.log('user edited');
      });
    }).catch(err => {

    });

  }

From Postman

Postman是一个构建和向api发出请求的工具. 通过这种方式,我们可以模拟从任何客户端应用程序或不同的服务调用API.

我们将演示的是如何发送一个请求来列出所有用户.

打开工具后,设置URL http://us-central1-{your-project}.cloudfunctions.net/api/users:

在Postman字段中加载的API URL准备作为GET请求触发

Next, on the tab authorization, 我们选择承载令牌,并设置之前从Dev Tools提取的值.

在邮差中设置不记名令牌

我们收到的响应的主体

Conclusion

Congratulations! 您已经完成了整个教程,现在您已经学习了如何在Firebase上创建基于用户角色的API.

我们还介绍了如何从Angular应用和Postman中使用它.

让我们回顾一下最重要的事情:

  1. Firebase允许您使用企业级身份验证API快速启动和运行, 你可以稍后再扩展.
  2. 几乎每个项目都需要授权——如果您需要使用基于角色的模型来控制访问的话, Firebase身份验证可以让您快速入门.
  3. 基于角色的模型依赖于验证从具有特定角色的用户请求的资源. specific users.
  4. Using an Express.. js应用程序在Firebase功能, 我们可以创建一个REST API并设置处理程序来对请求进行身份验证和授权.
  5. 利用内置的自定义声明,您可以创建基于角色的身份验证API并保护您的应用程序.

您可以进一步了解Firebase认证 here. 如果你想利用我们已经定义的角色,你可以使用@angular/fire helpers.

Understanding the basics

  • Is Firebase Auth a REST API?

    Firebase Auth是一项服务,它允许你的应用程序注册和验证用户对多个提供商,如谷歌, Facebook, Twitter, GitHub and more). Firebase Auth提供sdk,您可以使用这些sdk轻松地与web、Android和iOS集成. Firebase Auth也可以作为REST API使用

  • What is Firebase used for?

    Firebase是一套云产品,可帮助您快速构建无服务器移动或web应用程序. 它提供了每个应用程序(数据库)中涉及的大多数公共服务, authorization, storage, hosting).

  • 我如何获得Firebase认证API?

    你可以在firebase上用你的Google帐户创建一个项目.google.com. 一旦项目被创建,你就可以打开Firebase Auth并开始在你的应用中使用它.

  • Firebase和AWS哪个更好?

    Firebase是谷歌支持的产品, 其中一个谷歌正在努力发展并增加越来越多的功能. AWS Amplify是一款类似的产品,主要针对移动应用. 两者都是很棒的产品,Firebase是一个更老的产品,功能更多.

  • Is Firebase easy to use?

    Firebase是一个完全托管的服务,您可以非常轻松地开始使用它,并且在需要扩展时不必担心基础设施. 有很多很棒的文档和博客文章,其中有示例,可以快速了解它是如何工作的.

  • Firebase适合大型数据库吗?

    Firebase有两个数据库:Realtime Database和Firestore. 两者都是NoSQL数据库,具有相似的功能和不同的定价模型. Firestore支持更好的查询功能,而且这两个数据库的设计使得查询延迟不受数据库大小的影响.

聘请Toptal这方面的专家.
Hire Now
华金·希德的头像
Joaquin Cid

Located in 阿根廷圣达菲省罗萨里奥

Member since May 2, 2018

About the author

Joaquin是一名全栈开发人员,在WebMD和Getty Images等公司拥有超过12年的工作经验.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Previously At

Velocity Partners

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.