add: adding auth package
This commit is contained in:
parent
2446120319
commit
5c925c481d
28 changed files with 2292 additions and 2 deletions
72
packages/auth/.gitignore
vendored
Normal file
72
packages/auth/.gitignore
vendored
Normal file
|
@ -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
|
12
packages/auth/AUTHORS.md
Normal file
12
packages/auth/AUTHORS.md
Normal file
|
@ -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.
|
193
packages/auth/CHANGELOG.md
Normal file
193
packages/auth/CHANGELOG.md
Normal file
|
@ -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<User>`.
|
||||||
|
|
||||||
|
## 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<String, AuthStrategy<User>>`.
|
||||||
|
* 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.
|
29
packages/auth/LICENSE
Normal file
29
packages/auth/LICENSE
Normal file
|
@ -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.
|
84
packages/auth/README.md
Normal file
84
packages/auth/README.md
Normal file
|
@ -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<User>(
|
||||||
|
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
|
||||||
|
});
|
||||||
|
```
|
1
packages/auth/analysis_options.yaml
Normal file
1
packages/auth/analysis_options.yaml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
include: package:lints/recommended.yaml
|
22
packages/auth/example/client/example_client.http
Normal file
22
packages/auth/example/client/example_client.http
Normal file
|
@ -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
|
38
packages/auth/example/example.dart
Normal file
38
packages/auth/example/example.dart
Normal file
|
@ -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<User>(
|
||||||
|
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<User> fetchAUserByIdSomehow(String id) async {
|
||||||
|
// Fetch a user somehow...
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
113
packages/auth/example/example1.dart
Normal file
113
packages/auth/example/example1.dart
Normal file
|
@ -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<String, dynamic> map) {
|
||||||
|
return User(
|
||||||
|
username: map['username'],
|
||||||
|
password: map['password'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<User>(
|
||||||
|
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<User>((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<User>()) {
|
||||||
|
req.container!.registerSingleton<User>(
|
||||||
|
User(username: req.params['name']?.toString()));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
]).post(
|
||||||
|
'/existing/:name',
|
||||||
|
auth.authenticate('local'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await angelHttp.startServer('127.0.0.1', 3000);
|
||||||
|
}
|
69
packages/auth/example/example2.dart
Normal file
69
packages/auth/example/example2.dart
Normal file
|
@ -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<String, String> sampleUser = {'hello': 'world'};
|
||||||
|
|
||||||
|
final PlatformAuth<Map<String, String>> auth =
|
||||||
|
PlatformAuth<Map<String, String>>(
|
||||||
|
serializer: (user) async => '1337',
|
||||||
|
deserializer: (id) async => sampleUser);
|
||||||
|
//var headers = <String, String>{'accept': 'application/json'};
|
||||||
|
var localOpts = AngelAuthOptions<Map<String, String>>(
|
||||||
|
failureRedirect: '/failure', successRedirect: '/success');
|
||||||
|
var localOpts2 =
|
||||||
|
AngelAuthOptions<Map<String, String>>(canRespondWithJson: false);
|
||||||
|
|
||||||
|
Future<Map<String, String>> 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<Map<String, String>>(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
10
packages/auth/lib/auth.dart
Normal file
10
packages/auth/lib/auth.dart
Normal file
|
@ -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';
|
4
packages/auth/lib/auth_token.dart
Normal file
4
packages/auth/lib/auth_token.dart
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
/// Stand-alone JWT library.
|
||||||
|
library platform_auth.auth_token;
|
||||||
|
|
||||||
|
export 'src/auth_token.dart';
|
138
packages/auth/lib/src/auth_token.dart
Normal file
138
packages/auth/lib/src/auth_token.dart
Normal file
|
@ -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<String, String> _header =
|
||||||
|
SplayTreeMap.from({'alg': 'HS256', 'typ': 'JWT'});
|
||||||
|
|
||||||
|
String? ipAddress;
|
||||||
|
num lifeSpan;
|
||||||
|
String userId;
|
||||||
|
late DateTime issuedAt;
|
||||||
|
Map<String, dynamic> payload = {};
|
||||||
|
|
||||||
|
AuthToken(
|
||||||
|
{this.ipAddress,
|
||||||
|
this.lifeSpan = -1,
|
||||||
|
required this.userId,
|
||||||
|
DateTime? issuedAt,
|
||||||
|
Map<String, dynamic>? 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<String, dynamic>);
|
||||||
|
|
||||||
|
factory AuthToken.fromMap(Map<String, dynamic> 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<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, dynamic> toJson() {
|
||||||
|
return _splayify({
|
||||||
|
'iss': 'angel_auth',
|
||||||
|
'aud': ipAddress,
|
||||||
|
'exp': lifeSpan,
|
||||||
|
'iat': issuedAt.toIso8601String(),
|
||||||
|
'sub': userId,
|
||||||
|
'pld': _splayify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _splayify(Map<String, dynamic> 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<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
137
packages/auth/lib/src/configuration.dart
Normal file
137
packages/auth/lib/src/configuration.dart
Normal file
|
@ -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<String> scopes;
|
||||||
|
|
||||||
|
ExternalAuthOptions._(
|
||||||
|
this.clientId, this.clientSecret, this.redirectUri, this.scopes);
|
||||||
|
|
||||||
|
factory ExternalAuthOptions(
|
||||||
|
{required String clientId,
|
||||||
|
required String clientSecret,
|
||||||
|
required redirectUri,
|
||||||
|
Iterable<String> 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<String, dynamic> 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()))
|
||||||
|
: <String>[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<String>().equals(other.scopes, scopes);
|
||||||
|
|
||||||
|
/// Creates a copy of this object, with the specified changes.
|
||||||
|
ExternalAuthOptions copyWith(
|
||||||
|
{String? clientId,
|
||||||
|
String? clientSecret,
|
||||||
|
redirectUri,
|
||||||
|
Iterable<String> 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 `<redacted>`.
|
||||||
|
Map<String, dynamic> toJson({bool obscureSecret = true}) {
|
||||||
|
return {
|
||||||
|
'client_id': clientId,
|
||||||
|
'client_secret': obscureSecret ? '<redacted>' : 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<int>.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();
|
||||||
|
}
|
||||||
|
}
|
51
packages/auth/lib/src/middleware/require_auth.dart
Normal file
51
packages/auth/lib/src/middleware/require_auth.dart
Normal file
|
@ -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<User>({String? realm}) {
|
||||||
|
return (RequestContext req, ResponseContext res) async {
|
||||||
|
if (req.container != null) {
|
||||||
|
var reqContainer = req.container!;
|
||||||
|
if (reqContainer.has<User>()) {
|
||||||
|
return true;
|
||||||
|
} else if (reqContainer.has<Future<User>>()) {
|
||||||
|
await reqContainer.makeAsync<User>();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.headers['www-authenticate'] = 'Basic realm="${realm ?? 'angel_auth'}"';
|
||||||
|
throw PlatformHttpException.notAuthenticated();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts access to a resource via authentication.
|
||||||
|
RequestHandler requireAuthentication<User>() {
|
||||||
|
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<User>() || req.method == 'OPTIONS') {
|
||||||
|
return true;
|
||||||
|
} else if (reqContainer.has<Future<User>>()) {
|
||||||
|
await reqContainer.makeAsync<User>();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return reject(res);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return reject(res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
30
packages/auth/lib/src/options.dart
Normal file
30
packages/auth/lib/src/options.dart
Normal file
|
@ -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<User> = FutureOr Function(
|
||||||
|
RequestContext req, ResponseContext res, AuthToken token, User user);
|
||||||
|
|
||||||
|
class AngelAuthOptions<User> {
|
||||||
|
AngelAuthCallback? callback;
|
||||||
|
AngelAuthTokenCallback<User>? 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});
|
||||||
|
}
|
518
packages/auth/lib/src/plugin.dart
Normal file
518
packages/auth/lib/src/plugin.dart
Normal file
|
@ -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<User> {
|
||||||
|
final _log = Logger('PlatformAuth');
|
||||||
|
|
||||||
|
late Hmac _hs256;
|
||||||
|
late int _jwtLifeSpan;
|
||||||
|
final StreamController<User> _onLogin = StreamController<User>(),
|
||||||
|
_onLogout = StreamController<User>();
|
||||||
|
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<String, AuthStrategy<User>> strategies = {};
|
||||||
|
|
||||||
|
/// Serializes a user into a unique identifier associated only with one identity.
|
||||||
|
FutureOr<String> Function(User) serializer;
|
||||||
|
|
||||||
|
/// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance.
|
||||||
|
FutureOr<User> Function(String) deserializer;
|
||||||
|
|
||||||
|
/// Fires the result of [deserializer] whenever a user signs in to the application.
|
||||||
|
Stream<User> get onLogin => _onLogin.stream;
|
||||||
|
|
||||||
|
/// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application.
|
||||||
|
Stream<User> 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 = <int>[];
|
||||||
|
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<void> 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<User>>()) {
|
||||||
|
appContainer
|
||||||
|
.registerLazySingleton<Future<_AuthResult<User>>>((container) async {
|
||||||
|
var req = container.make<RequestContext>();
|
||||||
|
var res = container.make<ResponseContext>();
|
||||||
|
//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<Future<User>>((container) async {
|
||||||
|
var result = await container.makeAsync<_AuthResult<User>>();
|
||||||
|
return result.user;
|
||||||
|
});
|
||||||
|
|
||||||
|
appContainer.registerLazySingleton<Future<AuthToken>>((container) async {
|
||||||
|
var result = await container.makeAsync<_AuthResult<User>>();
|
||||||
|
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<User>()) {
|
||||||
|
reqContainer.registerSingleton<User>(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reqContainer.has<AuthToken>()) {
|
||||||
|
reqContainer.registerSingleton<AuthToken>(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<User>`, or Angel's injections directly:
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// var auth = PlatformAuth<User>(...);
|
||||||
|
/// 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<User>?> _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<Map<String, dynamic>> _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<User>? opt]) {
|
||||||
|
return (RequestContext req, ResponseContext res) async {
|
||||||
|
var authOption = opt ?? AngelAuthOptions<User>();
|
||||||
|
|
||||||
|
var names = <String>[];
|
||||||
|
|
||||||
|
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<User>() ?? false;
|
||||||
|
var result = hasExisting
|
||||||
|
? reqContainer?.make<User>()
|
||||||
|
: 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<User>() ?? false;
|
||||||
|
if (!hasUser) {
|
||||||
|
reqContainer?.registerSingleton<User>(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<User>? options]) {
|
||||||
|
return (RequestContext req, ResponseContext res) async {
|
||||||
|
if (req.container?.has<User>() == true) {
|
||||||
|
var user = req.container?.make<User>();
|
||||||
|
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<User> {
|
||||||
|
final User user;
|
||||||
|
final AuthToken token;
|
||||||
|
|
||||||
|
_AuthResult(this.user, this.token);
|
||||||
|
}
|
36
packages/auth/lib/src/popup_page.dart
Normal file
36
packages/auth/lib/src/popup_page.dart
Normal file
|
@ -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('''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Authentication Success</title>
|
||||||
|
<script>
|
||||||
|
var ev = new CustomEvent($evt, $detail);
|
||||||
|
window.opener.dispatchEvent(ev);
|
||||||
|
window.close();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Authentication Success</h1>
|
||||||
|
<p>
|
||||||
|
Now logging you in... If you continue to see this page, you may need to enable JavaScript.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
138
packages/auth/lib/src/strategies/local.dart
Normal file
138
packages/auth/lib/src/strategies/local.dart
Normal file
|
@ -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<User> LocalAuthVerifier<User>(String? username, String? password);
|
||||||
|
typedef LocalAuthVerifier<User> = FutureOr<User?> Function(
|
||||||
|
String? username, String? password);
|
||||||
|
|
||||||
|
class LocalAuthStrategy<User> extends AuthStrategy<User> {
|
||||||
|
final _log = Logger('LocalAuthStrategy');
|
||||||
|
|
||||||
|
final RegExp _rgxBasic = RegExp(r'^Basic (.+)$', caseSensitive: false);
|
||||||
|
final RegExp _rgxUsrPass = RegExp(r'^([^:]+):(.+)$');
|
||||||
|
|
||||||
|
LocalAuthVerifier<User> 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<User?> 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((_) => <String, dynamic>{});
|
||||||
|
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;
|
||||||
|
}
|
1
packages/auth/lib/src/strategies/strategies.dart
Normal file
1
packages/auth/lib/src/strategies/strategies.dart
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export 'local.dart';
|
10
packages/auth/lib/src/strategy.dart
Normal file
10
packages/auth/lib/src/strategy.dart
Normal file
|
@ -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<User> {
|
||||||
|
/// Authenticates or rejects an incoming user.
|
||||||
|
FutureOr<User?> authenticate(RequestContext req, ResponseContext res,
|
||||||
|
[AngelAuthOptions<User>? options]);
|
||||||
|
}
|
35
packages/auth/pubspec.yaml
Normal file
35
packages/auth/pubspec.yaml
Normal file
|
@ -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
|
34
packages/auth/test/auth_token_test.dart
Normal file
34
packages/auth/test/auth_token_test.dart
Normal file
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
153
packages/auth/test/callback_test.dart
Normal file
153
packages/auth/test/callback_test.dart
Normal file
|
@ -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<String, dynamic> map) {
|
||||||
|
return User(
|
||||||
|
username: map['username'] as String?,
|
||||||
|
password: map['password'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<User> 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<User>(
|
||||||
|
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<User>((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<User>()) {
|
||||||
|
req.container!.registerSingleton<User>(
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
}
|
164
packages/auth/test/config_test.dart
Normal file
164
packages/auth/test/config_test.dart
Normal file
|
@ -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': '<redacted>',
|
||||||
|
'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': [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
153
packages/auth/test/local_test.dart
Normal file
153
packages/auth/test/local_test.dart
Normal file
|
@ -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<Map<String, String>> auth =
|
||||||
|
PlatformAuth<Map<String, String>>(
|
||||||
|
serializer: (user) async => '1337',
|
||||||
|
deserializer: (id) async => sampleUser);
|
||||||
|
//var headers = <String, String>{'accept': 'application/json'};
|
||||||
|
var localOpts = AngelAuthOptions<Map<String, String>>(
|
||||||
|
failureRedirect: '/failure', successRedirect: '/success');
|
||||||
|
var localOpts2 =
|
||||||
|
AngelAuthOptions<Map<String, String>>(canRespondWithJson: false);
|
||||||
|
|
||||||
|
Map<String, String> sampleUser = {'hello': 'world'};
|
||||||
|
|
||||||
|
Future<Map<String, String>> 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<Map<String, String>>(),
|
||||||
|
]);
|
||||||
|
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"'));
|
||||||
|
});
|
||||||
|
}
|
45
packages/auth/test/protect_cookie_test.dart
Normal file
45
packages/auth/test/protect_cookie_test.dart
Normal file
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
//import 'dart:convert';
|
//import 'dart:convert';
|
||||||
//import 'dart:io';
|
//import 'dart:io';
|
||||||
//import 'package:angel3_framework/angel3_framework.dart';
|
//import 'package:platform_foundation/core.dart';
|
||||||
//import 'package:angel3_framework/http.dart';
|
//import 'package:platform_foundation/http.dart';
|
||||||
//import 'package:angel3_mock_request/angel3_mock_request.dart';
|
//import 'package:angel3_mock_request/angel3_mock_request.dart';
|
||||||
//import 'package:test/test.dart';
|
//import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue