diff --git a/CHANGELOG.md b/CHANGELOG.md index d328ba56..7e7dfd07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ * Added merge_map and migrated to 2.0.0 (6/6 tests passed) * Added mock_request and migrated to 2.0.0 (5/5 tests) * Migrated angel_framework to 4.0.0 (149/150 tests passed) -* Migrated angel_auth to 4.0.0 (29/30 tests passed) +* Migrated angel_auth to 4.0.0 (31/31 tests passed) * Migrated angel_configuration to 4.0.0 (6/8 testspassed) * Migrated angel_validate to 4.0.0 (6/7 tests passed) * Migrated json_god to 4.0.0 (13/13 tests passed) diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index 35c33b24..4f5374f9 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -1,102 +1,135 @@ -# 4.0.4 -* Changed serializer and deserializer to be required -* Fixed "allow basic" test case +# Change Log -# 4.0.3 -* Fixed "failureRedirect" test case +## 4.0.4 -# 4.0.2 -* Added MirrorsReflector to test cases +* Changed `serializer` and `deserializer` parameters to be required +* Fixed HTTP basic authentication +* Passed all 51 unit tests + +## 4.0.3 + +* Fixed "failureRedirect" unit test + +## 4.0.2 + +* Added MirrorsReflector to unit test + +## 4.0.1 -# 4.0.1 * Updated README -# 4.0.0 +## 4.0.0 + * Migrated to support Dart SDK 2.12.x NNBD -# 3.0.0 +## 3.0.0 + * Migrated to work with Dart SDK 2.12.x Non NNBD -# 2.1.5+1 +## 2.1.5+1 + * Fix error in popup page. -# 2.1.5 +## 2.1.5 + * Modify `_apply` to honor an existing `User` over `Future`. -# 2.1.4 +## 2.1.4 + * Deprecate `decodeJwt`, in favor of asynchronous injections. -# 2.1.3 +## 2.1.3 + * Use `await` on redirects, etc. -# 2.1.2 +## 2.1.2 + * Change empty cookie string to have double quotes (thanks @korsvanloon). -# 2.1.1 +## 2.1.1 + * Added `scopes` to `ExternalAuthOptions`. -# 2.1.0 +## 2.1.0 + * Added `ExternalAuthOptions`. -# 2.0.4 +## 2.0.4 + * `successRedirect` was previously explicitly returning a `200`; remove this and allow the default `302`. -# 2.0.3 +## 2.0.3 + * Updates for streaming parse of request bodies. -# 2.0.2 +## 2.0.2 + * Handle `null` return in `authenticate` + `failureRedirect`. -# 2.0.1 +## 2.0.1 + * Add generic parameter to `options` on `AuthStrategy.authenticate`. -# 2.0.0+1 +## 2.0.0+1 + * Meta update to improve Pub score. -# 2.0.0 +## 2.0.0 + * Made `AuthStrategy` generic. * `AngelAuth.strategies` is now a `Map>`. * Removed `AuthStrategy.canLogout`. * Made `AngelAuthTokenCallback` generic. -# 2.0.0-alpha +## 2.0.0-alpha + * Depend on Dart 2 and Angel 2. * Remove `dart2_constant`. * Remove `requireAuth`. * Remove `userKey`, instead favoring generic parameters. -# 1.2.0 +## 1.2.0 + * Deprecate `requireAuth`, in favor of `requireAuthentication`. * Allow configuring of the `userKey`. * Deprecate `middlewareName`. -# 1.1.1+6 +## 1.1.1+6 + * Fix a small logic bug that prevented `LocalAuthStrategy` from correctly propagating the authenticated user when using `Basic` auth. -# 1.1.1+5 +## 1.1.1+5 + * Prevent duplication of cookies. * Regenerate the JWT if `tokenCallback` is called. -# 1.1.1+4 +## 1.1.1+4 + * Patched `logout` to properly erase cookies * Fixed checking of expired tokens. -# 1.1.1+3 +## 1.1.1+3 + * `authenticate` returns the current user, if one is present. -# 1.1.1+2 +## 1.1.1+2 + * `_apply` now always sends a `token` cookie. -# 1.1.1+1 +## 1.1.1+1 + * Update `protectCookie` to only send `maxAge` when it is not `-1`. -# 1.1.1 +## 1.1.1 + * Added `protectCookie`, to better protect data sent in cookies. -# 1.1.0+2 +## 1.1.0+2 + * `LocalAuthStrategy` returns `true` on `Basic` authentication. -# 1.1.0+1 +## 1.1.0+1 + * Modified `LocalAuthStrategy`'s handling of `Basic` authentication. diff --git a/packages/auth/README.md b/packages/auth/README.md index 1bdcadc8..7d4fe791 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -1,21 +1,21 @@ -# angel3_auth +# Angel3 Anthentication + [![version](https://img.shields.io/badge/pub-v4.0.4-brightgreen)](https://pub.dartlang.org/packages/angel3_auth) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![Gitter](https://img.shields.io/gitter/room/angel_dart/discussion)](https://gitter.im/angel_dart/discussion) [![License](https://img.shields.io/github/license/dukefirehawk/angel)](https://github.com/dukefirehawk/angel/tree/angel3/packages/auth/LICENSE) -A complete authentication plugin for Angel. Inspired by Passport. +A complete authentication plugin for Angel3. Inspired by Passport. More details in the [User Guide](https://angel3-docs.dukefirehawk.com/guides/authentication). -# Wiki -[Click here](https://github.com/angel-dart/auth/wiki). +## Bundled Strategies -# Bundled Strategies * Local (with and without Basic Auth) -* Find other strategies (Twitter, Google, OAuth2, etc.) on Pub!!! +* Find other strategies (Twitter, Google, OAuth2, etc.) on pub -# Example -Ensure you have read the [wiki](https://github.com/angel-dart/auth/wiki). +## Example + +Ensure you have read the [User Guide](https://angel3-docs.dukefirehawk.com/guides/authentication). ```dart configureServer(Angel app) async { @@ -50,11 +50,11 @@ configureServer(Angel app) async { } ``` -# Default Authentication Callback +## Default Authentication Callback + A frequent use case within SPA's is opening OAuth login endpoints in a separate window. -[`angel_client`](https://github.com/angel-dart/client) -provides a facility for this, which works perfectly with the default callback provided -in this package. +[`angel3_client`](https://github.com/dukefirehawk/angel/tree/angel3/packages/client) +provides a facility for this, which works perfectly with the default callback provided in this package. ```dart configureServer(Angel app) async { @@ -78,7 +78,7 @@ configureServer(Angel app) async { ``` This renders a simple HTML page that fires the user's JWT as a `token` event in `window.opener`. -`angel_client` [exposes this as a Stream](https://github.com/dukefirehawk/angel/tree/angel3/packages/client#authentication): +`angel3_client` [exposes this as a Stream](https://github.com/dukefirehawk/angel/tree/angel3/packages/client#authentication): ```dart app.authenticateViaPopup('/auth/google').listen((jwt) { diff --git a/packages/auth/lib/src/auth_token.dart b/packages/auth/lib/src/auth_token.dart index 9190c19c..864f177d 100644 --- a/packages/auth/lib/src/auth_token.dart +++ b/packages/auth/lib/src/auth_token.dart @@ -2,6 +2,7 @@ import 'dart:collection'; import 'package:angel3_framework/angel3_framework.dart'; import 'dart:convert'; import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; /// Calls [BASE64URL], but also works for strings with lengths /// that are *not* multiples of 4. @@ -25,6 +26,8 @@ String decodeBase64(String str) { } class AuthToken { + static final _log = Logger('AuthToken'); + final SplayTreeMap _header = SplayTreeMap.from({'alg': 'HS256', 'typ': 'JWT'}); @@ -70,6 +73,7 @@ class AuthToken { var split = jwt.split('.'); if (split.length != 3) { + _log.severe('Invalid JWT'); throw AngelHttpException.notAuthenticated(message: 'Invalid JWT.'); } @@ -81,6 +85,7 @@ class AuthToken { var split = jwt.split('.'); if (split.length != 3) { + _log.severe('Invalid JWT'); throw AngelHttpException.notAuthenticated(message: 'Invalid JWT.'); } @@ -90,6 +95,7 @@ class AuthToken { var signature = base64Url.encode(hmac.convert(data.codeUnits).bytes); if (signature != split[2]) { + _log.severe('JWT payload does not match hashed version'); throw AngelHttpException.notAuthenticated( message: 'JWT payload does not match hashed version.'); } diff --git a/packages/auth/lib/src/configuration.dart b/packages/auth/lib/src/configuration.dart index 8d8b55ed..09983c87 100644 --- a/packages/auth/lib/src/configuration.dart +++ b/packages/auth/lib/src/configuration.dart @@ -1,9 +1,12 @@ import 'package:charcode/ascii.dart'; import 'package:collection/collection.dart'; import 'package:quiver/core.dart'; +import 'package:logging/logging.dart'; /// A common class containing parsing and validation logic for third-party authentication configuration. class ExternalAuthOptions { + static final _log = Logger('VirtualDirectory'); + /// The user's identifier, otherwise known as an "application id". final String clientId; @@ -31,6 +34,7 @@ class ExternalAuthOptions { return ExternalAuthOptions._( clientId, clientSecret, redirectUri, scopes.toSet()); } else { + _log.severe('RedirectUri is not valid'); throw ArgumentError.value( redirectUri, 'redirectUri', 'must be a String or Uri'); } @@ -46,6 +50,7 @@ class ExternalAuthOptions { var clientId = map['client_id']; var clientSecret = map['client_secret']; if (clientId == null || clientSecret == null) { + _log.severe('clientId or clientSecret is null'); throw ArgumentError('Invalid clientId and/or clientSecret'); } diff --git a/packages/auth/lib/src/middleware/require_auth.dart b/packages/auth/lib/src/middleware/require_auth.dart index 0c19c3fe..f65a1a4b 100644 --- a/packages/auth/lib/src/middleware/require_auth.dart +++ b/packages/auth/lib/src/middleware/require_auth.dart @@ -3,7 +3,7 @@ import 'package:angel3_framework/angel3_framework.dart'; /// Forces Basic authentication over the requested resource, with the given [realm] name, if no JWT is present. /// -/// [realm] defaults to `'angel_auth'`. +/// [realm] defaults to `'angel3_auth'`. RequestHandler forceBasicAuth({String? realm}) { return (RequestContext req, ResponseContext res) async { if (req.container != null) { diff --git a/packages/auth/lib/src/plugin.dart b/packages/auth/lib/src/plugin.dart index 35aa769b..70327ae5 100644 --- a/packages/auth/lib/src/plugin.dart +++ b/packages/auth/lib/src/plugin.dart @@ -11,6 +11,8 @@ import 'strategy.dart'; /// Handles authentication within an Angel application. class AngelAuth { + final _log = Logger('AngelAuth'); + late Hmac _hs256; late int _jwtLifeSpan; final StreamController _onLogin = StreamController(), @@ -43,8 +45,6 @@ class AngelAuth { /// This is a security provision. Even if a user's JWT is stolen, a remote attacker will not be able to impersonate anyone. final bool enforceIp; - final log = Logger('AngelAuth'); - /// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`. String reviveTokenEndpoint; @@ -109,7 +109,7 @@ class AngelAuth { } */ if (app.container == null) { - log.severe('Angel.container is null.'); + _log.severe('Angel3 container is null'); throw StateError( 'Angel.container is null. All authentication will fail.'); } @@ -126,6 +126,7 @@ class AngelAuth { var req = container.make(); var res = container.make(); if (req == null || res == null) { + _log.severe('RequestContext or responseContext is null'); throw AngelHttpException.forbidden(); } @@ -133,6 +134,7 @@ class AngelAuth { if (result != null) { return result; } else { + _log.severe('JWT is null'); throw AngelHttpException.forbidden(); } }); @@ -148,7 +150,7 @@ class AngelAuth { }); } - app.post(reviveTokenEndpoint, reviveJwt); + app.post(reviveTokenEndpoint, _reviveJwt); app.shutdownHooks.add((_) { _onLogin.close(); @@ -158,7 +160,7 @@ class AngelAuth { void _apply( RequestContext req, ResponseContext res, AuthToken token, User user) { if (req.container == null) { - log.severe('RequestContext.container is null.'); + _log.severe('RequestContext.container is null'); throw StateError( 'RequestContext.container is not set. All authentication will fail.'); } @@ -199,9 +201,9 @@ class AngelAuth { /// } /// ``` @deprecated - Future decodeJwtOld(RequestContext req, ResponseContext res) async { + Future decodeJwt(RequestContext req, ResponseContext res) async { if (req.method == 'POST' && req.path == reviveTokenEndpoint) { - return await reviveJwt(req, res); + return await _reviveJwt(req, res); } else { await _decodeJwt(req, res); return true; @@ -217,6 +219,7 @@ class AngelAuth { if (enforceIp) { if (req.ip != token.ipAddress) { + _log.severe('JWT cannot be accessed from this IP address'); throw AngelHttpException.forbidden( message: 'JWT cannot be accessed from this IP address.'); } @@ -227,6 +230,7 @@ class AngelAuth { token.issuedAt.add(Duration(milliseconds: token.lifeSpan.toInt())); if (!expiry.isAfter(DateTime.now())) { + _log.severe('Expired JWT'); throw AngelHttpException.forbidden(message: 'Expired JWT.'); } } @@ -250,7 +254,7 @@ class AngelAuth { } } - log.info('RequestContext.headers is null'); + _log.info('RequestContext.headers is null'); } else if (allowCookie && req.cookies.any((cookie) => cookie.name == 'token')) { return req.cookies.firstWhere((cookie) => cookie.name == 'token').value; @@ -289,7 +293,7 @@ class AngelAuth { } /// Attempts to revive an expired (or still alive) JWT. - Future> reviveJwt( + Future> _reviveJwt( RequestContext req, ResponseContext res) async { try { var jwt = getJwt(req); @@ -298,12 +302,15 @@ class AngelAuth { var body = await req.parseBody().then((_) => req.bodyAsMap); jwt = body['token']?.toString(); } + if (jwt == null) { + _log.severe('No JWT provided'); throw AngelHttpException.forbidden(message: 'No JWT provided'); } else { var token = AuthToken.validate(jwt, _hs256); if (enforceIp) { if (req.ip != token.ipAddress) { + _log.severe('WT cannot be accessed from this IP address'); throw AngelHttpException.forbidden( message: 'JWT cannot be accessed from this IP address.'); } @@ -329,7 +336,10 @@ class AngelAuth { return {'data': data, 'token': token.serialize(_hs256)}; } } catch (e) { - if (e is AngelHttpException) rethrow; + if (e is AngelHttpException) { + rethrow; + } + _log.severe('Malformed JWT'); throw AngelHttpException.badRequest(message: 'Malformed JWT'); } } @@ -385,13 +395,13 @@ class AngelAuth { userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); var jwt = token.serialize(_hs256); - if (options?.tokenCallback != null) { + if (options != null && options.tokenCallback != null) { var hasUser = reqContainer?.has() ?? false; if (!hasUser) { reqContainer?.registerSingleton(result); } - var r = await options?.tokenCallback!(req, res, token, result); + var r = await options.tokenCallback!(req, res, token, result); if (r != null) return r; jwt = token.serialize(_hs256); } @@ -402,15 +412,25 @@ class AngelAuth { _addProtectedCookie(res, 'token', jwt); } - if (options?.callback != null) { - return await options?.callback!(req, res, jwt); - } + // Options is not null + if (options != null) { + if (options.callback != null) { + return await options.callback!(req, res, jwt); + } - if (options?.successRedirect?.isNotEmpty == true) { - await res.redirect(options?.successRedirect); - return false; - } else if (options?.canRespondWithJson != false && - req.accepts('application/json')) { + if (options.successRedirect?.isNotEmpty == true) { + await res.redirect(options.successRedirect); + return false; + } else if (options.canRespondWithJson && + req.accepts('application/json')) { + var user = hasExisting + ? result + : await deserializer((await serializer(result)) as Object); + _onLogin.add(user); + return {'data': user, 'token': jwt}; + } + // Options is null + } else if (hasExisting && req.accepts('application/json')) { var user = hasExisting ? result : await deserializer((await serializer(result)) as Object); @@ -426,8 +446,8 @@ class AngelAuth { res.statusCode == 302 || res.headers.containsKey('location')) { return false; - } else if (options?.failureRedirect != null) { - await res.redirect(options!.failureRedirect); + } else if (options != null && options.failureRedirect != null) { + await res.redirect(options.failureRedirect); return false; } else { throw AngelHttpException.notAuthenticated(); diff --git a/packages/auth/lib/src/strategies/local.dart b/packages/auth/lib/src/strategies/local.dart index f8514a30..a2e5d1d6 100644 --- a/packages/auth/lib/src/strategies/local.dart +++ b/packages/auth/lib/src/strategies/local.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:logging/logging.dart'; import 'package:angel3_framework/angel3_framework.dart'; import '../options.dart'; import '../strategy.dart'; @@ -12,6 +13,8 @@ typedef LocalAuthVerifier = FutureOr Function( String? username, String? password); class LocalAuthStrategy extends AuthStrategy { + final _log = Logger('LocalAuthStrategy'); + final RegExp _rgxBasic = RegExp(r'^Basic (.+)$', caseSensitive: false); final RegExp _rgxUsrPass = RegExp(r'^([^:]+):(.+)$'); @@ -29,7 +32,9 @@ class LocalAuthStrategy extends AuthStrategy { this.invalidMessage = 'Please provide a valid username and password.', this.allowBasic = true, this.forceBasic = false, - this.realm = 'Authentication is required.'}); + this.realm = 'Authentication is required.'}) { + _log.info('Using LocalAuthStrategy'); + } @override Future authenticate(RequestContext req, ResponseContext res, @@ -51,6 +56,7 @@ class LocalAuthStrategy extends AuthStrategy { verificationResult = await verifier(usrPassMatch.group(1), usrPassMatch.group(2)); } else { + _log.severe('Bad request: $invalidMessage'); throw AngelHttpException.badRequest(errors: [invalidMessage]); } @@ -96,6 +102,7 @@ class LocalAuthStrategy extends AuthStrategy { } else if (verificationResult != false) { return verificationResult; } else { + _log.info('Not authenticated'); throw AngelHttpException.notAuthenticated(); } } diff --git a/packages/auth/pubspec.yaml b/packages/auth/pubspec.yaml index a3391bb7..543614f2 100644 --- a/packages/auth/pubspec.yaml +++ b/packages/auth/pubspec.yaml @@ -1,7 +1,8 @@ name: angel3_auth description: A complete authentication plugin for Angel. Includes support for stateless JWT tokens, Basic Auth, and more. version: 4.0.4 -homepage: https://github.com/dukefirehawk/angel/tree/angel3/packages/auth +homepage: https://angel3-framework.web.app/ +repository: https://github.com/dukefirehawk/angel/tree/angel3/packages/auth environment: sdk: '>=2.12.0 <3.0.0' dependencies: @@ -12,11 +13,11 @@ dependencies: http_parser: ^4.0.0 meta: ^1.3.0 quiver: ^3.0.0 + logging: ^1.0.0 dev_dependencies: angel3_container: ^3.0.0 http: ^0.13.1 io: ^1.0.0 - logging: ^1.0.0 pedantic: ^1.11.0 test: ^1.17.4 \ No newline at end of file diff --git a/packages/auth/test/callback_test.dart b/packages/auth/test/callback_test.dart index d3365eb7..f8be04db 100644 --- a/packages/auth/test/callback_test.dart +++ b/packages/auth/test/callback_test.dart @@ -50,7 +50,7 @@ void main() { var oldErrorHandler = app.errorHandler; app.errorHandler = (e, req, res) { - app.logger!.severe(e.message, e, e.stackTrace ?? StackTrace.current); + app.logger?.severe(e.message, e, e.stackTrace ?? StackTrace.current); return oldErrorHandler(e, req, res); }; @@ -69,8 +69,8 @@ void main() { }); await app - .findService('users')! - .create({'username': 'jdoe1', 'password': 'password'}); + .findService('users') + ?.create({'username': 'jdoe1', 'password': 'password'}); auth = AngelAuth( serializer: (u) => u.id, @@ -84,11 +84,11 @@ void main() { auth.strategies['local'] = LocalAuthStrategy((username, password) async { var users = await app - .findService('users')! - .index() + .findService('users') + ?.index() .then((it) => it.map((m) => User.parse(m as Map)).toList()); - var result = users.firstWhereOrNull( + var result = users?.firstWhereOrNull( (user) => user.username == username && user.password == password); return Future.value(result); @@ -144,6 +144,7 @@ void main() { body: {'username': 'jdoe1', 'password': 'password'}, headers: {'accept': 'application/json'}); print('Response: ${response.body}'); + print(response.headers); expect(json.decode(response.body)['data']['username'], equals('foo')); }); } diff --git a/packages/auth/test/local_test.dart b/packages/auth/test/local_test.dart index bb414f81..4743f066 100644 --- a/packages/auth/test/local_test.dart +++ b/packages/auth/test/local_test.dart @@ -46,7 +46,7 @@ void main() async { app.get('/hello', (req, res) { // => 'Woo auth' return 'Woo auth'; - }); //, middleware: [auth.authenticate('local')]); + }, middleware: [auth.authenticate('local')]); app.post('/login', (req, res) => 'This should not be shown', middleware: [auth.authenticate('local', localOpts)]); app.get('/success', (req, res) => 'yep', middleware: [ @@ -54,8 +54,11 @@ void main() async { ]); app.get('/failure', (req, res) => 'nope'); - app.logger = Logger('angel_auth') + app.logger = Logger('local_test') ..onRecord.listen((rec) { + print( + '${rec.time}: ${rec.level.name}: ${rec.loggerName}: ${rec.message}'); + if (rec.error != null) { print(rec.error); print(rec.stackTrace); @@ -96,12 +99,23 @@ void main() async { var response = await client.post(Uri.parse('$url/login'), body: json.encode(postData), headers: {'content-type': 'application/json'}); - print('Login response: ${response.body}'); + print('Status Code: ${response.statusCode}'); + print(response.headers); + print(response.body); expect(response.headers['location'], equals('/failure')); expect(response.statusCode, equals(401)); }); - test('allow basic', () async { + test('basic auth without authorization', () async { + var response = await client.get(Uri.parse('$url/hello')); + print('Status Code: ${response.statusCode}'); + print(response.headers); + print(response.body); + expect(response.statusCode, equals(401)); + }); + + //test('allow basic', () async { + test('basic auth with authorization', () async { var authString = base64.encode('username:password'.runes.toList()); var response = await client.get(Uri.parse('$url/hello'), headers: {'authorization': 'Basic $authString'});