Dart-aqueduct框架后台开发(2)-用户管理,swagger文档生成

引言

本章主要介绍通过 dart 的 aqueduct 框架来实现简单的用户管理以及标准的 OAuth2.0 验证。笔者会尽可能的遵循restful规范。

由于 aqueduct 使用到了注解,而注解是会用到反射的一个东西,目前个人在尝试中发现用到了 dart:mirro 就无法通过 dart2native 进行 aot 编译来提高运行性能。

开始吧!

连接数据库

上面一篇文章我们创建了数据库并用 navicat进行了连接。

相关介绍

控制器

控制器即 ControllerControllerhandle函数的异步类型为RequestOrResponse,也就是说可能返回一个请求或者响应。
返回响应相当于拦截了这次请求,直接在Controller内部处理数据给了客户端,而返回请求相当于将客户端的请求交给之后的控制器。

资源控制器

资源控制器即ResourceController,是Controller的子类,负责响应请求,处理数据给客户端,在资源控制器后就不能再有控制器能接收到请求。

实现接口头验证

从个人以往的一些经验,有的接口在访问的时候会用自己实现的一个 header 来实现头部验证,可能是为了防止 api 泄露后被恶意操作,我这里也模拟了这样一个头验证。

初始工程的 channel.dart 是这样

1
2
3
4
5
6
7
8
9
10
@override
Controller get entryPoint {
final router = Router();
router
.route("/example")
.linkFunction((request) async {
return Response.ok({"key": "value"});
});
return router;
}

这样一个有初始代码的服务器启动后,对本机 127.0.0.1:9000/example 进行访问都会返回{"key": "value"}

实际地址以配置为准

添加验证头

新建文件validate_controller,写入以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

class ValidateController extends Controller {
@override
FutureOr<RequestOrResponse> handle(Request request) async {
final headers = request.raw.headers;
final apiKey = headers.value('API-Key');
// print('apikey $apiKey');
if (apiKey == null) {
final Response response = Response.unauthorized();
response.contentType = ContentType.json; //内容类型
response.body = {'code': -1, 'error': '未发现秘钥'}; //内容
return response;
}
if (apiKey == Config.apiKey) {
return request;
}
final Response response = Response.unauthorized();
response.contentType = ContentType.json; //内容类型
response.body = {'code': -1, 'error': '秘钥不正确'}; //内容
return response;
}
}

同时更改channel.dart

1
2
3
4
5
6
router
.route("/example")
.link(() => ValidateController())
.linkFunction((request) async {
return Response.ok({"key": "value"});
});

当header中API-Key的值为空的时候,返回的是一个响应,这个响应会直接到客户端,也就是说这个时候服务端已经返回了,对/example的请求就不会再有后续的响应,Response.unauthorized的状态码为401。body 是自己简单返回的信息。

当客户端传过来API-Key的值与服务端相等的时候, 函数返回的是一个请求 request,这个 request 会交给之后的link,也就是

1
2
3
linkFunction((request) async {
return Response.ok({"key": "value"});
});

整个过程相当于是一个对请求的拦截。

测试一下

用户注册

新建RegisterController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class RegisterController extends ResourceController {
RegisterController(this.context);

final ManagedContext context;

@Operation.post()
FutureOr<Response> register(
@Bind.body(ignore: ["createData", 'mobile']) User newUser,
) async {
if (newUser.username == null) {
final Response response = Response.badRequest(); //404的状态码
response.contentType = ContentType.json; //内容类型
response.body = {'code': -1, 'error': '缺少用户名'}; //内容
return response;
}
if (newUser.password == null) {
final Response response = Response.badRequest(); //404的状态码
response.contentType = ContentType.json; //内容类型
response.body = {'code': -1, 'error': '缺少密码'}; //内容
return response;
}
newUser.createDate = DateTime.now();
newUser.hashedPassword = AuthUtility.generatePasswordHash(
newUser.password,
newUser.salt,
);
print('newUser.hashedPassword ->${newUser.hashedPassword}');
final query = Query<User>(context)
..where((a) => a.username).equalTo(newUser.username);
final user = await query.fetchOne();
if (user != null) {
final Response response = Response.badRequest(); //404的状态码
response.contentType = ContentType.json; //内容类型
response.body = {'code': -1, 'error': '该账号已被注册'}; //内容
return response;
}
final dbResult = await context.insertObject<User>(newUser);
final Map<String, dynamic> result = dbResult.asMap();
result.remove('password');
return Response.ok(result);
// Response.badRequest()
}
}

@Operation.post()代表响应post请求,接口功能为通过usernamepassword注册一个用户。
其中dbResult也是一个User实体类,得到Map后移除password字段后返回给客户端。

User在上篇文章

随后修改channel.dart

1
2
3
4
router
.route("/register")
.link(() => ValidateController())
.link(() => RegisterController(context));

由于注册其实也是操作用户数据库,更应该拦截/user这个路由,后面解释为什么注册为单独的一个接口。

运行测试

再发送一次

用户登录

使用的是文档中提到的OAuth2.0验证,就我的理解简单阐述一下,通过一个令牌交换的方式,客户端添加一个Basic token,去交换服务端的Bearer token
而操作整个用户数据库对应的某一行用的就是Bearer token

新建AuthController

这个类是从aqueduct库拷贝出来修改的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import 'package:server/server.dart';

import '../model/user_info.dart';

///
class AuthController extends ResourceController {
AuthController(this.authServer, this.context) {
acceptedContentTypes = [
ContentType("application", "x-www-form-urlencoded")
];
}

final ManagedContext context;
final AuthServer authServer;
@Bind.header(HttpHeaders.authorizationHeader)
String authHeader;

final AuthorizationBasicParser _parser = const AuthorizationBasicParser();
@Operation.post()
Future<Response> grant(
{@Bind.query("username") String username,
@Bind.query("password") String password,
@Bind.query("refresh_token") String refreshToken,
@Bind.query("code") String authCode,
@Bind.query("grant_type") String grantType,
@Bind.query("scope") String scope}) async {
print('authHeader ->$authHeader');
if (authHeader == null) {
return Response.badRequest(
body: {"error": '缺少basic header'},
);
}
final userQuery = Query<User>(context)
..where((a) => a.username).equalTo(username);
final user = await userQuery.fetchOne();
print('user->$user');
if (user == null) {
return Response.badRequest(
body: {"error": '该账户未注册'},
);
}
if (user.password != password) {
return Response.badRequest(
body: {"error": '账号或密码错误'},
);
}
AuthBasicCredentials basicRecord;
try {
basicRecord = _parser.parse(authHeader);
} on AuthorizationParserException catch (_) {
return _responseForError(AuthRequestError.invalidClient);
}

try {
final scopes = scope?.split(" ")?.map((s) => AuthScope(s))?.toList();

if (grantType == "password") {
final token = await authServer.authenticate(
username, password, basicRecord.username, basicRecord.password,
requestedScopes: scopes);
final query = Query<User>(context)
..where((a) => a.username).equalTo(username);
final localUser = await query.fetchOne();

final Map<String, dynamic> result = localUser.asMap();
result.remove('password');
result['token'] = token.accessToken;
return Response.ok(result);
// return CustomAuthController.tokenResponse(token);
} else if (grantType == "refresh_token") {
final token = await authServer.refresh(
refreshToken, basicRecord.username, basicRecord.password,
requestedScopes: scopes);

return AuthController.tokenResponse(token);
} else if (grantType == "authorization_code") {
if (scope != null) {
return _responseForError(AuthRequestError.invalidRequest);
}

final token = await authServer.exchange(
authCode, basicRecord.username, basicRecord.password);

return AuthController.tokenResponse(token);
} else if (grantType == null) {
return _responseForError(AuthRequestError.invalidRequest);
}
} on FormatException {
return _responseForError(AuthRequestError.invalidScope);
} on AuthServerException catch (e) {
return _responseForError(e.reason);
}

return _responseForError(AuthRequestError.unsupportedGrantType);
}

static Response tokenResponse(AuthToken token) {
return Response(HttpStatus.ok,
{"Cache-Control": "no-store", "Pragma": "no-cache"}, token.asMap());
}

@override
void willSendResponse(Response response) {
if (response.statusCode == 400) {
final body = response.body;
if (body != null && body["error"] is String) {
final errorMessage = body["error"] as String;
if (errorMessage.startsWith("multiple values")) {
response.body = {
"error":
AuthServerException.errorString(AuthRequestError.invalidRequest)
};
}
}
}
}

@override
List<APIParameter> documentOperationParameters(
APIDocumentContext context, Operation operation) {
final parameters = super.documentOperationParameters(context, operation);
parameters.removeWhere((p) => p.name == HttpHeaders.authorizationHeader);
return parameters;
}

@override
APIRequestBody documentOperationRequestBody(
APIDocumentContext context, Operation operation) {
final body = super.documentOperationRequestBody(context, operation);
body.content["application/x-www-form-urlencoded"].schema.required = [
"grant_type"
];
body.content["application/x-www-form-urlencoded"].schema
.properties["password"].format = "password";
return body;
}

@override
Map<String, APIOperation> documentOperations(
APIDocumentContext context, String route, APIPath path) {
final operations = super.documentOperations(context, route, path);

operations.forEach((_, op) {
op.security = [
APISecurityRequirement({"oauth2-client-authentication": []})
];
});

final relativeUri = Uri(path: route.substring(1));
authServer.documentedAuthorizationCodeFlow.tokenURL = relativeUri;
authServer.documentedAuthorizationCodeFlow.refreshURL = relativeUri;

authServer.documentedPasswordFlow.tokenURL = relativeUri;
authServer.documentedPasswordFlow.refreshURL = relativeUri;

return operations;
}

@override
Map<String, APIResponse> documentOperationResponses(
APIDocumentContext context, Operation operation) {
return {
"200": APIResponse.schema(
"Successfully exchanged credentials for token",
APISchemaObject.object({
"access_token": APISchemaObject.string(),
"token_type": APISchemaObject.string(),
"expires_in": APISchemaObject.integer(),
"refresh_token": APISchemaObject.string(),
"scope": APISchemaObject.string()
}),
contentTypes: ["application/json"]),
"400": APIResponse.schema("Invalid credentials or missing parameters.",
APISchemaObject.object({"error": APISchemaObject.string()}),
contentTypes: ["application/json"])
};
}

Response _responseForError(AuthRequestError error) {
return Response.badRequest(
body: {"error": AuthServerException.errorString(error)});
}
}

Basic header 生成方法

带秘钥

1
2
3
4
5
6
var clientID = "123";
var clientSecret = "456";
var clientCredentials =
Base64Encoder().convert("$clientID:$clientSecret".codeUnits);
var header = "Basic $clientCredentials";
print(header);

不带秘钥

1
2
3
4
5
var clientID = "123";
var clientCredentials =
Base64Encoder().convert("$clientID:".codeUnits);
var header = "Basic $clientCredentials";
print(header);

这个header就是Basicheader,注意不带秘钥的生成:还是跟在id后面。

随后需要同步数据库

1
2
3
pub run aqueduct auth add-client \
--id 123 \
--secret 456

修改 channel.dart

1
2
3
4
5
6
7
    final delegate = ManagedAuthDelegate<User>(context);
authServer = AuthServer(delegate);
***
router
.route("/login")
.link(() => ValidateController())
.link(() => AuthController(authServer, context));

客户端配置

添加Authorization

contont-type需要改为application/x-www-form-urlencoded

运行测试

查询用户

这个时候就需要登录拿到的Bearer token

修改 channel

1
2
3
4
5
router
.route("/user")
.link(() => ValidateController())
.link(() => Authorizer.bearer(authServer))
.link(() => UserController(context));

目前的整个channel.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import 'package:aqueduct/managed_auth.dart';
import 'package:server/controller/server_controller.dart';

import 'app_config.dart';
import 'controller/auth_controller.dart';
import 'controller/file_controller.dart';
import 'controller/register_controller.dart';
import 'controller/script_controller.dart';
import 'controller/user_controller.dart';
import 'controller/validate_controller.dart';
import 'model/user_info.dart';
import 'server.dart' hide AuthController, FileController;
class ServerChannel extends ApplicationChannel {
ManagedContext context; //可通过该实例操作数据库
AuthServer authServer;
@override
Future prepare() async {
logger.onRecord.listen(
(rec) => print("$rec ${rec.error ?? ""} ${rec.stackTrace ?? ""}"));
//执行初始化任务的方法
final AppConfig _config = AppConfig(options.configurationFilePath);
options.port = _config.port;
//new
final dataModel = ManagedDataModel.fromCurrentMirrorSystem(); //描述应用程序的数据模型
final psc = PostgreSQLPersistentStore.fromConnectionInfo(
_config.database.username,
_config.database.password,
_config.database.host,
_config.database.port,
_config.database.databaseName); //管理与单个数据库的连接
context = ManagedContext(dataModel, psc);
final delegate = ManagedAuthDelegate<User>(context);
authServer = AuthServer(delegate);

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

router
.route("/register")
.link(() => ValidateController())
.link(() => RegisterController(context));
router
.route("/login")
.link(() => ValidateController())
.link(() => AuthController(authServer, context));
router
.route("/user")
.link(() => ValidateController())
.link(() => Authorizer.bearer(authServer))
.link(() => UserController(context));
return router;
}
}

可以看到添加了Authorizer.bearer(authServer)这个控制器去拦截请求,也是去验证header中Authorization这个key,判断客户端提交的Bearer token是否等于登陆后服务端返回的Bearer token,如果不通过,内部处理了请求,如果验证通过,控制器交给UserController继续处理。

新建UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class UserController extends ResourceController {
UserController(this.context);
final ManagedContext context;

@Operation.get()
Future<Response> getUserInfo() async {
final forUserID = request.authorization.ownerID;
//根据id查询一条数据
final query = Query<User>(context)..where((a) => a.id).equalTo(forUserID);
final article = await query.fetchOne();
// article.password = '';
if (article != null) {
return Response.ok(article);
} else {
final Response response = Response.badRequest(); //404的状态码
response.contentType = ContentType.json; //内容类型
response.body = {'code': -1, 'error': '未找到该用户'}; //内容
}
}

在用户数据库中,用户 id 为数据库的主键,这个时候我们不需要通过/user/$id的方式去访问对应的用户,只需要通过设置Bearer token的头。
随后可通过request.authorization.ownerID直接拿到这个用户 id。(在验证通过的情况下)

删除

之后就比较简单了
添加到UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  @Operation.delete() //删除一篇文章
Future<Response> deleteUser() async {
final forUserID = request.authorization.ownerID;
final query = Query<User>(context)..where((a) => a.id).equalTo(forUserID);
//删除一条数据
final result = await query.delete();
if (result != null && result == 1) {
return Response.ok({'msg': '删除成功'});
} else {
final Response response = Response.badRequest(); //404的状态码
response.contentType = ContentType.json; //内容类型
response.body = {'code': -1, 'error': '删除失败'}; //内容
return response;
}
}

修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  @Operation.put()
Future<Response> updateUserInfo(
@Bind.body(ignore: ["createData"]) User user,
) async {
final forUserID = request.authorization.ownerID;
final query = Query<User>(context)
..values.role = user.role
..values.username = user.username
..values.email = user.email
..values.mobile = user.mobile
..values.qq = user.qq
..where((a) => a.id).equalTo(forUserID);
//更新一条数据
final result = await query.updateOne();
// final article = await query.fetchOne();
if (result != null) {
return Response.ok({'msg': '更新成功'});
} else {
return Response.ok({'msg': '更新失败'});
}
}

修改接口的客户端需要将需要修改的字段写进 body,content-type的类型为application/json
服务端会自动使用反射将受到的 json 转为User实体对象作为updateUserInfo函数的参数 ignore 代表忽略的字段,参数是一个字符串列表。

增删查改都有了,细节部分读者可自行修改,文章主要简单讲解实现,代码已经把一些会遇到的坑都处理了。
注册为什么不是 post /user 而是 post /register ?
因为 /user 是有 beaere token 的一个验证控制器拦截的,而注册的时候不需要任何的用户 token.

作者

梦魇兽

发布于

2021-02-20

更新于

2023-03-11

许可协议

评论