From 5c925c481d725731d62e3ac1a80742f4043c9a43 Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Sun, 15 Dec 2024 03:59:26 -0700 Subject: [PATCH] add: adding auth package --- packages/auth/.gitignore | 72 +++ packages/auth/AUTHORS.md | 12 + packages/auth/CHANGELOG.md | 193 +++++++ packages/auth/LICENSE | 29 + packages/auth/README.md | 84 +++ packages/auth/analysis_options.yaml | 1 + .../auth/example/client/example_client.http | 22 + packages/auth/example/example.dart | 38 ++ packages/auth/example/example1.dart | 113 ++++ packages/auth/example/example2.dart | 69 +++ packages/auth/lib/auth.dart | 10 + packages/auth/lib/auth_token.dart | 4 + packages/auth/lib/src/auth_token.dart | 138 +++++ packages/auth/lib/src/configuration.dart | 137 +++++ .../auth/lib/src/middleware/require_auth.dart | 51 ++ packages/auth/lib/src/options.dart | 30 + packages/auth/lib/src/plugin.dart | 518 ++++++++++++++++++ packages/auth/lib/src/popup_page.dart | 36 ++ packages/auth/lib/src/strategies/local.dart | 138 +++++ .../auth/lib/src/strategies/strategies.dart | 1 + packages/auth/lib/src/strategy.dart | 10 + packages/auth/pubspec.yaml | 35 ++ packages/auth/test/auth_token_test.dart | 34 ++ packages/auth/test/callback_test.dart | 153 ++++++ packages/auth/test/config_test.dart | 164 ++++++ packages/auth/test/local_test.dart | 153 ++++++ packages/auth/test/protect_cookie_test.dart | 45 ++ packages/testing/test/all_test.dart | 4 +- 28 files changed, 2292 insertions(+), 2 deletions(-) create mode 100644 packages/auth/.gitignore create mode 100644 packages/auth/AUTHORS.md create mode 100644 packages/auth/CHANGELOG.md create mode 100644 packages/auth/LICENSE create mode 100644 packages/auth/README.md create mode 100644 packages/auth/analysis_options.yaml create mode 100644 packages/auth/example/client/example_client.http create mode 100644 packages/auth/example/example.dart create mode 100644 packages/auth/example/example1.dart create mode 100644 packages/auth/example/example2.dart create mode 100644 packages/auth/lib/auth.dart create mode 100644 packages/auth/lib/auth_token.dart create mode 100644 packages/auth/lib/src/auth_token.dart create mode 100644 packages/auth/lib/src/configuration.dart create mode 100644 packages/auth/lib/src/middleware/require_auth.dart create mode 100644 packages/auth/lib/src/options.dart create mode 100644 packages/auth/lib/src/plugin.dart create mode 100644 packages/auth/lib/src/popup_page.dart create mode 100644 packages/auth/lib/src/strategies/local.dart create mode 100644 packages/auth/lib/src/strategies/strategies.dart create mode 100644 packages/auth/lib/src/strategy.dart create mode 100644 packages/auth/pubspec.yaml create mode 100644 packages/auth/test/auth_token_test.dart create mode 100644 packages/auth/test/callback_test.dart create mode 100644 packages/auth/test/config_test.dart create mode 100644 packages/auth/test/local_test.dart create mode 100644 packages/auth/test/protect_cookie_test.dart diff --git a/packages/auth/.gitignore b/packages/auth/.gitignore new file mode 100644 index 0000000..02256dd --- /dev/null +++ b/packages/auth/.gitignore @@ -0,0 +1,72 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.dart_tool +.packages +.pub/ +build/ + +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ + +### Dart template +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub + +# SDK 1.20 and later (no longer creates packages directories) + +# Older SDK versions +# (Include if the minimum SDK version specified in pubsepc.yaml is earlier than 1.20) +.project +.buildlog +**/packages/ + + +# Files created by dart2js +# (Most Dart developers will use pub build to compile Dart, use/modify these +# rules if you intend to use dart2js directly +# Convention is to use extension '.dart.js' for Dart compiled to Javascript to +# differentiate from explicit Javascript files) +*.dart.js +*.part.js +*.js.deps +*.js.map +*.info.json + +# Directory created by dartdoc + +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: + +## VsCode +.vscode/ + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +.idea/ +/out/ +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.DS_Store diff --git a/packages/auth/AUTHORS.md b/packages/auth/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/auth/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md new file mode 100644 index 0000000..d428c80 --- /dev/null +++ b/packages/auth/CHANGELOG.md @@ -0,0 +1,193 @@ +# Change Log + +## 8.2.0 + +* Require Dart >= 3.3 +* Updated `lints` to 4.0.0 + +## 8.1.1 + +* Updated repository link + +## 8.1.0 + +* Updated `lints` to 3.0.0 + +## 8.0.0 + +* Require Dart >= 3.0 +* Upgraded `http` to 1.0.0 +* Fixed failed `successRedirect` test case +* Fixed failed `failureRedirect` test case +* Fixed failed `login` test case +* Fixed failed `force basic` test case +* Added `example1` and `example2` + +## 7.0.1 + +* Fixed linter warnings + +## 7.0.0 + +* Require Dart >= 2.17 + +## 6.0.0 + +* Require Dart >= 2.16 + +## 5.0.0 + +* Skipped release + +## 4.1.2 + +* Fixed `requireAuthentication` to work correctly with null-safety type + +## 4.1.1 + +* Changed `userId` field of `AuthToken` to String type +* Changed `serializer` return value to String type +* Changed `deserializer` input parameter to String type + +## 4.1.0 + +* Updated linter to `package:lints` + +## 4.0.5 + +* Added support for verifier function to return an empty Map instead of null +* Fixed `canRespondWithJson` option to return data in the response body when set to true + +## 4.0.4 + +* Changed `serializer` and `deserializer` parameters to be required +* Fixed HTTP basic authentication +* All 31 unit tests passed + +## 4.0.3 + +* Fixed "failureRedirect" unit test + +## 4.0.2 + +* Added MirrorsReflector to unit test + +## 4.0.1 + +* Updated README + +## 4.0.0 + +* Migrated to support Dart >= 2.12 NNBD + +## 3.0.0 + +* Migrated to work with Dart >= 2.12 Non NNBD + +## 2.1.5+1 + +* Fix error in popup page. + +## 2.1.5 + +* Modify `_apply` to honor an existing `User` over `Future`. + +## 2.1.4 + +* Deprecate `decodeJwt`, in favor of asynchronous injections. + +## 2.1.3 + +* Use `await` on redirects, etc. + +## 2.1.2 + +* Change empty cookie string to have double quotes (thanks @korsvanloon). + +## 2.1.1 + +* Added `scopes` to `ExternalAuthOptions`. + +## 2.1.0 + +* Added `ExternalAuthOptions`. + +## 2.0.4 + +* `successRedirect` was previously explicitly returning a `200`; remove this and allow the default `302`. + +## 2.0.3 + +* Updates for streaming parse of request bodies. + +## 2.0.2 + +* Handle `null` return in `authenticate` + `failureRedirect`. + +## 2.0.1 + +* Add generic parameter to `options` on `AuthStrategy.authenticate`. + +## 2.0.0+1 + +* Meta update to improve Pub score. + +## 2.0.0 + +* Made `AuthStrategy` generic. +* `PlatformAuth.strategies` is now a `Map>`. +* Removed `AuthStrategy.canLogout`. +* Made `AngelAuthTokenCallback` generic. + +## 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 + +* Deprecate `requireAuth`, in favor of `requireAuthentication`. +* Allow configuring of the `userKey`. +* Deprecate `middlewareName`. + +## 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 + +* Prevent duplication of cookies. +* Regenerate the JWT if `tokenCallback` is called. + +## 1.1.1+4 + +* Patched `logout` to properly erase cookies +* Fixed checking of expired tokens. + +## 1.1.1+3 + +* `authenticate` returns the current user, if one is present. + +## 1.1.1+2 + +* `_apply` now always sends a `token` cookie. + +## 1.1.1+1 + +* Update `protectCookie` to only send `maxAge` when it is not `-1`. + +## 1.1.1 + +* Added `protectCookie`, to better protect data sent in cookies. + +## 1.1.0+2 + +* `LocalAuthStrategy` returns `true` on `Basic` authentication. + +## 1.1.0+1 + +* Modified `LocalAuthStrategy`'s handling of `Basic` authentication. diff --git a/packages/auth/LICENSE b/packages/auth/LICENSE new file mode 100644 index 0000000..df5e063 --- /dev/null +++ b/packages/auth/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 0000000..492c9f8 --- /dev/null +++ b/packages/auth/README.md @@ -0,0 +1,84 @@ +# Angel3 Anthentication + +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/platform_auth?include_prereleases) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![Discord](https://img.shields.io/discord/1060322353214660698)](https://discord.gg/3X6bxTUdCM) +[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/auth/LICENSE) + +A complete authentication plugin for Angel3. Inspired by Passport. More details in the [User Guide](https://angel3-docs.dukefirehawk.com/guides/authentication). + +## Bundled Strategies + +* Local (with and without Basic Auth) +* Find other strategies (Twitter, Google, OAuth2, etc.) on pub + +## Example + +Ensure you have read the [User Guide](https://angel3-docs.dukefirehawk.com/guides/authentication). + +```dart +configureServer(Angel app) async { + var auth = PlatformAuth( + serializer: (user) => user.id ?? '', + deserializer: (id) => fetchAUserByIdSomehow(id + ); + auth.strategies['local'] = LocalAuthStrategy(...); + + // POST route to handle username+password + app.post('/local', auth.authenticate('local')); + + // Using Angel's asynchronous injections, we can parse the JWT + // on demand. It won't be parsed until we check. + app.get('/profile', ioc((User user) { + print(user.description); + })); + + // Use a comma to try multiple strategies!!! + // + // Each strategy is run sequentially. If one succeeds, the loop ends. + // Authentication failures will just cause the loop to continue. + // + // If the last strategy throws an authentication failure, then + // a `401 Not Authenticated` is thrown. + var chainedHandler = auth.authenticate( + ['basic','facebook'], + authOptions + ); + + // Apply angel_auth-specific configuration. + await app.configure(auth.configureServer); +} +``` + +## Default Authentication Callback + +A frequent use case within SPA's is opening OAuth login endpoints in a separate window. [`angel3_client`](https://pub.dev/packages/angel3_client) provides a facility for this, which works perfectly with the default callback provided in this package. + +```dart +configureServer(Angel app) async { + var handler = auth.authenticate( + 'facebook', + AngelAuthOptions(callback: confirmPopupAuthentication())); + app.get('/auth/facebook', handler); + + // Use a comma to try multiple strategies!!! + // + // Each strategy is run sequentially. If one succeeds, the loop ends. + // Authentication failures will just cause the loop to continue. + // + // If the last strategy throws an authentication failure, then + // a `401 Not Authenticated` is thrown. + var chainedHandler = auth.authenticate( + ['basic','facebook'], + authOptions + ); +} +``` + +This renders a simple HTML page that fires the user's JWT as a `token` event in `window.opener`. `angel3_client` [exposes this as a Stream](https://pub.dev/documentation/angel3_client/latest/): + +```dart +app.authenticateViaPopup('/auth/google').listen((jwt) { + // Do something with the JWT +}); +``` diff --git a/packages/auth/analysis_options.yaml b/packages/auth/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/packages/auth/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/auth/example/client/example_client.http b/packages/auth/example/client/example_client.http new file mode 100644 index 0000000..330cae0 --- /dev/null +++ b/packages/auth/example/client/example_client.http @@ -0,0 +1,22 @@ +### Load landing page +GET http://localhost:3000/ HTTP/1.1 + +### login (call_back) +POST http://localhost:3000/login HTTP/1.1 +Content-Type: application/json +Authorization: Basic jdoe1:password + +### Success redirect (local) +POST http://localhost:3000/login HTTP/1.1 +Content-Type: application/json +Authorization: Basic username:password + +### Failure redirect (local) +POST http://localhost:3000/login HTTP/1.1 +Content-Type: application/json +Authorization: Basic password:username + +### Force basic +GET http://localhost:3000/hello HTTP/1.1 +Content-Type: application/json +Accept:application/json \ No newline at end of file diff --git a/packages/auth/example/example.dart b/packages/auth/example/example.dart new file mode 100644 index 0000000..d99d201 --- /dev/null +++ b/packages/auth/example/example.dart @@ -0,0 +1,38 @@ +import 'dart:async'; +import 'package:platform_auth/auth.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; + +void main() async { + var app = Application(); + var auth = PlatformAuth( + serializer: (user) => user.id ?? '', + deserializer: (id) => fetchAUserByIdSomehow(id)); + + // Middleware to decode JWT's and inject a user object... + await app.configure(auth.configureServer); + + auth.strategies['local'] = LocalAuthStrategy((username, password) { + // Retrieve a user somehow... + // If authentication succeeds, return a User object. + // + // Otherwise, return `null`. + return null; + }); + + app.post('/auth/local', auth.authenticate('local')); + + var http = PlatformHttp(app); + await http.startServer('127.0.0.1', 3000); + + print('Listening at http://127.0.0.1:3000'); +} + +class User { + String? id, username, password; +} + +Future fetchAUserByIdSomehow(String id) async { + // Fetch a user somehow... + throw UnimplementedError(); +} diff --git a/packages/auth/example/example1.dart b/packages/auth/example/example1.dart new file mode 100644 index 0000000..2aff28d --- /dev/null +++ b/packages/auth/example/example1.dart @@ -0,0 +1,113 @@ +import 'package:platform_auth/auth.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:io/ansi.dart'; +import 'package:logging/logging.dart'; +import 'package:collection/collection.dart'; + +class User extends Model { + String? username, password; + + User({this.username, this.password}); + + static User parse(Map map) { + return User( + username: map['username'], + password: map['password'], + ); + } + + Map toJson() { + return { + 'id': id, + 'username': username, + 'password': password, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String() + }; + } +} + +/* + * Backend for callback test cases + */ +void main() async { + hierarchicalLoggingEnabled = true; + + Application app = Application(reflector: MirrorsReflector()); + PlatformHttp angelHttp = PlatformHttp(app); + app.use('/users', MapService()); + + var oldErrorHandler = app.errorHandler; + app.errorHandler = (e, req, res) { + app.logger.severe(e.message, e, e.stackTrace ?? StackTrace.current); + return oldErrorHandler(e, req, res); + }; + + app.logger = Logger('platform_auth') + ..level = Level.FINEST + ..onRecord.listen((rec) { + print(rec); + + if (rec.error != null) { + print(yellow.wrap(rec.error.toString())); + } + + if (rec.stackTrace != null) { + print(yellow.wrap(rec.stackTrace.toString())); + } + }); + + await app + .findService('users') + ?.create({'username': 'jdoe1', 'password': 'password'}); + + var auth = PlatformAuth( + serializer: (u) => u.id ?? '', + deserializer: (id) async => + await app.findService('users')?.read(id) as User); + //auth.serializer = (u) => u.id; + //auth.deserializer = + // (id) async => await app.findService('users')!.read(id) as User; + + await app.configure(auth.configureServer); + + auth.strategies['local'] = LocalAuthStrategy((username, password) async { + var users = await app + .findService('users') + ?.index() + .then((it) => it.map((m) => User.parse(m)).toList()); + + var result = users?.firstWhereOrNull( + (user) => user.username == username && user.password == password); + + return Future.value(result); + }, allowBasic: true); + + app.post( + '/login', + auth.authenticate('local', AngelAuthOptions(callback: (req, res, token) { + res + ..write('Hello!') + ..close(); + }))); + + app.get('/', (req, res) => res.write("Hello")); + + app.chain([ + (req, res) { + if (!req.container!.has()) { + req.container!.registerSingleton( + User(username: req.params['name']?.toString())); + } + return true; + } + ]).post( + '/existing/:name', + auth.authenticate('local'), + ); + + await angelHttp.startServer('127.0.0.1', 3000); +} diff --git a/packages/auth/example/example2.dart b/packages/auth/example/example2.dart new file mode 100644 index 0000000..b308e43 --- /dev/null +++ b/packages/auth/example/example2.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'package:platform_auth/auth.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; +import 'package:logging/logging.dart'; + +final Map sampleUser = {'hello': 'world'}; + +final PlatformAuth> auth = + PlatformAuth>( + serializer: (user) async => '1337', + deserializer: (id) async => sampleUser); +//var headers = {'accept': 'application/json'}; +var localOpts = AngelAuthOptions>( + failureRedirect: '/failure', successRedirect: '/success'); +var localOpts2 = + AngelAuthOptions>(canRespondWithJson: false); + +Future> verifier(String? username, String? password) async { + if (username == 'username' && password == 'password') { + return sampleUser; + } else { + return {}; + } +} + +Future wireAuth(Application app) async { + //auth.strategies['local'] = LocalAuthStrategy(verifier); + auth.strategies['local'] = + LocalAuthStrategy(verifier, forceBasic: true, realm: 'test'); + await app.configure(auth.configureServer); +} + +/* + * Backend for local test cases + */ +void main() async { + Application app = Application(reflector: MirrorsReflector()); + PlatformHttp angelHttp = PlatformHttp(app, useZone: false); + await app.configure(wireAuth); + + app.get('/hello', (req, res) { + // => 'Woo auth' + return 'Woo auth'; + }, middleware: [auth.authenticate('local', localOpts2)]); + + app.post('/login', (req, res) => 'This should not be shown', + middleware: [auth.authenticate('local', localOpts)]); + + app.get('/success', (req, res) => 'yep', middleware: [ + requireAuthentication>(), + ]); + + app.get('/failure', (req, res) => 'nope'); + + 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); + } + }); + + await angelHttp.startServer('127.0.0.1', 3000); +} diff --git a/packages/auth/lib/auth.dart b/packages/auth/lib/auth.dart new file mode 100644 index 0000000..c9ea4a9 --- /dev/null +++ b/packages/auth/lib/auth.dart @@ -0,0 +1,10 @@ +library platform_auth; + +export 'src/middleware/require_auth.dart'; +export 'src/strategies/strategies.dart'; +export 'src/auth_token.dart'; +export 'src/configuration.dart'; +export 'src/options.dart'; +export 'src/plugin.dart'; +export 'src/popup_page.dart'; +export 'src/strategy.dart'; diff --git a/packages/auth/lib/auth_token.dart b/packages/auth/lib/auth_token.dart new file mode 100644 index 0000000..01ea1da --- /dev/null +++ b/packages/auth/lib/auth_token.dart @@ -0,0 +1,4 @@ +/// Stand-alone JWT library. +library platform_auth.auth_token; + +export 'src/auth_token.dart'; diff --git a/packages/auth/lib/src/auth_token.dart b/packages/auth/lib/src/auth_token.dart new file mode 100644 index 0000000..1f14dfb --- /dev/null +++ b/packages/auth/lib/src/auth_token.dart @@ -0,0 +1,138 @@ +import 'dart:collection'; +import 'package:platform_foundation/core.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. +String decodeBase64(String str) { + var output = str.replaceAll('-', '+').replaceAll('_', '/'); + + switch (output.length % 4) { + case 0: + break; + case 2: + output += '=='; + break; + case 3: + output += '='; + break; + default: + throw 'Illegal base64url string!"'; + } + + return utf8.decode(base64Url.decode(output)); +} + +class AuthToken { + static final _log = Logger('AuthToken'); + + final SplayTreeMap _header = + SplayTreeMap.from({'alg': 'HS256', 'typ': 'JWT'}); + + String? ipAddress; + num lifeSpan; + String userId; + late DateTime issuedAt; + Map payload = {}; + + AuthToken( + {this.ipAddress, + this.lifeSpan = -1, + required this.userId, + DateTime? issuedAt, + Map? payload}) { + this.issuedAt = issuedAt ?? DateTime.now(); + if (payload != null) { + this.payload.addAll(payload.keys + .fold({}, ((out, k) => out?..[k.toString()] = payload[k])) ?? + {}); + } + } + + factory AuthToken.fromJson(String jsons) => + AuthToken.fromMap(json.decode(jsons) as Map); + + factory AuthToken.fromMap(Map data) { + return AuthToken( + ipAddress: data['aud'].toString(), + lifeSpan: data['exp'] as num, + issuedAt: DateTime.parse(data['iat'].toString()), + userId: data['sub'], + payload: data['pld']); + } + + factory AuthToken.parse(String jwt) { + var split = jwt.split('.'); + + if (split.length != 3) { + _log.warning('Invalid JWT'); + throw PlatformHttpException.notAuthenticated(message: 'Invalid JWT.'); + } + + var payloadString = decodeBase64(split[1]); + return AuthToken.fromMap( + json.decode(payloadString) as Map); + } + + factory AuthToken.validate(String jwt, Hmac hmac) { + var split = jwt.split('.'); + + if (split.length != 3) { + _log.warning('Invalid JWT'); + throw PlatformHttpException.notAuthenticated(message: 'Invalid JWT.'); + } + + // 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); + + if (signature != split[2]) { + _log.warning('JWT payload does not match hashed version'); + throw PlatformHttpException.notAuthenticated( + message: 'JWT payload does not match hashed version.'); + } + + return 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 data = '$headerString.$payloadString'; + var signature = hmac.convert(data.codeUnits).bytes; + return '$data.${base64Url.encode(signature)}'; + } + + Map toJson() { + return _splayify({ + 'iss': 'angel_auth', + 'aud': ipAddress, + 'exp': lifeSpan, + 'iat': issuedAt.toIso8601String(), + 'sub': userId, + 'pld': _splayify(payload) + }); + } +} + +Map _splayify(Map map) { + var data = {}; + map.forEach((k, v) { + data[k] = _splay(v); + }); + return SplayTreeMap.from(data); +} + +dynamic _splay(dynamic value) { + if (value is Iterable) { + return value.map(_splay).toList(); + } else if (value is Map) { + return _splayify(value as Map); + } else { + return value; + } +} diff --git a/packages/auth/lib/src/configuration.dart b/packages/auth/lib/src/configuration.dart new file mode 100644 index 0000000..b0371dc --- /dev/null +++ b/packages/auth/lib/src/configuration.dart @@ -0,0 +1,137 @@ +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; + + /// The user's secret, other known as an "application secret". + final String clientSecret; + + /// The user's redirect URI. + final Uri redirectUri; + + /// The scopes to be passed to the external server. + final Set scopes; + + ExternalAuthOptions._( + this.clientId, this.clientSecret, this.redirectUri, this.scopes); + + factory ExternalAuthOptions( + {required String clientId, + required String clientSecret, + required redirectUri, + Iterable scopes = const []}) { + if (redirectUri is String) { + return ExternalAuthOptions._( + clientId, clientSecret, Uri.parse(redirectUri), scopes.toSet()); + } else if (redirectUri is Uri) { + 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'); + } + } + + /// Returns a JSON-friendly representation of this object. + /// + /// Parses the following fields: + /// * `client_id` + /// * `client_secret` + /// * `redirect_uri` + factory ExternalAuthOptions.fromMap(Map map) { + 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'); + } + + return ExternalAuthOptions( + clientId: clientId, + clientSecret: clientSecret, + redirectUri: map['redirect_uri'], + scopes: map['scopes'] is Iterable + ? ((map['scopes'] as Iterable).map((x) => x.toString())) + : [], + ); + } + + @override + int get hashCode => hash4(clientId, clientSecret, redirectUri, scopes); + + @override + bool operator ==(other) => + other is ExternalAuthOptions && + other.clientId == clientId && + other.clientSecret == other.clientSecret && + other.redirectUri == other.redirectUri && + const SetEquality().equals(other.scopes, scopes); + + /// Creates a copy of this object, with the specified changes. + ExternalAuthOptions copyWith( + {String? clientId, + String? clientSecret, + redirectUri, + Iterable scopes = const []}) { + return ExternalAuthOptions( + clientId: clientId ?? this.clientId, + clientSecret: clientSecret ?? this.clientSecret, + redirectUri: redirectUri ?? this.redirectUri, + scopes: (scopes).followedBy(this.scopes), + ); + } + + /// Returns a JSON-friendly representation of this object. + /// + /// Contains the following fields: + /// * `client_id` + /// * `client_secret` + /// * `redirect_uri` + /// + /// If [obscureSecret] is `true` (default), then the [clientSecret] will + /// be replaced by the string ``. + Map toJson({bool obscureSecret = true}) { + return { + 'client_id': clientId, + 'client_secret': obscureSecret ? '' : clientSecret, + 'redirect_uri': redirectUri.toString(), + 'scopes': scopes.toList(), + }; + } + + /// Returns a [String] representation of this object. + /// + /// If [obscureText] is `true` (default), then the [clientSecret] will be + /// replaced by asterisks in the output. + /// + /// If no [asteriskCount] is given, then the number of asterisks will equal the length of + /// the actual [clientSecret]. + @override + String toString({bool obscureSecret = true, int? asteriskCount}) { + String? secret; + + if (!obscureSecret) { + secret = clientSecret; + } else { + var codeUnits = + List.filled(asteriskCount ?? clientSecret.length, $asterisk); + secret = String.fromCharCodes(codeUnits); + } + + var b = StringBuffer('ExternalAuthOptions('); + b.write('clientId=$clientId'); + b.write(', clientSecret=$secret'); + b.write(', redirectUri=$redirectUri'); + b.write(', scopes=${scopes.toList()}'); + b.write(')'); + return b.toString(); + } +} diff --git a/packages/auth/lib/src/middleware/require_auth.dart b/packages/auth/lib/src/middleware/require_auth.dart new file mode 100644 index 0000000..d4783de --- /dev/null +++ b/packages/auth/lib/src/middleware/require_auth.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'package:platform_foundation/core.dart'; + +/// Forces Basic authentication over the requested resource, with the given [realm] name, if no JWT is present. +/// +/// [realm] defaults to `'platform_auth'`. +RequestHandler forceBasicAuth({String? realm}) { + return (RequestContext req, ResponseContext res) async { + if (req.container != null) { + var reqContainer = req.container!; + if (reqContainer.has()) { + return true; + } else if (reqContainer.has>()) { + await reqContainer.makeAsync(); + return true; + } + } + + res.headers['www-authenticate'] = 'Basic realm="${realm ?? 'angel_auth'}"'; + throw PlatformHttpException.notAuthenticated(); + }; +} + +/// Restricts access to a resource via authentication. +RequestHandler requireAuthentication() { + return (RequestContext req, ResponseContext res, + {bool throwError = true}) async { + bool reject(ResponseContext res) { + if (throwError) { + res.statusCode = 403; + throw PlatformHttpException.forbidden(); + } else { + return false; + } + } + + if (req.container != null) { + var reqContainer = req.container!; + if (reqContainer.has() || req.method == 'OPTIONS') { + return true; + } else if (reqContainer.has>()) { + await reqContainer.makeAsync(); + return true; + } else { + return reject(res); + } + } else { + return reject(res); + } + }; +} diff --git a/packages/auth/lib/src/options.dart b/packages/auth/lib/src/options.dart new file mode 100644 index 0000000..05334c8 --- /dev/null +++ b/packages/auth/lib/src/options.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +import 'package:platform_foundation/core.dart'; +import 'auth_token.dart'; + +typedef AngelAuthCallback = FutureOr Function( + RequestContext req, ResponseContext res, String token); + +typedef AngelAuthTokenCallback = FutureOr Function( + RequestContext req, ResponseContext res, AuthToken token, User user); + +class AngelAuthOptions { + AngelAuthCallback? callback; + AngelAuthTokenCallback? tokenCallback; + String? successRedirect; + String? failureRedirect; + + /// If `false` (default: `true`), then successful authentication will return `true` and allow the + /// execution of subsequent handlers, just like any other middleware. + /// + /// Works well with `Basic` authentication. + bool canRespondWithJson; + + AngelAuthOptions( + {this.callback, + this.tokenCallback, + this.canRespondWithJson = true, + this.successRedirect, + this.failureRedirect}); +} diff --git a/packages/auth/lib/src/plugin.dart b/packages/auth/lib/src/plugin.dart new file mode 100644 index 0000000..9002c10 --- /dev/null +++ b/packages/auth/lib/src/plugin.dart @@ -0,0 +1,518 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'package:platform_foundation/core.dart'; +import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; + +import 'auth_token.dart'; +import 'options.dart'; +import 'strategy.dart'; + +/// Handles authentication within an Angel application. +class PlatformAuth { + final _log = Logger('PlatformAuth'); + + late Hmac _hs256; + late int _jwtLifeSpan; + final StreamController _onLogin = StreamController(), + _onLogout = StreamController(); + final Random _random = Random.secure(); + final RegExp _rgxBearer = RegExp(r'^Bearer'); + + /// If `true` (default), then JWT's will be stored and retrieved from a `token` cookie. + final bool allowCookie; + + /// 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 [allowCookie] is `true`. + final String? cookieDomain; + + /// A path to restrict emitted cookies to. + /// + /// Only applies if [allowCookie] is `true`. + final String cookiePath; + + /// 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. + final bool enforceIp; + + /// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`. + String reviveTokenEndpoint; + + /// A set of [AuthStrategy] instances used to authenticate users. + Map> strategies = {}; + + /// Serializes a user into a unique identifier associated only with one identity. + FutureOr Function(User) serializer; + + /// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance. + FutureOr Function(String) deserializer; + + /// Fires the result of [deserializer] whenever a user signs in to the application. + Stream get onLogin => _onLogin.stream; + + /// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application. + Stream get onLogout => _onLogout.stream; + + /// The [Hmac] being used to encode JWT's. + Hmac get hmac => _hs256; + + String _randomString( + {int length = 32, + String validChars = + 'ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_'}) { + var chars = []; + while (chars.length < length) { + chars.add(_random.nextInt(validChars.length)); + } + return String.fromCharCodes(chars); + } + + /// `jwtLifeSpan` - should be in *milliseconds*. + PlatformAuth( + {String? jwtKey, + required this.serializer, + required this.deserializer, + num jwtLifeSpan = -1, + this.allowCookie = true, + this.allowTokenInQuery = true, + this.enforceIp = true, + this.cookieDomain, + this.cookiePath = '/', + this.secureCookies = true, + this.reviveTokenEndpoint = '/auth/token'}) + : super() { + _hs256 = Hmac(sha256, (jwtKey ?? _randomString()).codeUnits); + _jwtLifeSpan = jwtLifeSpan.toInt(); + } + + /// Configures an Angel server to decode and validate JSON Web tokens on demand, + /// whenever an instance of [User] is injected. + Future configureServer(Application app) async { + /* + if (serializer == null) { + throw StateError( + 'An `PlatformAuth` plug-in was called without its `serializer` being set. All authentication will fail.'); + } + if (deserializer == null) { + throw StateError( + 'An `PlatformAuth` plug-in was called without its `deserializer` being set. All authentication will fail.'); + } + + if (app.container == null) { + _log.severe('Angel3 container is null'); + throw StateError( + 'Angel.container is null. All authentication will fail.'); + } + */ + var appContainer = app.container; + + appContainer.registerSingleton(this); + if (runtimeType != PlatformAuth) { + appContainer.registerSingleton(this, as: PlatformAuth); + } + + if (!appContainer.has<_AuthResult>()) { + appContainer + .registerLazySingleton>>((container) async { + var req = container.make(); + var res = container.make(); + //if (req == null || res == null) { + // _log.warning('RequestContext or responseContext is null'); + // throw PlatformHttpException.forbidden(); + //} + + var result = await _decodeJwt(req, res); + if (result != null) { + return result; + } else { + _log.warning('JWT is null'); + throw PlatformHttpException.forbidden(); + } + }); + + appContainer.registerLazySingleton>((container) async { + var result = await container.makeAsync<_AuthResult>(); + return result.user; + }); + + appContainer.registerLazySingleton>((container) async { + var result = await container.makeAsync<_AuthResult>(); + return result.token; + }); + } + + app.post(reviveTokenEndpoint, _reviveJwt); + + app.shutdownHooks.add((_) { + _onLogin.close(); + }); + } + + void _apply( + RequestContext req, ResponseContext res, AuthToken token, User user) { + if (req.container == null) { + _log.severe('RequestContext.container is null'); + throw StateError( + 'RequestContext.container is not set. All authentication will fail.'); + } + + var reqContainer = req.container!; + if (!reqContainer.has()) { + reqContainer.registerSingleton(user); + } + + if (!reqContainer.has()) { + reqContainer.registerSingleton(token); + } + + if (allowCookie) { + _addProtectedCookie(res, 'token', token.serialize(_hs256)); + } + } + + /// DEPRECATED: A middleware that decodes a JWT from a request, and injects a corresponding user. + /// + /// Now that `package:angel_framework` supports asynchronous injections, this middleware + /// is no longer directly necessary. Instead, call [configureServer]. You can then use + /// `makeAsync`, or Angel's injections directly: + /// + /// ```dart + /// var auth = PlatformAuth(...); + /// await app.configure(auth.configureServer); + /// + /// app.get('/hmm', (User user) async { + /// // `package:angel_auth` decodes the JWT on demand. + /// print(user.name); + /// }); + /// + /// @Expose('/my') + /// class MyController extends Controller { + /// @Expose('/hmm') + /// String getUsername(User user) => user.name + /// } + /// ``` + /* + @deprecated + Future decodeJwt(RequestContext req, ResponseContext res) async { + if (req.method == 'POST' && req.path == reviveTokenEndpoint) { + return await _reviveJwt(req, res); + } else { + await _decodeJwt(req, res); + return true; + } + } + */ + + Future<_AuthResult?> _decodeJwt( + RequestContext req, ResponseContext res) async { + var jwt = getJwt(req); + + if (jwt != null) { + var token = AuthToken.validate(jwt, _hs256); + + if (enforceIp) { + if (req.ip != token.ipAddress) { + _log.warning('JWT cannot be accessed from this IP address'); + throw PlatformHttpException.forbidden( + message: 'JWT cannot be accessed from this IP address.'); + } + } + + if (token.lifeSpan > -1) { + var expiry = + token.issuedAt.add(Duration(milliseconds: token.lifeSpan.toInt())); + + if (!expiry.isAfter(DateTime.now())) { + _log.warning('Expired JWT'); + throw PlatformHttpException.forbidden(message: 'Expired JWT.'); + } + } + + var user = await deserializer(token.userId); + _apply(req, res, token, user); + return _AuthResult(user, token); + } + + return null; + } + + /// Retrieves a JWT from a request, if any was sent at all. + String? getJwt(RequestContext req) { + if (req.headers?.value('Authorization') != null) { + final authHeader = req.headers?.value('Authorization'); + if (authHeader != null) { + // Allow Basic auth to fall through + if (_rgxBearer.hasMatch(authHeader)) { + return authHeader.replaceAll(_rgxBearer, '').trim(); + } + } + + _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; + } else if (allowTokenInQuery) { + //&& req.uri?.queryParameters['token'] is String) { + if (req.uri != null) { + return req.uri?.queryParameters['token']?.toString(); + } + } + + return null; + } + + void _addProtectedCookie(ResponseContext res, String name, String value) { + if (!res.cookies.any((c) => c.name == name)) { + res.cookies.add(protectCookie(Cookie(name, value))); + } + } + + /// Applies security protections to a [cookie]. + Cookie protectCookie(Cookie cookie) { + if (secureCookies != false) { + cookie.httpOnly = true; + cookie.secure = true; + } + + var lifeSpan = _jwtLifeSpan; + if (lifeSpan > 0) { + cookie.maxAge ??= lifeSpan < 0 ? -1 : lifeSpan ~/ 1000; + cookie.expires ??= DateTime.now().add(Duration(milliseconds: lifeSpan)); + } + + cookie.domain ??= cookieDomain; + cookie.path ??= cookiePath; + return cookie; + } + + /// Attempts to revive an expired (or still alive) JWT. + Future> _reviveJwt( + RequestContext req, ResponseContext res) async { + try { + var jwt = getJwt(req); + + if (jwt == null) { + var body = await req.parseBody().then((_) => req.bodyAsMap); + jwt = body['token']?.toString(); + } + + if (jwt == null) { + _log.warning('No JWT provided'); + throw PlatformHttpException.forbidden(message: 'No JWT provided'); + } else { + var token = AuthToken.validate(jwt, _hs256); + if (enforceIp) { + if (req.ip != token.ipAddress) { + _log.warning('WT cannot be accessed from this IP address'); + throw PlatformHttpException.forbidden( + message: 'JWT cannot be accessed from this IP address.'); + } + } + + if (token.lifeSpan > -1) { + var expiry = token.issuedAt + .add(Duration(milliseconds: token.lifeSpan.toInt())); + + if (!expiry.isAfter(DateTime.now())) { + //print( + // 'Token has indeed expired! Resetting assignment date to current timestamp...'); + // Extend its lifespan by changing iat + token.issuedAt = DateTime.now(); + } + } + + if (allowCookie) { + _addProtectedCookie(res, 'token', token.serialize(_hs256)); + } + + final data = await deserializer(token.userId); + return {'data': data, 'token': token.serialize(_hs256)}; + } + } catch (e) { + if (e is PlatformHttpException) { + rethrow; + } + _log.warning('Malformed JWT'); + throw PlatformHttpException.badRequest(message: 'Malformed JWT'); + } + } + + /// Attempts to authenticate a user using one or more strategies. + /// + /// [type] is a strategy name to try, or a `List` of such. + /// + /// If a strategy returns `null` or `false`, either the next one is tried, + /// or a `401 Not Authenticated` is thrown, if it is the last one. + /// + /// Any other result is considered an authenticated user, and terminates the loop. + RequestHandler authenticate(type, [AngelAuthOptions? opt]) { + return (RequestContext req, ResponseContext res) async { + var authOption = opt ?? AngelAuthOptions(); + + var names = []; + + var arr = type is Iterable + ? type.map((x) => x.toString()).toList() + : [type.toString()]; + + for (var t in arr) { + var n = t + .split(',') + .map((s) => s.trim()) + .where((String s) => s.isNotEmpty) + .toList(); + names.addAll(n); + } + + for (var i = 0; i < names.length; i++) { + var name = names[i]; + + var strategy = strategies[name]; + if (strategy == null) { + _log.severe('No strategy "$name" found.'); + throw ArgumentError('No strategy "$name" found.'); + } + + var reqContainer = req.container; + + if (reqContainer == null) { + print('req.container is null'); + } + + var hasExisting = reqContainer?.has() ?? false; + var result = hasExisting + ? reqContainer?.make() + : await strategy.authenticate(req, res, authOption); + + if (result == true) { + return result; + } else if (result != null && result != false) { + //} else if (result != null && result is Map && result.isNotEmpty) { + var userId = await serializer(result); + + // Create JWT + var token = AuthToken( + userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); + var jwt = token.serialize(_hs256); + + if (authOption.tokenCallback != null) { + var hasUser = reqContainer?.has() ?? false; + if (!hasUser) { + reqContainer?.registerSingleton(result); + } + + var r = await authOption.tokenCallback!(req, res, token, result); + if (r != null) return r; + jwt = token.serialize(_hs256); + } + + _apply(req, res, token, result); + + if (allowCookie) { + _addProtectedCookie(res, 'token', jwt); + } + + // Options is not null + if (authOption.callback != null) { + return await authOption.callback!(req, res, jwt); + } + + if (authOption.successRedirect?.isNotEmpty == true) { + await res.redirect(authOption.successRedirect); + return false; + } else if (authOption.canRespondWithJson && + req.accepts('application/json')) { + var user = hasExisting + ? result + : await deserializer(await serializer(result)); + _onLogin.add(user); + return {'data': user, 'token': jwt}; + } + + return true; + } else { + if (i < names.length - 1) continue; + // Check if not redirect + if (res.statusCode == 301 || + res.statusCode == 302 || + res.headers.containsKey('location')) { + return false; + } else if (authOption.failureRedirect != null) { + await res.redirect(authOption.failureRedirect); + return false; + } else { + _log.warning('Not authenticated'); + throw PlatformHttpException.notAuthenticated(); + } + } + } + }; + } + + /// Log a user in on-demand. + Future login(AuthToken token, RequestContext req, ResponseContext res) async { + var user = await deserializer(token.userId); + _apply(req, res, token, user); + _onLogin.add(user); + + if (allowCookie) { + _addProtectedCookie(res, 'token', token.serialize(_hs256)); + } + } + + /// Log a user in on-demand. + Future loginById( + String userId, RequestContext req, ResponseContext res) async { + var user = await deserializer(userId); + var token = + AuthToken(userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); + _apply(req, res, token, user); + _onLogin.add(user); + + if (allowCookie) { + _addProtectedCookie(res, 'token', token.serialize(_hs256)); + } + } + + /// Log an authenticated user out. + RequestHandler logout([AngelAuthOptions? options]) { + return (RequestContext req, ResponseContext res) async { + if (req.container?.has() == true) { + var user = req.container?.make(); + if (user != null) { + _onLogout.add(user); + } + } + + if (allowCookie == true) { + res.cookies.removeWhere((cookie) => cookie.name == 'token'); + _addProtectedCookie(res, 'token', '""'); + } + + if (options != null && + options.successRedirect != null && + options.successRedirect!.isNotEmpty) { + await res.redirect(options.successRedirect); + } + + return true; + }; + } +} + +class _AuthResult { + final User user; + final AuthToken token; + + _AuthResult(this.user, this.token); +} diff --git a/packages/auth/lib/src/popup_page.dart b/packages/auth/lib/src/popup_page.dart new file mode 100644 index 0000000..de11cb7 --- /dev/null +++ b/packages/auth/lib/src/popup_page.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'package:platform_foundation/core.dart'; +import 'package:http_parser/http_parser.dart'; +import 'options.dart'; + +/// Displays a default callback page to confirm authentication via popups. +AngelAuthCallback confirmPopupAuthentication({String eventName = 'token'}) { + return (req, ResponseContext res, String jwt) { + var evt = json.encode(eventName); + var detail = json.encode({'detail': jwt}); + + res + ..contentType = MediaType('text', 'html') + ..write(''' + + + + + Authentication Success + + + +

Authentication Success

+

+ Now logging you in... If you continue to see this page, you may need to enable JavaScript. +

+ + + '''); + return false; + }; +} diff --git a/packages/auth/lib/src/strategies/local.dart b/packages/auth/lib/src/strategies/local.dart new file mode 100644 index 0000000..1607432 --- /dev/null +++ b/packages/auth/lib/src/strategies/local.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:logging/logging.dart'; +import 'package:platform_foundation/core.dart'; +import '../options.dart'; +import '../strategy.dart'; + +/// Determines the validity of an incoming username and password. +// typedef FutureOr LocalAuthVerifier(String? username, String? password); +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'^([^:]+):(.+)$'); + + LocalAuthVerifier verifier; + String usernameField; + String passwordField; + String invalidMessage; + final bool allowBasic; + final bool forceBasic; + String realm; + + LocalAuthStrategy(this.verifier, + {this.usernameField = 'username', + this.passwordField = 'password', + this.invalidMessage = 'Please provide a valid username and password.', + this.allowBasic = false, + this.forceBasic = false, + this.realm = 'Authentication is required.'}) { + _log.info('Using LocalAuthStrategy'); + } + + @override + Future authenticate(RequestContext req, ResponseContext res, + [AngelAuthOptions? options]) async { + var localOptions = options ?? AngelAuthOptions(); + User? verificationResult; + + if (allowBasic) { + var authHeader = req.headers?.value('authorization') ?? ''; + + if (_rgxBasic.hasMatch(authHeader)) { + var base64AuthString = _rgxBasic.firstMatch(authHeader)?.group(1); + if (base64AuthString == null) { + return null; + } + var authString = String.fromCharCodes(base64.decode(base64AuthString)); + if (_rgxUsrPass.hasMatch(authString)) { + Match usrPassMatch = _rgxUsrPass.firstMatch(authString)!; + verificationResult = + await verifier(usrPassMatch.group(1), usrPassMatch.group(2)); + } else { + _log.warning('Bad request: $invalidMessage'); + throw PlatformHttpException.badRequest(errors: [invalidMessage]); + } + + if (verificationResult == null) { + res + ..statusCode = 401 + ..headers['www-authenticate'] = 'Basic realm="$realm"'; + await res.close(); + return null; + } + + //Allow non-null to pass through + //return verificationResult; + } + } else { + var body = await req + .parseBody() + .then((_) => req.bodyAsMap) + .catchError((_) => {}); + if (_validateString(body[usernameField]?.toString()) && + _validateString(body[passwordField]?.toString())) { + verificationResult = await verifier( + body[usernameField]?.toString(), body[passwordField]?.toString()); + } + } + + // User authentication succeeded can return Map(one element), User(non null) or true + if (verificationResult != null && verificationResult != false) { + if (verificationResult is Map && verificationResult.isNotEmpty) { + return verificationResult; + } else if (verificationResult is! Map) { + return verificationResult; + } + } + + // Force basic if set + if (forceBasic) { + //res.headers['www-authenticate'] = 'Basic realm="$realm"'; + res + ..statusCode = 401 + ..headers['www-authenticate'] = 'Basic realm="$realm"'; + await res.close(); + return null; + } + + // Redirect failed authentication + if (localOptions.failureRedirect != null && + localOptions.failureRedirect!.isNotEmpty) { + await res.redirect(localOptions.failureRedirect, code: 401); + return null; + } + + _log.info('Not authenticated'); + throw PlatformHttpException.notAuthenticated(); + + /* + if (verificationResult is Map && verificationResult.isEmpty) { + if (localOptions.failureRedirect != null && + localOptions.failureRedirect!.isNotEmpty) { + await res.redirect(localOptions.failureRedirect, code: 401); + return null; + } + + if (forceBasic) { + res.headers['www-authenticate'] = 'Basic realm="$realm"'; + return null; + } + + return null; + } else if (verificationResult != false || + (verificationResult is Map && verificationResult.isNotEmpty)) { + return verificationResult; + } else { + _log.info('Not authenticated'); + throw PlatformHttpException.notAuthenticated(); + } + */ + } + + bool _validateString(String? str) => str != null && str.isNotEmpty; +} diff --git a/packages/auth/lib/src/strategies/strategies.dart b/packages/auth/lib/src/strategies/strategies.dart new file mode 100644 index 0000000..ce1589a --- /dev/null +++ b/packages/auth/lib/src/strategies/strategies.dart @@ -0,0 +1 @@ +export 'local.dart'; diff --git a/packages/auth/lib/src/strategy.dart b/packages/auth/lib/src/strategy.dart new file mode 100644 index 0000000..a56c795 --- /dev/null +++ b/packages/auth/lib/src/strategy.dart @@ -0,0 +1,10 @@ +import 'dart:async'; +import 'package:platform_foundation/core.dart'; +import 'options.dart'; + +/// A function that handles login and signup for an Angel application. +abstract class AuthStrategy { + /// Authenticates or rejects an incoming user. + FutureOr authenticate(RequestContext req, ResponseContext res, + [AngelAuthOptions? options]); +} diff --git a/packages/auth/pubspec.yaml b/packages/auth/pubspec.yaml new file mode 100644 index 0000000..2477792 --- /dev/null +++ b/packages/auth/pubspec.yaml @@ -0,0 +1,35 @@ +name: platform_auth +description: A complete authentication plugin for Angel3. Includes support for stateless JWT tokens, Basic Auth, and more. +version: 8.2.0 +homepage: https://angel3-framework.web.app/ +repository: https://github.com/dart-backend/angel/tree/master/packages/auth +environment: + sdk: '>=3.3.0 <4.0.0' +dependencies: + platform_foundation: ^8.0.0 + charcode: ^1.3.0 + collection: ^1.17.0 + crypto: ^3.0.0 + http_parser: ^4.0.0 + meta: ^1.9.0 + quiver: ^3.2.0 + logging: ^1.2.0 +dev_dependencies: + platform_container: ^8.0.0 + http: ^1.0.0 + io: ^1.0.0 + test: ^1.24.0 + lints: ^4.0.0 +# dependency_overrides: +# angel3_container: +# path: ../container/angel_container +# angel3_framework: +# path: ../framework +# angel3_http_exception: +# path: ../http_exception +# angel3_model: +# path: ../model +# angel3_route: +# path: ../route +# angel3_mock_request: +# path: ../mock_request \ No newline at end of file diff --git a/packages/auth/test/auth_token_test.dart b/packages/auth/test/auth_token_test.dart new file mode 100644 index 0000000..98ca5c6 --- /dev/null +++ b/packages/auth/test/auth_token_test.dart @@ -0,0 +1,34 @@ +import 'package:platform_auth/src/auth_token.dart'; +import 'package:crypto/crypto.dart'; +import 'package:test/test.dart'; + +void main() async { + final hmac = Hmac(sha256, 'angel_auth'.codeUnits); + + test('sample serialization', () { + var token = AuthToken(ipAddress: 'localhost', userId: 'thosakwe'); + var jwt = token.serialize(hmac); + print(jwt); + + var parsed = AuthToken.validate(jwt, hmac); + print(parsed.toJson()); + expect(parsed.toJson()['aud'], equals(token.ipAddress)); + expect(parsed.toJson()['sub'], equals(token.userId)); + }); + + test('custom payload', () { + var token = AuthToken(ipAddress: 'localhost', userId: 'thosakwe', payload: { + 'foo': 'bar', + 'baz': { + 'one': 1, + 'franken': ['stein'] + } + }); + var jwt = token.serialize(hmac); + print(jwt); + + var parsed = AuthToken.validate(jwt, hmac); + print(parsed.toJson()); + expect(parsed.toJson()['pld'], equals(token.payload)); + }); +} diff --git a/packages/auth/test/callback_test.dart b/packages/auth/test/callback_test.dart new file mode 100644 index 0000000..f388e19 --- /dev/null +++ b/packages/auth/test/callback_test.dart @@ -0,0 +1,153 @@ +import 'dart:io'; +import 'package:platform_auth/auth.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; +import 'dart:convert'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:http/http.dart' as http; +import 'package:io/ansi.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; +import 'package:collection/collection.dart'; + +class User extends Model { + String? username, password; + + User({this.username, this.password}); + + static User parse(Map map) { + return User( + username: map['username'] as String?, + password: map['password'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'username': username, + 'password': password, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String() + }; + } +} + +void main() { + late Application app; + late PlatformHttp angelHttp; + PlatformAuth auth; + http.Client? client; + HttpServer server; + String? url; + String? encodedAuth; + + setUp(() async { + hierarchicalLoggingEnabled = true; + app = Application(reflector: MirrorsReflector()); + angelHttp = PlatformHttp(app); + app.use('/users', MapService()); + + var oldErrorHandler = app.errorHandler; + app.errorHandler = (e, req, res) { + app.logger.severe(e.message, e, e.stackTrace ?? StackTrace.current); + return oldErrorHandler(e, req, res); + }; + + app.logger = Logger('platform_auth') + ..level = Level.FINEST + ..onRecord.listen((rec) { + print(rec); + + if (rec.error != null) { + print(yellow.wrap(rec.error.toString())); + } + + if (rec.stackTrace != null) { + print(yellow.wrap(rec.stackTrace.toString())); + } + }); + + await app + .findService('users') + ?.create({'username': 'jdoe1', 'password': 'password'}); + + auth = PlatformAuth( + serializer: (u) => u.id ?? '', + deserializer: (id) async => + await app.findService('users')?.read(id) as User); + //auth.serializer = (u) => u.id; + //auth.deserializer = + // (id) async => await app.findService('users')!.read(id) as User; + + await app.configure(auth.configureServer); + + auth.strategies['local'] = LocalAuthStrategy((username, password) async { + var users = await app + .findService('users') + ?.index() + .then((it) => it.map((m) => User.parse(m)).toList()); + + var result = users?.firstWhereOrNull( + (user) => user.username == username && user.password == password); + + return Future.value(result); + }, allowBasic: true); + + app.post( + '/login', + auth.authenticate('local', + AngelAuthOptions(callback: (req, res, token) { + res + ..write('Hello!') + ..close(); + }))); + + app.chain([ + (req, res) { + if (!req.container!.has()) { + req.container!.registerSingleton( + User(username: req.params['name']?.toString())); + } + return true; + } + ]).post( + '/existing/:name', + auth.authenticate('local'), + ); + + encodedAuth = base64.encode(utf8.encode('jdoe1:password')); + + client = http.Client(); + server = await angelHttp.startServer(); + url = 'http://${server.address.address}:${server.port}'; + }); + + tearDown(() async { + client!.close(); + await angelHttp.close(); + //app = null; + client = null; + url = null; + }); + + test('login', () async { + final response = await client!.post(Uri.parse('$url/login'), + headers: {'Authorization': 'Basic $encodedAuth'}); + print('Response: ${response.body}'); + expect(response.body, equals('Hello!')); + }, + skip: Platform.version.contains('2.0.0-dev') + ? 'Blocked on https://github.com/dart-lang/sdk/issues/33594' + : null); + + test('preserve existing user', () async { + final response = await client!.post(Uri.parse('$url/existing/foo'), + 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/config_test.dart b/packages/auth/test/config_test.dart new file mode 100644 index 0000000..5cce5d2 --- /dev/null +++ b/packages/auth/test/config_test.dart @@ -0,0 +1,164 @@ +import 'package:platform_auth/auth.dart'; +import 'package:test/test.dart'; + +void main() { + var options = ExternalAuthOptions( + clientId: 'foo', + clientSecret: 'bar', + redirectUri: 'http://example.com', + ); + + test('parses uri', () { + expect(options.redirectUri, Uri(scheme: 'http', host: 'example.com')); + }); + + group('copyWith', () { + test('empty produces exact copy', () { + expect(options.copyWith(), options); + }); + + test('all fields', () { + expect( + options.copyWith( + clientId: 'hey', + clientSecret: 'hello', + redirectUri: 'https://yes.no', + scopes: ['a', 'b'], + ), + ExternalAuthOptions( + clientId: 'hey', + clientSecret: 'hello', + redirectUri: 'https://yes.no', + scopes: ['a', 'b'], + ), + ); + }); + + test('not equal to original if different', () { + expect(options.copyWith(clientId: 'hey'), isNot(options)); + }); + }); + + group('new()', () { + test('accepts uri', () { + expect( + ExternalAuthOptions( + clientId: 'foo', + clientSecret: 'bar', + redirectUri: Uri.parse('http://example.com'), + ), + options, + ); + }); + + test('accepts string', () { + expect( + ExternalAuthOptions( + clientId: 'foo', + clientSecret: 'bar', + redirectUri: 'http://example.com', + ), + options, + ); + }); + + test('rejects invalid redirectUri', () { + expect( + () => ExternalAuthOptions( + clientId: 'foo', clientSecret: 'bar', redirectUri: 24.5), + throwsArgumentError, + ); + }); + +/* Deprecated as clientId and clientSecret cannot be null + test('ensures id not null', () { + expect( + () => ExternalAuthOptions( + clientId: null, + clientSecret: 'bar', + redirectUri: 'http://example.com'), + throwsArgumentError, + ); + }); + + test('ensures secret not null', () { + expect( + () => ExternalAuthOptions( + clientId: 'foo', + clientSecret: null, + redirectUri: 'http://example.com'), + throwsArgumentError, + ); + }); + */ + }); + + group('fromMap()', () { + test('rejects invalid map', () { + expect( + () => ExternalAuthOptions.fromMap({'yes': 'no'}), + throwsArgumentError, + ); + }); + + test('accepts correct map', () { + expect( + ExternalAuthOptions.fromMap({ + 'client_id': 'foo', + 'client_secret': 'bar', + 'redirect_uri': 'http://example.com', + }), + options, + ); + }); + }); + + group('toString()', () { + test('produces correct string', () { + expect( + options.toString(obscureSecret: false), + 'ExternalAuthOptions(clientId=foo, clientSecret=bar, redirectUri=http://example.com, scopes=[])', + ); + }); + + test('obscures secret', () { + expect( + options.toString(), + 'ExternalAuthOptions(clientId=foo, clientSecret=***, redirectUri=http://example.com, scopes=[])', + ); + }); + + test('asteriskCount', () { + expect( + options.toString(asteriskCount: 7), + 'ExternalAuthOptions(clientId=foo, clientSecret=*******, redirectUri=http://example.com, scopes=[])', + ); + }); + }); + + group('toJson()', () { + test('obscures secret', () { + expect( + options.toJson(), + { + 'client_id': 'foo', + 'client_secret': '', + 'redirect_uri': 'http://example.com', + 'scopes': [], + }, + ); + }); + + test('produces correct map', () { + expect( + options.toJson(obscureSecret: false), + { + 'client_id': 'foo', + 'client_secret': 'bar', + 'redirect_uri': 'http://example.com', + 'scopes': [], + }, + ); + }); + }); +} diff --git a/packages/auth/test/local_test.dart b/packages/auth/test/local_test.dart new file mode 100644 index 0000000..b9cf1e6 --- /dev/null +++ b/packages/auth/test/local_test.dart @@ -0,0 +1,153 @@ +import 'dart:async'; +import 'package:platform_auth/auth.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +final PlatformAuth> auth = + PlatformAuth>( + serializer: (user) async => '1337', + deserializer: (id) async => sampleUser); +//var headers = {'accept': 'application/json'}; +var localOpts = AngelAuthOptions>( + failureRedirect: '/failure', successRedirect: '/success'); +var localOpts2 = + AngelAuthOptions>(canRespondWithJson: false); + +Map sampleUser = {'hello': 'world'}; + +Future> verifier(String? username, String? password) async { + if (username == 'username' && password == 'password') { + return sampleUser; + } else { + return {}; + } +} + +Future wireAuth(Application app) async { + //auth.serializer = (user) async => 1337; + //auth.deserializer = (id) async => sampleUser; + + auth.strategies['local'] = LocalAuthStrategy(verifier, allowBasic: true); + await app.configure(auth.configureServer); +} + +void main() async { + Application app; + late PlatformHttp angelHttp; + late http.Client client; + String? url; + String? basicAuthUrl; + + setUp(() async { + client = http.Client(); + app = Application(reflector: MirrorsReflector()); + angelHttp = PlatformHttp(app, useZone: false); + await app.configure(wireAuth); + + app.get('/hello', (req, res) { + // => 'Woo auth' + return 'Woo auth'; + }, middleware: [auth.authenticate('local', localOpts2)]); + app.post('/login', (req, res) => 'This should not be shown', + middleware: [auth.authenticate('local', localOpts)]); + app.get('/success', (req, res) => 'yep', middleware: [ + requireAuthentication>(), + ]); + app.get('/failure', (req, res) => 'nope'); + + 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); + } + }); + + var server = 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 angelHttp.close(); + //client = null; + url = null; + basicAuthUrl = null; + }); + + test('can use "auth" as middleware', () async { + var response = await client.get(Uri.parse('$url/success'), + headers: {'Accept': 'application/json'}); + print(response.body); + expect(response.statusCode, equals(403)); + }); + + test('successRedirect', () async { + //var postData = {'username': 'username', 'password': 'password'}; + var encodedAuth = base64.encode(utf8.encode('username:password')); + + var response = await client.post(Uri.parse('$url/login'), + headers: {'Authorization': 'Basic $encodedAuth'}); + expect(response.statusCode, equals(302)); + expect(response.headers['location'], equals('/success')); + }); + + test('failureRedirect', () async { + //var postData = {'username': 'password', 'password': 'username'}; + var encodedAuth = base64.encode(utf8.encode('password:username')); + + var response = await client.post(Uri.parse('$url/login'), + headers: {'Authorization': 'Basic $encodedAuth'}); + print('Status Code: ${response.statusCode}'); + print(response.headers); + print(response.body); + expect(response.headers['location'], equals('/failure')); + expect(response.statusCode, equals(401)); + }); + + 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'}); + print(response.statusCode); + print(response.body); + expect(response.body, equals('"Woo auth"')); + }); + + test('allow basic via URL encoding', () async { + var response = await client.get(Uri.parse('$basicAuthUrl/hello')); + expect(response.body, equals('"Woo auth"')); + }); + + test('force basic', () async { + auth.strategies.clear(); + auth.strategies['local'] = + LocalAuthStrategy(verifier, forceBasic: true, realm: 'test'); + var response = await client.get(Uri.parse('$url/hello'), headers: { + 'accept': 'application/json', + 'content-type': 'application/json' + }); + print('Header = ${response.headers}'); + print('Body <${response.body}>'); + var head = response.headers['www-authenticate']; + expect(head, equals('Basic realm="test"')); + }); +} diff --git a/packages/auth/test/protect_cookie_test.dart b/packages/auth/test/protect_cookie_test.dart new file mode 100644 index 0000000..9486952 --- /dev/null +++ b/packages/auth/test/protect_cookie_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:platform_auth/auth.dart'; +import 'package:test/test.dart'; + +const Duration threeDays = Duration(days: 3); + +void main() { + late Cookie defaultCookie; + var auth = PlatformAuth( + secureCookies: true, + cookieDomain: 'SECURE', + jwtLifeSpan: threeDays.inMilliseconds, + serializer: (u) => u, + deserializer: (u) => u); + + setUp(() => defaultCookie = Cookie('a', 'b')); + + test('sets maxAge', () { + expect(auth.protectCookie(defaultCookie).maxAge, threeDays.inSeconds); + }); + + test('sets expires', () { + var now = 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'); + }); +} diff --git a/packages/testing/test/all_test.dart b/packages/testing/test/all_test.dart index a0244a1..8d65247 100644 --- a/packages/testing/test/all_test.dart +++ b/packages/testing/test/all_test.dart @@ -1,7 +1,7 @@ //import 'dart:convert'; //import 'dart:io'; -//import 'package:angel3_framework/angel3_framework.dart'; -//import 'package:angel3_framework/http.dart'; +//import 'package:platform_foundation/core.dart'; +//import 'package:platform_foundation/http.dart'; //import 'package:angel3_mock_request/angel3_mock_request.dart'; //import 'package:test/test.dart';