跳转至

5. 使用OAuth 2.0添加身份验证和授权

我们的heroes应用程序允许任何人创建或查看同一组英雄。我们将继续在上一章项目 heroes的基础上,要求用户在查看或创建英雄之前登录。

我们已经完成了浏览器应用的开发

我们现在已经到了使用浏览器应用来测试我们的Aqueduct应用的时候,变得有点麻烦了。从这里开始,我们将使用curlaqueduct document client和测试。

OAuth 2.0基础

OAuth 2.0是一个授权框架,还包含有关身份验证的指南。身份验证是通常通过用户名和密码证明您是特定用户的过程。授权是确保用户可以访问特定资源或资源集合的过程。在我们的应用程序中,必须先授权用户身份,然后才能查看或创建英雄。

在一个简单的身份验证和授权方案中,每个HTTP请求都在Authorization标头中包含用户的用户名和密码(凭据)。执行此操作涉及许多安全风险,因此OAuth 2.0采用了另一种方法:您一次发送凭据,然后获得access token作为回报。然后,您在每个请求中发送此访问令牌。因为服务器授予令牌,所以它知道您已经输入了凭据(您已经经过身份验证),并且会记住令牌属于谁。实际上,每次令牌都与发送凭据相同,只是令牌有时间限制,出问题时可以撤消令牌。

Aqueduct有一个内置的OAuth 2.0实现,它利用了ORM。这个实现是aqueduct包的一部分,但它是一个单独的库,名为aqueduct/managed_auth。如果你不熟悉OAuth 2.0,可能需要几个步骤来设置,可能很难理解,但你会得到一个经过良好测试的安全授权实现。

替代性实施方案

在大多数情况下,使用package:aqueduct/managed_auth是比较好的。在某些情况下,你可能希望将授权信息存储在不同的数据库系统中,或者使用类似JWT的令牌格式。这是一个复杂的话题,需要大量的测试工作,而且不在本教程的讨论范围之内。

设置OAuth 2.0:创建用户类型

我们的应用需要一些 "用户 "的概念,一个登录到应用中管理英雄的人。这个用户将有一个用户名和密码。在后面的练习中,一个用户还将有一个属于他们的英雄列表。创建一个新文件model/user.dart,并输入以下代码。

import 'package:aqueduct/managed_auth.dart';
import 'package:heroes/heroes.dart';
import 'package:heroes/model/hero.dart';

class User extends ManagedObject<_User> implements _User, ManagedAuthResourceOwner<_User> {}

class _User extends ResourceOwnerTableDefinition {}

导入的库package:aqueduct/managed_auth.dart包含使用ORM存储用户,令牌和其他OAuth 2.0相关数据的类型。 这些类型之一是ResourceOwnerTableDefinition,这是我们用户表定义的超类。 此类型包含Aqueduct实施身份验证所需的所有必填字段。

资源所有者

资源所有者是 "用户 "的一个更通用的术语,它来自OAuth 2.0规范。在框架中,你会看到使用资源所有者的一些变体的类型和变量,但就所有意图和目的而言,你可以认为这是一个 "用户"。

如果你很好奇,ResourceOwnerTableDefinition看起来像这样:

class ResourceOwnerTableDefinition {
  @primaryKey
  int id;

  @Column(unique: true, indexed: true)
  String username;

  @Column(omitByDefault: true)
  String hashedPassword;

  @Column(omitByDefault: true)
  String salt;

  ManagedSet<ManagedAuthToken> tokens;
}

由于这些字段位于User的表定义中,因此我们的User表具有所有这些数据库列。

ManagedAuthResourceOwner

请注意,User实现了 ManagedAuthResourceOwner<_User>,当使用package:aqueduct/managed_auth时,这是任何OAuth 2.0资源所有者类型的要求。

设置OAuth 2.0:AuthServer及其代理

现在我们有了一个用户,我们需要某种方式来创建新用户并对其进行身份验证。 身份验证非常棘手,尤其是在OAuth 2.0中,所以有一个服务对象为我们做了困难的部分,叫做AuthServer。 此类型具有认证和授权用户所需的所有逻辑。 例如,如果给定有效的用户凭据,则AuthServer可以生成一个新的token。

channel.dart中,将以下导入添加到文件顶部:

import 'package:aqueduct/managed_auth.dart';
import 'package:heroes/model/user.dart';

然后,在你的channel 中声明一个新的authServer属性,并在prepare中初始化它。

class HeroesChannel extends ApplicationChannel {
  ManagedContext context;

  // Add this field
  AuthServer authServer;

  Future prepare() async {
    logger.onRecord.listen((rec) => print("$rec ${rec.error ?? ""} ${rec.stackTrace ?? ""}"));

    final config = HeroConfig(options.configurationFilePath);
    final dataModel = ManagedDataModel.fromCurrentMirrorSystem();
    final persistentStore = PostgreSQLPersistentStore.fromConnectionInfo(
      config.database.username,
      config.database.password,
      config.database.host,
      config.database.port,
      config.database.databaseName);

    context = ManagedContext(dataModel, persistentStore);

    // Add these two lines:
    final authStorage = ManagedAuthDelegate<User>(context);
    authServer = AuthServer(authStorage);
  }
  ...

虽然AuthServer处理认证和授权的逻辑,但它不知道如何存储或获取它用于这些任务的数据。相反,它依靠一个delegate对象来处理存储和从数据库中获取数据。在我们的应用中,我们使用来自package:aqueduct/managed_authManagedAuthDelegate <T>作为委托。这个类型使用ORM来完成这些任务;泛型参数必须是我们应用程序的用户对象。

委托

委托是一种设计模式,其中一个对象具有多个回调,这些回调被分组到一个接口中。 类型不是为每个回调定义闭包,而是实现委托对象调用的方法。这是一种将大量相关回调组织成一个整洁类的方式。

通过导入aqueduct/managed_auth,我们在应用程序中添加了一些托管对象(用于存储令牌和其他身份验证数据),并且还具有一个新的User托管对象。 现在是运行数据库迁移的好时机。 在您的项目目录中,运行以下命令:

aqueduct db generate
aqueduct db upgrade --connect postgres://heroes_user:password@localhost:5432/heroes

设置OAuth 2.0:注册用户

现在我们有了用户的概念,我们的数据库和应用程序已设置为可以处理身份验证,我们可以开始创建新用户了。 让我们创建一个用于注册用户的新控制器。 该控制器将接受主体中包含用户名和密码的POST请求。 它将新用户插入数据库并对用户的密码进行安全的哈希处理。

在创建此控制器之前,我们需要考虑以下事项:我们的注册端点将需要用户密码,但是我们将用户密码存储为加密哈希。 这样可以防止有权访问您的数据库的人知道用户的密码。 为了将请求的主体绑定到User对象,它需要一个密码字段,但是我们不想在没有先对密码进行哈希处理的情况下将密码存储在数据库中。

我们可以通过瞬态属性来实现。瞬时属性是一个管理对象的属性,它不存储在数据库中。它们是在托管对象子类中声明的,而不是在表定义中。默认情况下,瞬态属性不会从请求体中读取,也不会被编码到响应体中;除非我们为它添加Serialize注解。将此属性添加到你的User类型中:

class User extends ManagedObject<_User> implements _User, ManagedAuthResourceOwner<_User> {
  @Serialize(input: true, output: false)
  String password;
}

这声明了一个User具有瞬态属性 password,可以在输入时(从请求体)读取,但不能在输出时(向响应体)发送。我们不需要运行数据库迁移,因为瞬态属性不存储在数据库中。

现在,创建文件controller/register_controller.dart并输入以下代码。

import 'dart:async';

import 'package:aqueduct/aqueduct.dart';
import 'package:heroes/model/user.dart';

class RegisterController extends ResourceController {
  RegisterController(this.context, this.authServer);

  final ManagedContext context;
  final AuthServer authServer;

  @Operation.post()
  Future<Response> createUser(@Bind.body() User user) async {
    // 在花时间进行哈希处理之前检查所需的参数
    if (user.username == null || user.password == null) {
      return Response.badRequest(
        body: {"error": "username and password required."});
    }

    user
      ..salt = AuthUtility.generateRandomSalt()
      ..hashedPassword = authServer.hashPassword(user.password, user.salt);

    return Response.ok(await Query(context, values: user).insert());
  }
}

这个控制器接受包含用户的POST请求。一个用户有很多字段(username、password、hashhedPassword、salt),但我们将计算后两个字段,只要求请求包含前两个字段。控制器在将密码存储到数据库之前,会生成一个盐和哈希。在channel.dart中,让我们链接这个控制器,别忘了导入它!

import 'package:heroes/controller/register_controller.dart';

...

  @override
  Controller get entryPoint {
    final router = Router();

    router
      .route('/heroes/[:id]')
      .link(() => HeroesController(context));

    router
      .route('/register')
      .link(() => RegisterController(context, authServer));

    return router;
  }
}    

让我们运行该应用程序,并在命令行中使用curl创建一个新用户。 (我们将指定-n1来指定使用一个隔离,并加快启动速度。)

aqueduct serve -n1

然后,向服务器发出一个请求:

curl -X POST http://localhost:8888/register -H 'Content-Type: application/json' -d '{"username":"bob", "password":"password"}'

你将获得新的用户对象及其用户名:

{"id":1,"username":"bob"}

设置OAuth 2.0:验证用户身份

现在我们具有了一个有密码的用户,我们可以创建一个端点,获取用户凭证并返回一个访问令牌。好消息是,这个控制器已经存在于Aqueduct中,你只需要把它挂到一个路由上。更新channel.dart中的entryPoint,为路由/auth/token添加一个AuthController

@override
Controller get entryPoint {
  final router = Router();

  // add this route
  router
    .route('/auth/token')
    .link(() => AuthController(authServer));

  router
    .route('/heroes/[:id]')
    .link(() => HeroesController(context));

  router
    .route('/register')
    .link(() => RegisterController(context, authServer));

  return router;
}

AuthController遵循OAuth 2.0规范,用于在获得有效的用户凭据后授予访问令牌。 要了解必须如何构造对此端点的请求,我们需要讨论OAuth 2.0 clients。 在OAuth 2.0中,客户端是一个允许代表用户访问你的服务器的应用程序。 客户端可以是浏览器应用程序,移动应用程序,另一个服务器,语音助手等。客户端始终具有标识符字符串,通常类似于com.stablekernel.account_app.mobile

进行身份验证时,始终通过客户端对用户进行身份验证。 此客户端信息必须附加到每个身份验证请求,并且服务器必须验证该客户端先前是否已注册。 因此,我们需要为我们的应用程序注册一个新的客户端。 使用aqueduct auth add-client CLI将客户端存储在我们的应用程序数据库中。 从您的项目目录运行以下命令:

aqueduct auth add-client --id com.heroes.tutorial --connect postgres://heroes_user:password@localhost:5432/heroes

OAuth 2.0客户端

客户端必须具有标识符,但它也可能具有密钥、重定向URI和允许范围的列表。 有关这些选项如何影响身份验证的信息,请参见OAuth 2.0指南 。 最值得注意的是,客户端标识符必须有一个密钥才能发出刷新令牌。 客户端存储在应用程序的数据库中。

这将在我们上一轮数据库迁移所创建的OAuth 2.0客户端表中插入一条新的记录,并允许我们进行身份验证请求。一个认证请求必须满足以下所有标准:

  • 客户端标识符(以及密钥,如果存在的话)也作为基本的Authorization头包含在内。
  • 用户名和密码包含在请求体中。
  • 键值 grant_type=password包含在请求体中。
  • 请求体内容类型是application/x-www-form-urlencoded;这意味着请求体实际上是一个查询字符串(例如username=bob&password=pw&grant_type=password)。

在Dart代码中,如下所示:

import 'dart:async';
import 'dart:convert';

import 'package:http/http.dart'
    as http; // Must include http: any package in your pubspec.yaml

Future<void> main() async {
  const clientID = "org.hasenbalg.zeiterfassung";
  const body = "username=bob&password=password&grant_type=password";

// 请注意clientID之后的冒号(:)。
// 客户端标识符密钥会跟在这个后面,这里没有密钥,所以是空字符串。
  final String clientCredentials =
      const Base64Encoder().convert("$clientID:".codeUnits);

  final http.Response response =
      await http.post("http://localhost:8888/auth/token",
          headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            "Authorization": "Basic $clientCredentials"
          },
          body: body);
  print(response.body);
}

您可以执行该代码,也可以使用以下curl

curl -X POST http://localhost:8888/auth/token -H 'Authorization: Basic Y29tLmhlcm9lcy50dXRvcmlhbDo=' -H 'Content-Type: application/x-www-form-urlencoded' -d 'username=bob&password=password&grant_type=password'

如果成功,您将收到以下包含访问令牌的响应:

{"access_token":"687PWKFHRTQ9MveQ2dKvP95D4cWie1gh","token_type":"bearer","expires_in":86399}

拿着这个访问令牌,我们一会儿就会用到它。

设置OAuth 2.0:保护路由

现在我们可以创建和验证用户,我们可以通过要求英雄请求的访问令牌来保护我们的英雄免受匿名用户的影响。在channel.dart中,在/heroes channel中间链接一个Authorizer

router
  .route('/heroes/[:id]')
  .link(() => Authorizer.bearer(authServer))
  .link(() => HeroesController(context));

Authorizer通过验证请求中的Authorization头来保护通道,防止未经授权的请求。当使用Authorizer.bearer创建时,它确保授权头包含一个有效的访问令牌。重新启动您的应用程序,并尝试在不包含任何授权的情况下访问/heroes端点。

curl -X GET --verbose http://localhost:8888/heroes

你会得到一个401 Unauthorized的响应。现在,在一个不记名授权头中包含你的访问令牌(注意,你的令牌会有所不同):

curl -X GET http://localhost:8888/heroes -H 'Authorization: Bearer 687PWKFHRTQ9MveQ2dKvP95D4cWie1gh'

你将获得英雄列表!

Authorizer 的其他用途

Authorizer可以验证访问令牌范围和基本授权凭证。你将在后面的练习中看到这些例子。