From c8444a7c7bde83b7e320a7f6af5f38eda0488ced Mon Sep 17 00:00:00 2001 From: Tobe O Date: Wed, 27 Jun 2018 12:36:31 -0400 Subject: [PATCH] 1.1.1 --- .idea/angel_auth.iml | 1 + .../tests_in_protect_cookie_test_dart.xml | 7 ++ .travis.yml | 5 +- CHANGELOG.md | 3 + analysis_options.yaml | 3 +- lib/src/auth_token.dart | 34 +++++----- lib/src/plugin.dart | 68 ++++++++++++++----- lib/src/strategies/local.dart | 10 +-- pubspec.yaml | 2 +- test/callback_test.dart | 10 +-- test/local_test.dart | 16 +++-- test/protect_cookie_test.dart | 44 ++++++++++++ 12 files changed, 152 insertions(+), 51 deletions(-) create mode 100644 .idea/runConfigurations/tests_in_protect_cookie_test_dart.xml create mode 100644 test/protect_cookie_test.dart diff --git a/.idea/angel_auth.iml b/.idea/angel_auth.iml index eae13016..954fa6c5 100644 --- a/.idea/angel_auth.iml +++ b/.idea/angel_auth.iml @@ -2,6 +2,7 @@ + diff --git a/.idea/runConfigurations/tests_in_protect_cookie_test_dart.xml b/.idea/runConfigurations/tests_in_protect_cookie_test_dart.xml new file mode 100644 index 00000000..5c896404 --- /dev/null +++ b/.idea/runConfigurations/tests_in_protect_cookie_test_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index de2210c9..a9e2c109 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1 +1,4 @@ -language: dart \ No newline at end of file +language: dart +dart: + - dev + - stable \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fabce8a8..2d567846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 1.1.1 +* Added `protectCookie`, to better protect data sent in cookies. + # 1.1.0+2 * `LocalAuthStrategy` returns `true` on `Basic` authentication. diff --git a/analysis_options.yaml b/analysis_options.yaml index 518eb901..eae1e42a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,2 +1,3 @@ analyzer: - strong-mode: true \ No newline at end of file + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/lib/src/auth_token.dart b/lib/src/auth_token.dart index 67b66d83..e19c391a 100644 --- a/lib/src/auth_token.dart +++ b/lib/src/auth_token.dart @@ -1,6 +1,6 @@ import 'dart:collection'; -import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; +import 'package:dart2_constant/convert.dart'; import 'package:crypto/crypto.dart'; /// Calls [BASE64URL], but also works for strings with lengths @@ -21,7 +21,7 @@ String decodeBase64(String str) { throw 'Illegal base64url string!"'; } - return UTF8.decode(BASE64URL.decode(output)); + return utf8.decode(base64Url.decode(output)); } class AuthToken { @@ -39,21 +39,23 @@ class AuthToken { this.lifeSpan: -1, this.userId, DateTime issuedAt, - Map payload: const {}}) { + Map payload: const {}}) { this.issuedAt = issuedAt ?? new DateTime.now(); - this.payload.addAll(payload ?? {}); + this.payload.addAll( + payload?.keys?.fold({}, (out, k) => out..[k.toString()] = payload[k]) ?? + {}); } - factory AuthToken.fromJson(String json) => - new AuthToken.fromMap(JSON.decode(json)); + factory AuthToken.fromJson(String jsons) => + new AuthToken.fromMap(json.decode(jsons) as Map); factory AuthToken.fromMap(Map data) { return new AuthToken( - ipAddress: data["aud"], - lifeSpan: data["exp"], - issuedAt: DateTime.parse(data["iat"]), + ipAddress: data["aud"].toString(), + lifeSpan: data["exp"] as num, + issuedAt: DateTime.parse(data["iat"].toString()), userId: data["sub"], - payload: data["pld"] ?? {}); + payload: data["pld"] as Map ?? {}); } factory AuthToken.parse(String jwt) { @@ -63,7 +65,7 @@ class AuthToken { throw new AngelHttpException.notAuthenticated(message: "Invalid JWT."); var payloadString = decodeBase64(split[1]); - return new AuthToken.fromMap(JSON.decode(payloadString)); + return new AuthToken.fromMap(json.decode(payloadString) as Map); } factory AuthToken.validate(String jwt, Hmac hmac) { @@ -75,21 +77,21 @@ class AuthToken { // var headerString = decodeBase64(split[0]); var payloadString = decodeBase64(split[1]); var data = split[0] + "." + split[1]; - var signature = BASE64URL.encode(hmac.convert(data.codeUnits).bytes); + var signature = base64Url.encode(hmac.convert(data.codeUnits).bytes); if (signature != split[2]) throw new AngelHttpException.notAuthenticated( message: "JWT payload does not match hashed version."); - return new AuthToken.fromMap(JSON.decode(payloadString)); + return new AuthToken.fromMap(json.decode(payloadString) as Map); } String serialize(Hmac hmac) { - var headerString = BASE64URL.encode(JSON.encode(_header).codeUnits); - var payloadString = BASE64URL.encode(JSON.encode(toJson()).codeUnits); + var headerString = base64Url.encode(json.encode(_header).codeUnits); + var payloadString = base64Url.encode(json.encode(toJson()).codeUnits); var data = headerString + "." + payloadString; var signature = hmac.convert(data.codeUnits).bytes; - return data + "." + BASE64URL.encode(signature); + return data + "." + base64Url.encode(signature); } Map toJson() { diff --git a/lib/src/plugin.dart b/lib/src/plugin.dart index 04460fb3..fc56d993 100644 --- a/lib/src/plugin.dart +++ b/lib/src/plugin.dart @@ -12,7 +12,7 @@ import 'strategy.dart'; /// Handles authentication within an Angel application. class AngelAuth { Hmac _hs256; - num _jwtLifeSpan; + int _jwtLifeSpan; final StreamController _onLogin = new StreamController(), _onLogout = new StreamController(); Math.Random _random = new Math.Random.secure(); @@ -24,13 +24,22 @@ class AngelAuth { /// If `true` (default), then users can include a JWT in the query string as `token`. final bool allowTokenInQuery; + /// Whether emitted cookies should have the `secure` and `HttpOnly` flags, + /// as well as being restricted to a specific domain. + final bool secureCookies; + + /// A domain to restrict emitted cookies to. + /// + /// Only applies if [secureCookies] is `true`. + final String cookieDomain; + /// The name to register [requireAuth] as. Default: `auth`. String middlewareName; /// If `true` (default), then JWT's will be considered invalid if used from a different IP than the first user's it was issued to. /// /// This is a security provision. Even if a user's JWT is stolen, a remote attacker will not be able to impersonate anyone. - bool enforceIp; + final bool enforceIp; /// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`. String reviveTokenEndpoint; @@ -62,17 +71,20 @@ class AngelAuth { return new String.fromCharCodes(chars); } + /// `jwtLifeSpan` - should be in *milliseconds*. AngelAuth( {String jwtKey, num jwtLifeSpan, this.allowCookie: true, this.allowTokenInQuery: true, this.enforceIp: true, + this.cookieDomain, + this.secureCookies: true, this.middlewareName: 'auth', this.reviveTokenEndpoint: "/auth/token"}) : super() { _hs256 = new Hmac(sha256, (jwtKey ?? _randomString()).codeUnits); - _jwtLifeSpan = jwtLifeSpan ?? -1; + _jwtLifeSpan = jwtLifeSpan?.toInt() ?? -1; } Future configureServer(Angel app) async { @@ -121,7 +133,7 @@ class AngelAuth { } if (token.lifeSpan > -1) { - token.issuedAt.add(new Duration(milliseconds: token.lifeSpan)); + token.issuedAt.add(new Duration(milliseconds: token.lifeSpan.toInt())); if (!token.issuedAt.isAfter(new DateTime.now())) throw new AngelHttpException.forbidden(message: "Expired JWT."); @@ -146,20 +158,40 @@ class AngelAuth { req.cookies.any((cookie) => cookie.name == "token")) { return req.cookies.firstWhere((cookie) => cookie.name == "token").value; } else if (allowTokenInQuery && req.query['token'] is String) { - return req.query['token']; + return req.query['token']?.toString(); } return null; } + /// Applies security protections to a [cookie]. + Cookie protectCookie(Cookie cookie) { + if (secureCookies != false) { + cookie.httpOnly = true; + cookie.secure = true; + cookie.domain ??= cookieDomain; + } + + cookie.maxAge ??= + _jwtLifeSpan < 0 ? -1 : _jwtLifeSpan ~/ Duration.millisecondsPerSecond; + + if (_jwtLifeSpan > 0) { + cookie.expires ??= + new DateTime.now().add(new Duration(milliseconds: _jwtLifeSpan)); + } + + return cookie; + } + /// Attempts to revive an expired (or still alive) JWT. - Future> reviveJwt(RequestContext req, ResponseContext res) async { + Future> reviveJwt( + RequestContext req, ResponseContext res) async { try { var jwt = getJwt(req); if (jwt == null) { var body = await req.lazyBody(); - jwt = body['token']; + jwt = body['token']?.toString(); } if (jwt == null) { throw new AngelHttpException.forbidden(message: "No JWT provided"); @@ -172,7 +204,7 @@ class AngelAuth { } if (token.lifeSpan > -1) { - token.issuedAt.add(new Duration(milliseconds: token.lifeSpan)); + token.issuedAt.add(new Duration(milliseconds: token.lifeSpan.toInt())); if (!token.issuedAt.isAfter(new DateTime.now())) { print( @@ -183,7 +215,8 @@ class AngelAuth { } if (allowCookie) - res.cookies.add(new Cookie('token', token.serialize(_hs256))); + res.cookies + .add(protectCookie(new Cookie('token', token.serialize(_hs256)))); final data = await deserializer(token.userId); return {'data': data, 'token': token.serialize(_hs256)}; @@ -207,7 +240,7 @@ class AngelAuth { List names = []; var arr = type is Iterable ? type.toList() : [type]; - for (var t in arr) { + for (String t in arr) { var n = t .split(',') .map((s) => s.trim()) @@ -227,7 +260,7 @@ class AngelAuth { if (result == true) return result; else if (result != false) { - var userId = await serializer(result); + var userId = await serializer(result as T); // Create JWT var token = new AuthToken( @@ -242,7 +275,8 @@ class AngelAuth { _apply(req, token, result); - if (allowCookie) res.cookies.add(new Cookie("token", jwt)); + if (allowCookie) + res.cookies.add(protectCookie(new Cookie("token", jwt))); if (options?.callback != null) { return await options.callback(req, res, jwt); @@ -256,7 +290,7 @@ class AngelAuth { (req.headers.value("accept").contains("application/json") || req.headers.value("accept").contains("*/*") || req.headers.value("accept").contains("application/*"))) { - var user = await deserializer(await serializer(result)); + var user = await deserializer(await serializer(result as T)); _onLogin.add(user); return {"data": user, "token": jwt}; } @@ -283,7 +317,8 @@ class AngelAuth { _onLogin.add(user); if (allowCookie) - res.cookies.add(new Cookie('token', token.serialize(_hs256))); + res.cookies + .add(protectCookie(new Cookie('token', token.serialize(_hs256)))); } /// Log a user in on-demand. @@ -295,7 +330,8 @@ class AngelAuth { _onLogin.add(user); if (allowCookie) - res.cookies.add(new Cookie('token', token.serialize(_hs256))); + res.cookies + .add(protectCookie(new Cookie('token', token.serialize(_hs256)))); } /// Log an authenticated user out. @@ -314,7 +350,7 @@ class AngelAuth { } var user = req.grab('user'); - if (user != null) _onLogout.add(user); + if (user != null) _onLogout.add(user as T); req.injections..remove(AuthToken)..remove('user'); req.properties.remove('user'); diff --git a/lib/src/strategies/local.dart b/lib/src/strategies/local.dart index 1483da82..e6b5a7ac 100644 --- a/lib/src/strategies/local.dart +++ b/lib/src/strategies/local.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:convert'; +import 'package:dart2_constant/convert.dart'; import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; import '../options.dart'; @@ -50,7 +50,7 @@ class LocalAuthStrategy extends AuthStrategy { if (_rgxBasic.hasMatch(authHeader)) { String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1); String authString = - new String.fromCharCodes(BASE64.decode(base64AuthString)); + new String.fromCharCodes(base64.decode(base64AuthString)); if (_rgxUsrPass.hasMatch(authString)) { Match usrPassMatch = _rgxUsrPass.firstMatch(authString); verificationResult = @@ -73,10 +73,10 @@ class LocalAuthStrategy extends AuthStrategy { if (verificationResult == null) { await req.parse(); - if (_validateString(req.body[usernameField]) && - _validateString(req.body[passwordField])) { + if (_validateString(req.body[usernameField]?.toString()) && + _validateString(req.body[passwordField]?.toString())) { verificationResult = - await verifier(req.body[usernameField], req.body[passwordField]); + await verifier(req.body[usernameField]?.toString(), req.body[passwordField]?.toString()); } } diff --git a/pubspec.yaml b/pubspec.yaml index e61d1d33..e48c9bbc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: angel_auth description: A complete authentication plugin for Angel. -version: 1.1.0+2 +version: 1.1.1 author: Tobe O homepage: https://github.com/angel-dart/angel_auth environment: diff --git a/test/callback_test.dart b/test/callback_test.dart index 654ea123..e1796cc5 100644 --- a/test/callback_test.dart +++ b/test/callback_test.dart @@ -13,6 +13,7 @@ class User extends Model { main() { Angel app; + AngelHttp angelHttp; AngelAuth auth; http.Client client; HttpServer server; @@ -20,14 +21,15 @@ main() { setUp(() async { app = new Angel(); + angelHttp = new AngelHttp(app, useZone: false); app.use('/users', new TypedService(new MapService())); await app .service('users') .create({'username': 'jdoe1', 'password': 'password'}); - auth = new AngelAuth(); - auth.serializer = (User user) async => user.id; + auth = new AngelAuth(); + auth.serializer = (u) => u.id; auth.deserializer = app.service('users').read; await app.configure(auth.configureServer); @@ -52,13 +54,13 @@ main() { }))); client = new http.Client(); - server = await app.startServer(); + server = await angelHttp.startServer(); url = 'http://${server.address.address}:${server.port}'; }); tearDown(() async { client.close(); - await server.close(force: true); + await angelHttp.close(); app = null; client = null; url = null; diff --git a/test/local_test.dart b/test/local_test.dart index 8c611a96..d8433a03 100644 --- a/test/local_test.dart +++ b/test/local_test.dart @@ -1,13 +1,13 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_auth/angel_auth.dart'; +import 'package:dart2_constant/convert.dart'; import 'package:http/http.dart' as http; import 'package:test/test.dart'; final AngelAuth auth = new AngelAuth(); -Map headers = {HttpHeaders.ACCEPT: ContentType.JSON.mimeType}; +var headers = {HttpHeaders.ACCEPT: ContentType.JSON.mimeType}; AngelAuthOptions localOpts = new AngelAuthOptions( failureRedirect: '/failure', successRedirect: '/success'); Map sampleUser = {'hello': 'world'}; @@ -30,6 +30,7 @@ Future wireAuth(Angel app) async { main() async { Angel app; + AngelHttp angelHttp; http.Client client; String url; String basicAuthUrl; @@ -37,6 +38,7 @@ main() async { setUp(() async { client = new http.Client(); app = new Angel(); + angelHttp = new AngelHttp(app, useZone: false); await app.configure(wireAuth); app.get('/hello', 'Woo auth', middleware: [auth.authenticate('local')]); app.post('/login', 'This should not be shown', @@ -45,14 +47,14 @@ main() async { app.get('/failure', "nope"); HttpServer server = - await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); + await angelHttp.startServer('127.0.0.1', 0); url = "http://${server.address.host}:${server.port}"; basicAuthUrl = "http://username:password@${server.address.host}:${server.port}"; }); tearDown(() async { - await app.httpServer.close(force: true); + await angelHttp.close(); client = null; url = null; basicAuthUrl = null; @@ -68,7 +70,7 @@ main() async { test('successRedirect', () async { Map postData = {'username': 'username', 'password': 'password'}; var response = await client.post("$url/login", - body: JSON.encode(postData), + body: json.encode(postData), headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType}); expect(response.statusCode, equals(200)); expect(response.headers[HttpHeaders.LOCATION], equals('/success')); @@ -77,7 +79,7 @@ main() async { test('failureRedirect', () async { Map postData = {'username': 'password', 'password': 'username'}; var response = await client.post("$url/login", - body: JSON.encode(postData), + body: json.encode(postData), headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType}); print("Login response: ${response.body}"); expect(response.headers[HttpHeaders.LOCATION], equals('/failure')); @@ -85,7 +87,7 @@ main() async { }); test('allow basic', () async { - String authString = BASE64.encode("username:password".runes.toList()); + String authString = base64.encode("username:password".runes.toList()); var response = await client.get("$url/hello", headers: {HttpHeaders.AUTHORIZATION: 'Basic $authString'}); expect(response.body, equals('"Woo auth"')); diff --git a/test/protect_cookie_test.dart b/test/protect_cookie_test.dart new file mode 100644 index 00000000..e8a1cb84 --- /dev/null +++ b/test/protect_cookie_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:angel_auth/angel_auth.dart'; +import 'package:test/test.dart'; + +const Duration threeDays = const Duration(days: 3); + +void main() { + Cookie defaultCookie; + var auth = new AngelAuth( + secureCookies: true, + cookieDomain: 'SECURE', + jwtLifeSpan: threeDays.inMilliseconds, + ); + + setUp(() => defaultCookie = new Cookie('a', 'b')); + + test('sets maxAge', () { + expect(auth.protectCookie(defaultCookie).maxAge, threeDays.inSeconds); + }); + + test('sets expires', () { + var now = new DateTime.now(); + var expiry = auth.protectCookie(defaultCookie).expires; + var diff = expiry.difference(now); + expect(diff.inSeconds, threeDays.inSeconds); + }); + + test('sets httpOnly', () { + expect(auth.protectCookie(defaultCookie).httpOnly, true); + }); + + test('sets secure', () { + expect(auth.protectCookie(defaultCookie).secure, true); + }); + + test('sets domain', () { + expect(auth.protectCookie(defaultCookie).domain, 'SECURE'); + }); + + test('preserves domain if present', () { + expect(auth.protectCookie(defaultCookie..domain = 'foo').domain, 'foo'); + }); +}