Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic management of tokens for API in general #34

Open
wants to merge 7 commits into
base: feed-test
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ios/Flutter/Debug.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
1 change: 1 addition & 0 deletions ios/Flutter/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
81 changes: 81 additions & 0 deletions lib/src/misc/cookieManager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Our own implementation of Dio cookie manger
* Modified from: https://github.com/flutterchina/dio/blob/master/plugins/cookie_manager/lib/src/cookie_mgr.dart
*
* What changed? We store cookie in baseUrl instead of full Uri to allow using
* cookie across the app (with requests many different routes)
*/

import 'dart:async';
import 'dart:io';

import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';

/// Don't use this class in Browser environment
class CookieManager extends Interceptor {
/// Cookie manager for http requests。Learn more details about
/// CookieJar please refer to [cookie_jar](https://github.com/flutterchina/cookie_jar)
final CookieJar cookieJar;

CookieManager(this.cookieJar);

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
cookieJar.loadForRequest(options.uri).then((cookies) {
var cookie = getCookies(cookies);
if (cookie.isNotEmpty) {
options.headers[HttpHeaders.cookieHeader] = cookie;
}
handler.next(options);
}).catchError((e, stackTrace) {
var err = DioError(requestOptions: options, error: e);
err.stackTrace = stackTrace;
handler.reject(err, true);
});
}

@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_saveCookies(response)
.then((_) => handler.next(response))
.catchError((e, stackTrace) {
var err = DioError(requestOptions: response.requestOptions, error: e);
err.stackTrace = stackTrace;
handler.reject(err, true);
});
}

@override
void onError(DioError err, ErrorInterceptorHandler handler) {
if (err.response != null) {
_saveCookies(err.response!)
.then((_) => handler.next(err))
.catchError((e, stackTrace) {
var _err = DioError(
requestOptions: err.response!.requestOptions,
error: e,
);
_err.stackTrace = stackTrace;
handler.next(_err);
});
} else {
handler.next(err);
}
}

Future<void> _saveCookies(Response response) async {
var cookies = response.headers[HttpHeaders.setCookieHeader];

if (cookies != null) {
await cookieJar.saveFromResponse(
response.requestOptions.uri,
cookies.map((str) => Cookie.fromSetCookieValue(str)).toList(),
);
}
}

static String getCookies(List<Cookie> cookies) {
return cookies.map((cookie) => '${cookie.name}=${cookie.value}').join('; ');
}
}
14 changes: 4 additions & 10 deletions lib/src/providers/feedApi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:developer';
import 'dart:io';

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:oneplace_illinois/src/misc/config.dart';
import 'package:oneplace_illinois/src/misc/exceptions.dart';
import 'package:oneplace_illinois/src/models/feedItem.dart';
Expand All @@ -13,17 +14,10 @@ class FeedAPI {
Future<List<FeedItem>> getFeed(ApiService api) async {
Uri uri = Uri.http(Config.baseEndpoint!, feedListPath);

final response = await api.get(uri);
if (response.statusCode != 200) {
throw HttpException(
response.reasonPhrase ?? response.statusCode.toString());
}
Map<String, dynamic> data = jsonDecode(response.body);
if (data['error'] != null) {
log(data.toString());
throw ApiException(data['error']);
}
final client = await api.getClientWithAuth();
final data = await client.get('/feed/list', queryParameters: {}); // qs for later

// NOTE: Does this catch case of empty feed list?
List<FeedItem> feedItems =
data['payload'].map<FeedItem>((e) => FeedItem.fromJSON(e)).toList();
feedItems.sort((a, b) => a.postDate.compareTo(b.postDate));
Expand Down
116 changes: 96 additions & 20 deletions lib/src/services/api.dart
Original file line number Diff line number Diff line change
@@ -1,45 +1,121 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';

import 'package:http/http.dart';
import 'package:http/io_client.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
//import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:oneplace_illinois/src/misc/config.dart';
import 'package:oneplace_illinois/src/misc/exceptions.dart';
import 'package:oneplace_illinois/src/misc/cookieManager.dart';
import 'package:oneplace_illinois/src/services/firebaseAuth.dart';

class ApiService extends IOClient {
static final _loginUri = Uri.http(Config.baseEndpoint!, '/api/v1/user/login');
static const _refreshCookieName = 'refresh_jwt';
class ApiService {
// NOTE: Signed cookie doesn't work with HTTP request. Only works with HTTPS.
static final _baseUrl = Uri.https(Config.baseEndpoint!, '');

static PersistCookieJar _cookieJar = new PersistCookieJar(ignoreExpires: false);
/* // use default settings for now
static Future<PersistCookieJar> get cookieJar async {
if (_cookieJar == null) {
Directory appDocDir = await getApplicationDocumentsDirectory();
String appDocPath = appDocDir.path;
_cookieJar = new PersistCookieJar({
ignoreExpires: false, // don't save/load expired cookies
storage: appDocPath + '/.cookies/'
});
}
return _cookieJar;
}
*/

final storage = new FlutterSecureStorage(); // for storing access token

FirebaseAuthService _firebaseAuth;
String? _refreshToken;
String? _accessToken;

ApiService({required firebaseAuth}) : this._firebaseAuth = firebaseAuth;

@override
Future<IOStreamedResponse> send(BaseRequest request) async {
if (_refreshToken == null || _accessToken == null) {

Future<Dio> getClient() async {
final Dio _dio = Dio();

_dio.options.baseUrl = '$_baseUrl/api/v1';
_dio.interceptors.clear();

_dio.interceptors.add(InterceptorsWrapper(
onResponse: (Response response, handler) async {
var data = response.data;

if (data['error'] != null) {
log(data.toString());
throw ApiException(data['error']);
}

// save access token (refresh token is auto-saved by CookieManager above)
if (data['payload']?['accessToken'] != null && data['payload']?['accessToken'] != '') {
await storage.write(key: 'jwt_access', value: data['payload']?['accessToken']);
}

return data;
},
));

return _dio;
Comment on lines +40 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should just go with getClientWithAuth alone, since it's probably good to make all our API requests authenticated even if not yet necessary (maybe validation is added later)?

Copy link
Member

@AndrewLester AndrewLester Aug 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think there's a way to extend the Dio client, which would make ApiService itself available for running gets, posts, etc. with the credentials automatically applied. See here: https://pub.dev/packages/dio#extends-dio-class

Not sure if this will do what we need it do though, as the docs aren't good in this section. It may allow for the removal of the getClient method though, which I think makes things clearer when the code is used.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to use just getClientWithAuth, then we need to modify it to allow request even if there is no access token (for /section/search and /course/search). Extending the Dio class would be nice, but I don't see the need for it at the moment. If you are able to do it without much hassle, sure go for it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we extend the class, we could pass an additional option (part of options) to distinguish between auth & no-auth requests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah extending the Dio class may be as much of a hassle as extending IOClient is. Does running a /section/search with some query parameters and an access token cause an error?

If it does, then I suppose we will need to have separate code paths for authenticated/unauthenticated requests. I propose changing the method getClient to use a keyword argument credentials or auth or something that can be true or false.

}

Future<Dio> _getClientWithAuth() async {
// retrieve access token
String? _accessToken = await storage.read(key: 'jwt_access');

if (_accessToken == null) {
// interceptor from _getClient() used by _login() will auto-save
// the new access & refresh token (for later requests)
await _login();
}

request.headers.addAll({
'Authorization': 'Bearer ${_accessToken!}',
'Cookie': '$_refreshCookieName=${_refreshToken!}',
});
final Dio _dio = Dio();

_dio.options.baseUrl = '$_baseUrl/api/v1';
_dio.interceptors.clear();

// Add cookie (refresh token) to interceptor
_dio.interceptors.add(CookieManager(_cookieJar));

_dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options, handler) {
// Add access token to interceptor
options.headers['Authorization'] = 'Bearer ' + _accessToken!;
handler.next(options);
},
onResponse: (Response response, handler) async {
var data = response.data;

return super.send(request);
if (data['error'] != null) {
log(data.toString());
throw ApiException(data['error']);
}

// Save access token if new one is issued from the server
if (data['payload'] && data['payload']['accessToken']) {
await storage.write(key: 'jwt_access', value: data['payload']['accessToken']);
}

// return response;
return data;
}
));
return _dio;
}

Future<void> _login() async {
var user = await _firebaseAuth.userStream.first;
Response response = await post(_loginUri, body: {

Dio client = await getClient();

Response response = await client.post('/user/login', data: {
'email': user!.email,
'token': await user.getIdToken(),
});

var cookies = response.headers['set-cookie']!;
_refreshToken = cookies.split(';')[0].split('=')[1];
_accessToken = jsonDecode(response.body)['payload']['accessToken'];
}
}
34 changes: 17 additions & 17 deletions lib/src/services/courseApi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:developer';
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:oneplace_illinois/src/misc/config.dart';
import 'package:oneplace_illinois/src/models/courseItem.dart';
import 'package:oneplace_illinois/src/models/sectionItem.dart';
Expand All @@ -10,15 +11,15 @@ import 'package:oneplace_illinois/src/services/api.dart';
class CourseAPI {
Future<List<CourseItem>?> getCourses(ApiService api, String query,
{bool onlyCourses = false}) async {
Uri uri = Uri.http(Config.baseEndpoint!, '/api/v1/course/search',
{"keyword": query, "only_courses": onlyCourses.toString()});
final response = await api.get(uri);

if (response.statusCode != 200) {
throw HttpException(
response.reasonPhrase ?? response.statusCode.toString());
}
List<dynamic> data = jsonDecode(response.body)['payload']['courses'];

Dio client = await api.getClient();

// NOTE: only_courses accept boolean (prev was onlyCourses.toString())
final body = await client.get('/course/search', queryParameters: {
'keyword': query, 'only_courses': onlyCourses
});

List<dynamic> data = body['courses'];
List<CourseItem> courses =
data.map((e) => CourseItem.fromJSON(e, onlyCourses)).toList();
courses.sort((a, b) => a.compareTo([query, b]));
Expand All @@ -29,16 +30,15 @@ class CourseAPI {
List<String> codeSections = fullCode.split('_');
String code = codeSections[0];
String crn = codeSections[1];
Uri uri = Uri.http(Config.baseEndpoint!, '/api/v1/section/search',
{'code': code, 'CRN': crn});

final response = await api.get(uri);
if (response.statusCode != 200) {
throw HttpException(
response.reasonPhrase ?? response.statusCode.toString());
}
Dio client = await api.getClient();

final body = await client.get('/section/search', queryParameters: {
'code': code, 'CRN': crn,
});

dynamic data = jsonDecode(response.body)['payload']['sections'][0];
// Since we provided CRN, only one section is returned
dynamic data = body['sections'][0];
SectionItem section = SectionItem.fromJSON(data);
return section;
}
Expand Down
21 changes: 21 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0"
cookie_jar:
dependency: "direct main"
description:
name: cookie_jar
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
csslib:
dependency: transitive
description:
Expand All @@ -92,6 +99,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
dio:
dependency: "direct main"
description:
name: dio
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
fake_async:
dependency: transitive
description:
Expand Down Expand Up @@ -181,6 +195,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.5"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.1"
flutter_spinkit:
dependency: "direct main"
description:
Expand Down
4 changes: 4 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ dependencies:
permission_handler: ^8.1.4+2
path_provider: ^2.0.2
tuple: ^2.0.0
dio: ^4.0.0
cookie_jar: ^3.0.1
#dio_cookie_manager: ^1.0.0 # depends on dio: ^3.0.0
flutter_secure_storage: ^4.2.1

dev_dependencies:
flutter_test:
Expand Down