引言 本章主要介绍通过 dart 的 aqueduct 框架来实现简单的用户管理以及标准的 OAuth2.0 验证。笔者会尽可能的遵循restful
规范。
由于 aqueduct 使用到了注解,而注解是会用到反射的一个东西,目前个人在尝试中发现用到了 dart:mirro 就无法通过 dart2native 进行 aot 编译来提高运行性能。
开始吧!
连接数据库 上面一篇文章我们创建了数据库并用 navicat进行了连接。
相关介绍 控制器 控制器即 Controller
,Controller
中handle
函数的异步类型为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' ); 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(); response.contentType = ContentType.json; response.body = {'code' : -1 , 'error' : '缺少用户名' }; return response; } if (newUser.password == null ) { final Response response = Response.badRequest(); 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(); 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); } }
@Operation.post()
代表响应post
请求,接口功能为通过username
和password
注册一个用户。 其中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); } 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)}); } }
带秘钥 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
就是Basic
header,注意不带秘钥的生成:
还是跟在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; 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); } @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; final query = Query<User>(context)..where((a) => a.id).equalTo(forUserID); final article = await query.fetchOne(); if (article != null ) { return Response.ok(article); } else { final Response response = Response.badRequest(); 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(); 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(); 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.