如何使用 Firebase 身份验证构建基于角色的 API

已发表: 2022-03-11

在本教程中,我们将构建一个 REST API 来使用 Firebase 和 Node.js 管理用户和角色。 此外,我们将了解如何使用 API 来授权(或不授权)哪些用户可以访问特定资源。

介绍

几乎每个应用程序都需要某种级别的授权系统。 在某些情况下,使用我们的用户表验证用户名/密码集就足够了,但通常,我们需要更细粒度的权限模型来允许某些用户访问某些资源并限制其他用户访问它们。 构建支持后者的系统并非易事,而且可能非常耗时。 在本教程中,我们将学习如何使用 Firebase 构建基于角色的身份验证 API,这将帮助我们快速启动和运行。

基于角色的身份验证

在此授权模型中,访问权限被授予角色,而不是特定用户,并且根据您设计权限模型的方式,用户可以拥有一个或多个。 另一方面,资源需要某些角色才能允许用户执行它。

基于角色的身份验证,带有插图

火力基地

Firebase 身份验证

简而言之,Firebase 身份验证是一个可扩展的基于令牌的身份验证系统,并提供与最常见的提供商(如 Google、Facebook 和 Twitter 等)的开箱即用集成。

它使我们能够使用自定义声明,我们将利用这些声明构建灵活的基于角色的 API。

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

设置后,自定义声明将包含在 Firebase 生成的令牌中,我们可以读取该值来控制访问。

它还附带一个非常慷慨的免费配额,在大多数情况下绰绰有余。

Firebase 函数

Functions 是一种完全托管的无服务器平台服务。 我们只需要在 Node.js 中编写代码并部署它。 Firebase 负责按需扩展基础架构、服务器配置等。 在我们的例子中,我们将使用它来构建我们的 API 并通过 HTTP 将其公开给 Web。

Firebase 允许我们将express.js应用程序设置为不同路径的处理程序——例如,您可以创建一个 Express 应用程序并将其挂接到/mypath ,所有到达此路由的请求都将由配置的app处理。

在函数的上下文中,您可以使用 Admin SDK 访问整个 Firebase 身份验证 API。

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

我们将建造什么

所以在我们开始之前,让我们看看我们将构建什么。 我们将使用以下端点创建一个 REST API:

Http动词小路描述授权
得到/用户列出所有用户只有管​​理员和管理员有权访问
邮政/用户创建新用户只有管​​理员和管理员有权访问
得到/users/:id 获取 :id 用户管理员、经理和与 :id 相同的用户具有访问权限
修补/users/:id 更新 :id 用户管理员、经理和与 :id 相同的用户具有访问权限
删除/users/:id 删除 :id 用户管理员、经理和与 :id 相同的用户具有访问权限

这些端点中的每一个都将处理身份验证、验证授权、执行相应的操作,并最终返回有意义的 HTTP 代码。

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

构建 API

为了构建 API,我们需要:

  • Firebase 项目
  • 安装了firebase-tools

首先,登录 Firebase:

 firebase login

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

 firebase init ? Which Firebase CLI features do you want to set up for this folder? ... (O) Functions: Configure and deploy Cloud Functions ? Select a default Firebase project for this directory: {your-project} ? What language would you like to use to write Cloud Functions? TypeScript ? Do you want to use TSLint to catch probable bugs and enforce style? Yes ? Do you want to install dependencies with npm now? Yes

此时,您将拥有一个 Functions 文件夹,其中包含创建 Firebase Functions 的最低设置。

src/index.ts有一个helloWorld示例,您可以取消注释以验证您的 Functions 是否有效。 然后你可以cd functions并运行npm run serve 。 此命令将转译代码并启动本地服务器。

您可以在 http://localhost:5000/{your-project}/us-central1/helloWorld 查看结果

全新的 Firebase 应用

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

创建 Firebase HTTP 函数

现在让我们编写我们的 API。 我们将创建一个 http Firebase 函数并将其挂接到/api路径。

首先,安装npm install express

src/index.ts我们将:

  • 使用admin.initializeApp();
  • 将 Express 应用程序设置为我们的api https 端点的处理程序
import * as functions from 'firebase-functions'; import * as admin from 'firebase-admin'; import * as express from 'express'; admin.initializeApp(); const app = express(); export const api = functions.https.onRequest(app);

现在,所有发往/api的请求都将由app实例处理。

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

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

 npm install --save cors body-parser
 npm install --save-dev @types/cors

然后:

 //... import * as cors from 'cors'; import * as bodyParser from 'body-parser'; //... const app = express(); app.use(bodyParser.json()); app.use(cors({ origin: true })); export const api = functions.https.onRequest(app);

最后,我们将配置app将处理的路由。

 //... import { routesConfig } from './users/routes-config'; //… app.use(cors({ origin: true })); routesConfig(app) export const api = functions.https.onRequest(app);

Firebase Functions 允许我们将 Express 应用程序设置为处理程序,以及您在functions.https.onRequest(app); ——在这种情况下, api也将由app处理。 这允许我们编写特定的端点,例如api/users并为每个 HTTP 动词设置一个处理程序,我们接下来会这样做。

让我们创建文件src/users/routes-config.ts

在这里,我们将在POST '/users'处设置一个create处理程序

import { Application } from "express"; import { create} from "./controller"; export function routesConfig(app: Application) { app.post('/users', create ); }

现在,我们将创建src/users/controller.ts文件。

在这个函数中,我们首先验证所有字段都在请求正文中,然后,我们创建用户并设置自定义声明。

我们只是在setCustomUserClaims中传递{ role } — Firebase 已经设置了其他字段。

如果没有发生错误,我们会返回一个 201 代码,其中包含创建的用户的uid

 import { Request, Response } from "express"; import * as admin from 'firebase-admin' export async function create(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) { return handleError(res, err) } } function handleError(res: Response, err: any) { return res.status(500).send({ message: `${err.code} - ${err.message}` }); }

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

在文件src/users/routes-config.ts

 //... import { isAuthenticated } from "../auth/authenticated"; import { isAuthorized } from "../auth/authorized"; export function routesConfig(app: Application) { app.post('/users', isAuthenticated, isAuthorized({ hasRole: ['admin', 'manager'] }), create ); }

让我们创建文件src/auth/authenticated.ts

在此函数中,我们将验证请求标头中是否存在authorization承载令牌。 然后我们将使用admin.auth().verifyidToken()对其进行解码,并将用户的uidroleemail保存在res.locals变量中,我们稍后将使用它来验证授权。

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

 import { Request, Response } from "express"; import * as admin from 'firebase-admin' export async function isAuthenticated(req: Request, res: Response, next: Function) { const { authorization } = req.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 = await 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' }); } }

现在,让我们创建一个src/auth/authorized.ts文件。

在这个处理程序中,我们从我们之前设置的res.locals中提取用户的信息,并验证它是否具有执行操作所需的角色,或者在操作允许同一用户执行的情况下,我们验证请求参数上的 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授权它们。 这很好,但由于 Firebase 不允许我们从项目控制台设置自定义声明,我们将无法执行任何这些端点。 为了绕过这个,我们可以从 Firebase Authentication Console 创建一个 root 用户

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

并在代码中设置电子邮件比较。 现在,当从该用户发出请求时,我们将能够执行所有操作。

 //... const { role, email, uid } = res.locals const { id } = req.params if (email === '[email protected]') return next(); //...

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

对于获取或更新发送:id参数的单个用户的操作,我们还允许同一用户执行该操作。

 export function 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 ]); }

src/users/controller.ts上。 在这些操作中,我们利用管理 SDK 与 Firebase 身份验证进行交互并执行相应的操作。 正如我们之前在create操作中所做的那样,我们在每个操作中返回一个有意义的 HTTP 代码。

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

 //.. export async function all(req: Request, res: Response) { try { const listUsers = await admin.auth().listUsers() const users = listUsers.users.map(mapUser) return res.status(200).send({ users }) } catch (err) { return handleError(res, err) } } function mapUser(user: admin.auth.UserRecord) { const customClaims = (user.customClaims || { role: '' }) as { 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 } } export async function 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) { return 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) { return handleError(res, err) } } export async function remove(req: Request, res: Response) { try { const { id } = req.params await admin.auth().deleteUser(id) return res.status(204).send({}) } catch (err) { return handleError(res, err) } } //...

现在我们可以在本地运行该函数。 为此,首先您需要设置帐户密钥,以便能够在本地与 auth API 连接。 然后运行:

 npm run serve

部署 API

伟大的! 现在我们已经编写了基于角色的 API,我们可以将其部署到 Web 并开始使用它。 使用 Firebase 进行部署非常简单,我们只需要运行firebase deploy即可。 部署完成后,我们可以通过发布的 URL 访问我们的 API。

运行 firebase deploy 命令

您可以在 https://console.firebase.google.com/u/0/project/{your-project}/functions/list 查看 API URL。

Firebase 控制台上的 API 网址

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

使用 API

部署 API 后,我们有多种使用方法——在本教程中,我将介绍如何通过 Postman 或 Angular 应用程序使用它。

如果我们在任何浏览器上输入 List All Users URL ( /api/users ),我们将得到以下信息:

Firebase 身份验证 API

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

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

从 Angular 应用程序

在本教程中,我将介绍从 Angular 应用程序使用 API 的重要部分。 可以在此处访问完整的存储库,如果您需要有关如何创建 Angular 应用程序和配置 @angular/fire 以使用的分步教程,您可以查看这篇文章。

所以,回到登录,我们将有一个带有<form>SignInComponent ,让用户输入用户名和密码。

 //... <form [formGroup]="form"> <div class="form-group"> <label>Email address</label> <input type="email" formControlName="email" class="form-control" placeholder="Enter email"> </div> <div class="form-group"> <label>Password</label> <input type="password" formControlName="password" class="form-control" placeholder="Password"> </div> </form> //...

在课堂上,我们使用signInWithEmailAndPassword服务AngularFireAuth

 //... form: FormGroup = new FormGroup({ email: new FormControl(''), password: new FormControl('') }) constructor( private afAuth: AngularFireAuth ) { } async signIn() { try { const { email, password } = this.form.value await this.afAuth.auth.signInWithEmailAndPassword(email, password) } catch (err) { console.log(err) } } //..

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

通过 Angular 应用程序登录

从 Angular 应用程序登录时的 API 响应

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

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

此文件显示如何从AngularFireAuth获取令牌并将其添加到标头的请求中。 然后我们在 AppModule 中提供拦截器文件。

http-interceptors/auth-token.interceptor.ts

 @Injectable({ providedIn: 'root' }) export class AuthTokenHttpInterceptor implements HttpInterceptor { constructor( private auth: AngularFireAuth ) { } intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { 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) }) ) } } export const AuthTokenHttpInterceptorProvider = { provide: HTTP_INTERCEPTORS, useClass: AuthTokenHttpInterceptor, multi: true }

app.module.ts

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

一旦设置了拦截器,我们就可以从httpClient向我们的 API 发出请求。 例如,这是一个UsersService ,我们将列表称为所有用户,通过其 ID 获取用户,创建用户并更新用户。

 //… export type CreateUserRequest = { displayName: string, password: string, email: string, role: string } export type UpdateUserRequest = { uid: string } & CreateUserRequest @Injectable({ providedIn: 'root' }) export class UserService { private baseUrl = '{your-functions-url}/api/users' constructor( private http: HttpClient ) { } get users$(): Observable<User[]> { return this.http.get<{ users: User[] }>(`${this.baseUrl}`).pipe( map(result => { return result.users }) ) } user$(id: string): Observable<User> { return this.http.get<{ user: User }>(`${this.baseUrl}/${id}`).pipe( map(result => { return result.user }) ) } create(user: CreateUserRequest) { return this.http.post(`${this.baseUrl}`, user) } edit(user: UpdateUserRequest) { return this.http.patch(`${this.baseUrl}/${user.uid}`, user) } }

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

 //... <div *ngIf="user$ | async; let user" class="col-12"> <div class="d-flex justify-content-between my-3"> <h4> Me </h4> </div> <ul class="list-group"> <li class="list-group-item d-flex justify-content-between align-items-center"> <div> <h5 class="mb-1">{{user.displayName}}</h5> <small>{{user.email}}</small> </div> <span class="badge badge-primary badge-pill">{{user.role?.toUpperCase()}}</span> </li> </ul> </div> <div class="col-12"> <div class="d-flex justify-content-between my-3"> <h4> All Users </h4> </div> <ul *ngIf="users$ | async; let users" class="list-group"> <li *ngFor="let user of users" class="list-group-item d-flex justify-content-between align-items-center"> <div> <h5 class="mb-1">{{user.displayName}}</h5> <small class="d-block">{{user.email}}</small> <small class="d-block">{{user.uid}}</small> </div> <span class="badge badge-primary badge-pill">{{user.role?.toUpperCase()}}</span> </li> </ul> //...
 //... users$: Observable<User[]> user$: Observable<User> constructor( private userService: UserService, private userForm: UserFormService, private modal: NgbModal, private afAuth: AngularFireAuth ) { } ngOnInit() { this.users$ = this.userService.users$ this.user$ = this.afAuth.user.pipe( filter(user => !!user), switchMap(user => this.userService.user$(user.uid)) ) } //...

这就是结果。

我们 Angular 应用程序中的所有用户

请注意,如果我们使用具有role=user的用户登录,则只会呈现 Me 部分。

角色 user 可以访问的用户资源的视图

我们会在网络检查器上得到一个 403。 这是由于我们之前在 API 上设置的限制,只允许“管理员”列出所有用户。

网络检查器中的 403 错误

现在,让我们添加“创建用户”和“编辑用户”功能。 为此,让我们首先创建一个UserFormComponent和一个UserFormService

 <ng-container *ngIf="user$ | async"></ng-container> <div class="modal-header"> <h4 class="modal-title">{{ title$ | async}}</h4> <button type="button" class="close" (click)="dismiss()"> <span aria-hidden="true">&times;</span> </button> </div> <div class="modal-body"> <form [formGroup]="form" (ngSubmit)="save()"> <div class="form-group"> <label>Email address</label> <input type="email" formControlName="email" class="form-control" placeholder="Enter email"> </div> <div class="form-group"> <label>Password</label> <input type="password" formControlName="password" class="form-control" placeholder="Password"> </div> <div class="form-group"> <label>Display Name</label> <input type="string" formControlName="displayName" class="form-control" placeholder="Enter display name"> </div> <div class="form-group"> <label>Role</label> <select class="custom-select" formControlName="role"> <option value="admin">Admin</option> <option value="manager">Manager</option> <option value="user">User</option> </select> </div> </form> </div> <div class="modal-footer"> <button type="button" class="btn btn-outline-danger" (click)="dismiss()">Cancel</button> <button type="button" class="btn btn-primary" (click)="save()">Save</button> </div>
 @Component({ selector: 'app-user-form', templateUrl: './user-form.component.html', styleUrls: ['./user-form.component.scss'] }) export class UserFormComponent implements OnInit { form = new FormGroup({ uid: new FormControl(''), email: new FormControl(''), displayName: new FormControl(''), password: new FormControl(''), role: new FormControl(''), }); title$: Observable<string>; 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.dismiss('modal dismissed'); } save() { const { displayName, email, role, password, uid } = this.form.value; this.modal.close({ displayName, email, role, password, uid }); } }
 import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { map } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class 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) ); } }

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

 //... <div class="d-flex justify-content-between my-3"> <h4> Me </h4> <button class="btn btn-primary" (click)="edit(user)"> Edit Profile </button> </div> //... <div class="d-flex justify-content-between my-3"> <h4> All Users </h4> <button class="btn btn-primary" (click)="create()"> New User </button> </div> //...
 //... 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 => { }); }

从邮递员

Postman 是一种构建 API 并向 API 发出请求的工具。 这样,我们可以模拟我们正在从任何客户端应用程序或不同的服务调用我们的 API。

我们将演示的是如何发送请求以列出所有用户。

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

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

接下来,在选项卡授权上,我们选择 Bearer Token 并设置我们之前从开发工具中提取的值。

在 Postman 中设置不记名令牌

我们收到的响应正文

结论

恭喜! 您已经完成了整个教程,现在您已经学会了在 Firebase 上创建基于用户角色的 API。

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

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

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

您可以在此处进一步了解 Firebase 身份验证。 如果你想利用我们定义的角色,你可以使用 @angular/fire 助手。