Add 'packages/auth/' from commit '1274ad6b0d9c288ab5366e8f3f977e50418166af'
git-subtree-dir: packages/auth git-subtree-mainline:6890bbf53f
git-subtree-split:1274ad6b0d
This commit is contained in:
commit
e887b1d21f
33 changed files with 1867 additions and 0 deletions
76
packages/auth/.gitignore
vendored
Normal file
76
packages/auth/.gitignore
vendored
Normal file
|
@ -0,0 +1,76 @@
|
|||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Dart template
|
||||
# See https://www.dartlang.org/tools/private-files.html
|
||||
|
||||
# Files and directories created by pub
|
||||
.buildlog
|
||||
.packages
|
||||
.project
|
||||
.pub/
|
||||
build/
|
||||
**/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
|
||||
doc/api/
|
||||
|
||||
# Don't commit pubspec lock file
|
||||
# (Library packages only! Remove pattern if developing an application package)
|
||||
pubspec.lock
|
||||
### 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:
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/dictionaries
|
||||
.idea/vcs.xml
|
||||
.idea/jsLibraryMappings.xml
|
||||
|
||||
# Sensitive or high-churn files:
|
||||
.idea/dataSources.ids
|
||||
.idea/dataSources.xml
|
||||
.idea/dataSources.local.xml
|
||||
.idea/sqlDataSources.xml
|
||||
.idea/dynamic.xml
|
||||
.idea/uiDesigner.xml
|
||||
|
||||
# Gradle:
|
||||
.idea/gradle.xml
|
||||
.idea/libraries
|
||||
|
||||
# Mongo Explorer plugin:
|
||||
.idea/mongoSettings.xml
|
||||
|
||||
## File-based project format:
|
||||
*.iws
|
||||
|
||||
## Plugin-specific files:
|
||||
|
||||
# IntelliJ
|
||||
/out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.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
|
||||
|
||||
.dart_tool
|
17
packages/auth/.idea/angel_auth.iml
Normal file
17
packages/auth/.idea/angel_auth.iml
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||
</component>
|
||||
</module>
|
28
packages/auth/.idea/misc.xml
Normal file
28
packages/auth/.idea/misc.xml
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptSettings">
|
||||
<option name="languageLevel" value="ES6" />
|
||||
</component>
|
||||
<component name="ProjectInspectionProfilesVisibleTreeState">
|
||||
<entry key="Project Default">
|
||||
<profile-state>
|
||||
<expanded-state>
|
||||
<State>
|
||||
<id />
|
||||
</State>
|
||||
<State>
|
||||
<id>General</id>
|
||||
</State>
|
||||
<State>
|
||||
<id>XPath</id>
|
||||
</State>
|
||||
</expanded-state>
|
||||
<selected-state>
|
||||
<State>
|
||||
<id>AngularJS</id>
|
||||
</State>
|
||||
</selected-state>
|
||||
</profile-state>
|
||||
</entry>
|
||||
</component>
|
||||
</project>
|
8
packages/auth/.idea/modules.xml
Normal file
8
packages/auth/.idea/modules.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/angel_auth.iml" filepath="$PROJECT_DIR$/.idea/angel_auth.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
8
packages/auth/.idea/runConfigurations/All_Tests.xml
Normal file
8
packages/auth/.idea/runConfigurations/All_Tests.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="All Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test" />
|
||||
<option name="scope" value="FOLDER" />
|
||||
<option name="testRunnerOptions" value="-j 4" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
|
@ -0,0 +1,6 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Auth Token Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/auth_token_test.dart" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
6
packages/auth/.idea/runConfigurations/Callback_Tests.xml
Normal file
6
packages/auth/.idea/runConfigurations/Callback_Tests.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Callback Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/callback_test.dart" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
6
packages/auth/.idea/runConfigurations/Local_Tests.xml
Normal file
6
packages/auth/.idea/runConfigurations/Local_Tests.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Local Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/local_test.dart" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
|
@ -0,0 +1,8 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="preserve existing user in callback_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/callback_test.dart" />
|
||||
<option name="scope" value="GROUP_OR_TEST_BY_NAME" />
|
||||
<option name="testName" value="preserve existing user" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
|
@ -0,0 +1,7 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="tests in protect_cookie_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/protect_cookie_test.dart" />
|
||||
<option name="testRunnerOptions" value="-j4" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
4
packages/auth/.travis.yml
Normal file
4
packages/auth/.travis.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
language: dart
|
||||
dart:
|
||||
- dev
|
||||
- stable
|
83
packages/auth/CHANGELOG.md
Normal file
83
packages/auth/CHANGELOG.md
Normal file
|
@ -0,0 +1,83 @@
|
|||
# 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.
|
||||
* `AngelAuth.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.
|
21
packages/auth/LICENSE
Normal file
21
packages/auth/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 angel-dart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
85
packages/auth/README.md
Normal file
85
packages/auth/README.md
Normal file
|
@ -0,0 +1,85 @@
|
|||
# angel_auth
|
||||
|
||||
[![Pub](https://img.shields.io/pub/v/angel_auth.svg)](https://pub.dartlang.org/packages/angel_auth)
|
||||
[![build status](https://travis-ci.org/angel-dart/auth.svg?branch=master)](https://travis-ci.org/angel-dart/auth)
|
||||
|
||||
A complete authentication plugin for Angel. Inspired by Passport.
|
||||
|
||||
# Wiki
|
||||
[Click here](https://github.com/angel-dart/auth/wiki).
|
||||
|
||||
# Bundled Strategies
|
||||
* Local (with and without Basic Auth)
|
||||
* Find other strategies (Twitter, Google, OAuth2, etc.) on Pub!!!
|
||||
|
||||
# Example
|
||||
Ensure you have read the [wiki](https://github.com/angel-dart/auth/wiki).
|
||||
|
||||
```dart
|
||||
configureServer(Angel app) async {
|
||||
var auth = AngelAuth<User>();
|
||||
auth.serializer = ...;
|
||||
auth.deserializer = ...;
|
||||
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.
|
||||
[`angel_client`](https://github.com/angel-dart/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`.
|
||||
`angel_client` [exposes this as a Stream](https://github.com/angel-dart/client#authentication):
|
||||
|
||||
```dart
|
||||
app.authenticateViaPopup('/auth/google').listen((jwt) {
|
||||
// Do something with the JWT
|
||||
});
|
||||
```
|
4
packages/auth/analysis_options.yaml
Normal file
4
packages/auth/analysis_options.yaml
Normal file
|
@ -0,0 +1,4 @@
|
|||
include: package:pedantic/analysis_options.yaml
|
||||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
39
packages/auth/example/example.dart
Normal file
39
packages/auth/example/example.dart
Normal file
|
@ -0,0 +1,39 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_auth/angel_auth.dart';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
|
||||
main() async {
|
||||
var app = Angel();
|
||||
var auth = AngelAuth<User>();
|
||||
|
||||
auth.serializer = (user) => user.id;
|
||||
|
||||
auth.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`.
|
||||
});
|
||||
|
||||
app.post('/auth/local', auth.authenticate('local'));
|
||||
|
||||
var http = AngelHttp(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(id) async {
|
||||
// Fetch a user somehow...
|
||||
throw UnimplementedError();
|
||||
}
|
10
packages/auth/lib/angel_auth.dart
Normal file
10
packages/auth/lib/angel_auth.dart
Normal file
|
@ -0,0 +1,10 @@
|
|||
library angel_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 angel_auth.auth_token;
|
||||
|
||||
export 'src/auth_token.dart';
|
124
packages/auth/lib/src/auth_token.dart
Normal file
124
packages/auth/lib/src/auth_token.dart
Normal file
|
@ -0,0 +1,124 @@
|
|||
import 'dart:collection';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.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 {
|
||||
final SplayTreeMap<String, String> _header =
|
||||
SplayTreeMap.from({"alg": "HS256", "typ": "JWT"});
|
||||
|
||||
String ipAddress;
|
||||
DateTime issuedAt;
|
||||
num lifeSpan;
|
||||
var userId;
|
||||
Map<String, dynamic> payload = {};
|
||||
|
||||
AuthToken(
|
||||
{this.ipAddress,
|
||||
this.lifeSpan = -1,
|
||||
this.userId,
|
||||
DateTime issuedAt,
|
||||
Map payload = const {}}) {
|
||||
this.issuedAt = issuedAt ?? DateTime.now();
|
||||
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"] as Map ?? {});
|
||||
}
|
||||
|
||||
factory AuthToken.parse(String jwt) {
|
||||
var split = jwt.split(".");
|
||||
|
||||
if (split.length != 3)
|
||||
throw AngelHttpException.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)
|
||||
throw AngelHttpException.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])
|
||||
throw AngelHttpException.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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SplayTreeMap _splayify(Map map) {
|
||||
var data = {};
|
||||
map.forEach((k, v) {
|
||||
data[k] = _splay(v);
|
||||
});
|
||||
return SplayTreeMap.from(data);
|
||||
}
|
||||
|
||||
_splay(value) {
|
||||
if (value is Iterable) {
|
||||
return value.map(_splay).toList();
|
||||
} else if (value is Map)
|
||||
return _splayify(value);
|
||||
else
|
||||
return value;
|
||||
}
|
133
packages/auth/lib/src/configuration.dart
Normal file
133
packages/auth/lib/src/configuration.dart
Normal file
|
@ -0,0 +1,133 @@
|
|||
import 'package:charcode/ascii.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:quiver_hashcode/hashcode.dart';
|
||||
|
||||
/// A common class containing parsing and validation logic for third-party authentication configuration.
|
||||
class ExternalAuthOptions {
|
||||
/// 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) {
|
||||
if (clientId == null) {
|
||||
throw ArgumentError.notNull('clientId');
|
||||
} else if (clientSecret == null) {
|
||||
throw ArgumentError.notNull('clientSecret');
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
return ExternalAuthOptions(
|
||||
clientId: map['client_id'] as String,
|
||||
clientSecret: map['client_secret'] as String,
|
||||
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}) {
|
||||
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();
|
||||
}
|
||||
}
|
41
packages/auth/lib/src/middleware/require_auth.dart
Normal file
41
packages/auth/lib/src/middleware/require_auth.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
|
||||
/// Forces Basic authentication over the requested resource, with the given [realm] name, if no JWT is present.
|
||||
///
|
||||
/// [realm] defaults to `'angel_auth'`.
|
||||
RequestHandler forceBasicAuth<User>({String realm}) {
|
||||
return (RequestContext req, ResponseContext res) async {
|
||||
if (req.container.has<User>())
|
||||
return true;
|
||||
else if (req.container.has<Future<User>>()) {
|
||||
await req.container.makeAsync<User>();
|
||||
return true;
|
||||
}
|
||||
|
||||
res.headers['www-authenticate'] = 'Basic realm="${realm ?? 'angel_auth'}"';
|
||||
throw AngelHttpException.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 AngelHttpException.forbidden();
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.container.has<User>() || req.method == 'OPTIONS')
|
||||
return true;
|
||||
else if (req.container.has<Future<User>>()) {
|
||||
await req.container.makeAsync<User>();
|
||||
return true;
|
||||
} 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:angel_framework/angel_framework.dart';
|
||||
import 'auth_token.dart';
|
||||
|
||||
typedef FutureOr AngelAuthCallback(
|
||||
RequestContext req, ResponseContext res, String token);
|
||||
|
||||
typedef FutureOr AngelAuthTokenCallback<User>(
|
||||
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,
|
||||
String this.failureRedirect});
|
||||
}
|
449
packages/auth/lib/src/plugin.dart
Normal file
449
packages/auth/lib/src/plugin.dart
Normal file
|
@ -0,0 +1,449 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as Math;
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'auth_token.dart';
|
||||
import 'options.dart';
|
||||
import 'strategy.dart';
|
||||
|
||||
/// Handles authentication within an Angel application.
|
||||
class AngelAuth<User> {
|
||||
Hmac _hs256;
|
||||
int _jwtLifeSpan;
|
||||
final StreamController<User> _onLogin = StreamController<User>(),
|
||||
_onLogout = StreamController<User>();
|
||||
Math.Random _random = Math.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 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(Object) 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*.
|
||||
AngelAuth(
|
||||
{String jwtKey,
|
||||
this.serializer,
|
||||
this.deserializer,
|
||||
num jwtLifeSpan,
|
||||
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() ?? -1;
|
||||
}
|
||||
|
||||
/// Configures an Angel server to decode and validate JSON Web tokens on demand,
|
||||
/// whenever an instance of [User] is injected.
|
||||
Future<void> configureServer(Angel app) async {
|
||||
if (serializer == null)
|
||||
throw StateError(
|
||||
'An `AngelAuth` plug-in was called without its `serializer` being set. All authentication will fail.');
|
||||
if (deserializer == null)
|
||||
throw StateError(
|
||||
'An `AngelAuth` plug-in was called without its `deserializer` being set. All authentication will fail.');
|
||||
|
||||
app.container.registerSingleton(this);
|
||||
if (runtimeType != AngelAuth)
|
||||
app.container.registerSingleton(this, as: AngelAuth);
|
||||
|
||||
if (!app.container.has<_AuthResult<User>>()) {
|
||||
app.container
|
||||
.registerLazySingleton<Future<_AuthResult<User>>>((container) async {
|
||||
var req = container.make<RequestContext>();
|
||||
var res = container.make<ResponseContext>();
|
||||
var result = await _decodeJwt(req, res);
|
||||
if (result != null) {
|
||||
return result;
|
||||
} else {
|
||||
throw AngelHttpException.forbidden();
|
||||
}
|
||||
});
|
||||
|
||||
app.container.registerLazySingleton<Future<User>>((container) async {
|
||||
var result = await container.makeAsync<_AuthResult<User>>();
|
||||
return result.user;
|
||||
});
|
||||
|
||||
app.container.registerLazySingleton<Future<AuthToken>>((container) async {
|
||||
var result = await container.makeAsync<_AuthResult<User>>();
|
||||
return result.token;
|
||||
});
|
||||
}
|
||||
|
||||
if (reviveTokenEndpoint != null) {
|
||||
app.post(reviveTokenEndpoint, reviveJwt);
|
||||
}
|
||||
|
||||
app.shutdownHooks.add((_) {
|
||||
_onLogin.close();
|
||||
});
|
||||
}
|
||||
|
||||
void _apply(
|
||||
RequestContext req, ResponseContext res, AuthToken token, User user) {
|
||||
if (!req.container.has<User>()) {
|
||||
req.container.registerSingleton<User>(user);
|
||||
}
|
||||
|
||||
if (!req.container.has<AuthToken>()) {
|
||||
req.container.registerSingleton<AuthToken>(token);
|
||||
}
|
||||
|
||||
if (allowCookie == true) {
|
||||
_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 = AngelAuth<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 {
|
||||
String jwt = getJwt(req);
|
||||
|
||||
if (jwt != null) {
|
||||
var token = AuthToken.validate(jwt, _hs256);
|
||||
|
||||
if (enforceIp) {
|
||||
if (req.ip != null && req.ip != token.ipAddress)
|
||||
throw AngelHttpException.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()))
|
||||
throw AngelHttpException.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");
|
||||
|
||||
// Allow Basic auth to fall through
|
||||
if (_rgxBearer.hasMatch(authHeader))
|
||||
return authHeader.replaceAll(_rgxBearer, "").trim();
|
||||
} 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (_jwtLifeSpan > 0) {
|
||||
cookie.maxAge ??= _jwtLifeSpan < 0 ? -1 : _jwtLifeSpan ~/ 1000;
|
||||
cookie.expires ??=
|
||||
DateTime.now().add(Duration(milliseconds: _jwtLifeSpan));
|
||||
}
|
||||
|
||||
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) {
|
||||
throw AngelHttpException.forbidden(message: "No JWT provided");
|
||||
} else {
|
||||
var token = AuthToken.validate(jwt, _hs256);
|
||||
if (enforceIp) {
|
||||
if (req.ip != token.ipAddress)
|
||||
throw AngelHttpException.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 AngelHttpException) rethrow;
|
||||
throw AngelHttpException.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> options]) {
|
||||
return (RequestContext req, ResponseContext res) async {
|
||||
List<String> names = [];
|
||||
var arr = type is Iterable
|
||||
? type.map((x) => x.toString()).toList()
|
||||
: [type.toString()];
|
||||
|
||||
for (String t in arr) {
|
||||
var n = t
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((String s) => s.isNotEmpty)
|
||||
.toList();
|
||||
names.addAll(n);
|
||||
}
|
||||
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
var name = names[i];
|
||||
|
||||
var strategy = strategies[name] ??=
|
||||
throw ArgumentError('No strategy "$name" found.');
|
||||
|
||||
var hasExisting = req.container.has<User>();
|
||||
var result = hasExisting
|
||||
? req.container.make<User>()
|
||||
: await strategy.authenticate(req, res, options);
|
||||
if (result == true)
|
||||
return result;
|
||||
else if (result != false && result != null) {
|
||||
var userId = await serializer(result);
|
||||
|
||||
// Create JWT
|
||||
var token = AuthToken(
|
||||
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
|
||||
var jwt = token.serialize(_hs256);
|
||||
|
||||
if (options?.tokenCallback != null) {
|
||||
if (!req.container.has<User>()) {
|
||||
req.container.registerSingleton<User>(result);
|
||||
}
|
||||
|
||||
var r = await options.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);
|
||||
}
|
||||
|
||||
if (options?.callback != null) {
|
||||
return await options.callback(req, res, jwt);
|
||||
}
|
||||
|
||||
if (options?.successRedirect?.isNotEmpty == true) {
|
||||
await res.redirect(options.successRedirect);
|
||||
return false;
|
||||
} else if (options?.canRespondWithJson != false &&
|
||||
req.accepts('application/json')) {
|
||||
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 (options?.failureRedirect != null) {
|
||||
await res.redirect(options.failureRedirect);
|
||||
return false;
|
||||
} else
|
||||
throw AngelHttpException.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(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>()) {
|
||||
var user = req.container.make<User>();
|
||||
_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:angel_framework/angel_framework.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;
|
||||
};
|
||||
}
|
97
packages/auth/lib/src/strategies/local.dart
Normal file
97
packages/auth/lib/src/strategies/local.dart
Normal file
|
@ -0,0 +1,97 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import '../options.dart';
|
||||
import '../strategy.dart';
|
||||
|
||||
bool _validateString(String str) => str != null && str.isNotEmpty;
|
||||
|
||||
/// Determines the validity of an incoming username and password.
|
||||
typedef FutureOr<User> LocalAuthVerifier<User>(
|
||||
String username, String password);
|
||||
|
||||
class LocalAuthStrategy<User> extends AuthStrategy<User> {
|
||||
RegExp _rgxBasic = RegExp(r'^Basic (.+)$', caseSensitive: false);
|
||||
RegExp _rgxUsrPass = RegExp(r'^([^:]+):(.+)$');
|
||||
|
||||
LocalAuthVerifier<User> verifier;
|
||||
String usernameField;
|
||||
String passwordField;
|
||||
String invalidMessage;
|
||||
final bool allowBasic;
|
||||
final bool forceBasic;
|
||||
String realm;
|
||||
|
||||
LocalAuthStrategy(this.verifier,
|
||||
{String this.usernameField = 'username',
|
||||
String this.passwordField = 'password',
|
||||
String this.invalidMessage =
|
||||
'Please provide a valid username and password.',
|
||||
bool this.allowBasic = true,
|
||||
bool this.forceBasic = false,
|
||||
String this.realm = 'Authentication is required.'});
|
||||
|
||||
@override
|
||||
Future<User> authenticate(RequestContext req, ResponseContext res,
|
||||
[AngelAuthOptions options_]) async {
|
||||
AngelAuthOptions options = options_ ?? AngelAuthOptions();
|
||||
User verificationResult;
|
||||
|
||||
if (allowBasic) {
|
||||
String authHeader = req.headers.value('authorization') ?? "";
|
||||
|
||||
if (_rgxBasic.hasMatch(authHeader)) {
|
||||
String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1);
|
||||
String 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
|
||||
throw AngelHttpException.badRequest(errors: [invalidMessage]);
|
||||
|
||||
if (verificationResult == false || verificationResult == null) {
|
||||
res
|
||||
..statusCode = 401
|
||||
..headers['www-authenticate'] = 'Basic realm="$realm"';
|
||||
await res.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
return verificationResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (verificationResult == null) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
if (verificationResult == false || verificationResult == null) {
|
||||
if (options.failureRedirect != null &&
|
||||
options.failureRedirect.isNotEmpty) {
|
||||
await res.redirect(options.failureRedirect, code: 401);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (forceBasic) {
|
||||
res.headers['www-authenticate'] = 'Basic realm="$realm"';
|
||||
throw AngelHttpException.notAuthenticated();
|
||||
}
|
||||
|
||||
return null;
|
||||
} else if (verificationResult != null && verificationResult != false) {
|
||||
return verificationResult;
|
||||
} else {
|
||||
throw AngelHttpException.notAuthenticated();
|
||||
}
|
||||
}
|
||||
}
|
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:angel_framework/angel_framework.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]);
|
||||
}
|
21
packages/auth/pubspec.yaml
Normal file
21
packages/auth/pubspec.yaml
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: angel_auth
|
||||
description: A complete authentication plugin for Angel. Includes support for stateless JWT tokens, Basic Auth, and more.
|
||||
version: 2.1.5+1
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/angel-dart/angel_auth
|
||||
environment:
|
||||
sdk: ">=2.0.0-dev <3.0.0"
|
||||
dependencies:
|
||||
angel_framework: ^2.0.0-rc.6
|
||||
charcode: ^1.0.0
|
||||
collection: ^1.0.0
|
||||
crypto: ^2.0.0
|
||||
http_parser: ^3.0.0
|
||||
meta: ^1.0.0
|
||||
quiver_hashcode: ^2.0.0
|
||||
dev_dependencies:
|
||||
http: ^0.12.0
|
||||
io: ^0.3.2
|
||||
logging: ^0.11.0
|
||||
pedantic: ^1.0.0
|
||||
test: ^1.0.0
|
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:angel_auth/src/auth_token.dart";
|
||||
import "package:crypto/crypto.dart";
|
||||
import "package:test/test.dart";
|
||||
|
||||
main() async {
|
||||
final Hmac 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));
|
||||
});
|
||||
}
|
141
packages/auth/test/callback_test.dart
Normal file
141
packages/auth/test/callback_test.dart
Normal file
|
@ -0,0 +1,141 @@
|
|||
import 'dart:io';
|
||||
import 'package:angel_auth/angel_auth.dart';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:io/ansi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:test/test.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<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
'updated_at': updatedAt?.toIso8601String()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
AngelHttp angelHttp;
|
||||
AngelAuth<User> auth;
|
||||
http.Client client;
|
||||
HttpServer server;
|
||||
String url;
|
||||
|
||||
setUp(() async {
|
||||
hierarchicalLoggingEnabled = true;
|
||||
app = Angel();
|
||||
angelHttp = AngelHttp(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('angel_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 = AngelAuth<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 as Map)).toList());
|
||||
return users.firstWhere(
|
||||
(user) => user.username == username && user.password == password,
|
||||
orElse: () => null);
|
||||
});
|
||||
|
||||
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'),
|
||||
);
|
||||
|
||||
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('$url/login',
|
||||
body: {'username': 'jdoe1', 'password': 'password'});
|
||||
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('$url/existing/foo',
|
||||
body: {'username': 'jdoe1', 'password': 'password'},
|
||||
headers: {'accept': 'application/json'});
|
||||
print('Response: ${response.body}');
|
||||
expect(json.decode(response.body)['data']['username'], equals('foo'));
|
||||
});
|
||||
}
|
162
packages/auth/test/config_test.dart
Normal file
162
packages/auth/test/config_test.dart
Normal file
|
@ -0,0 +1,162 @@
|
|||
import 'package:angel_auth/angel_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,
|
||||
);
|
||||
});
|
||||
|
||||
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': [],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
124
packages/auth/test/local_test.dart
Normal file
124
packages/auth/test/local_test.dart
Normal file
|
@ -0,0 +1,124 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:angel_auth/angel_auth.dart';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final AngelAuth<Map<String, String>> auth = AngelAuth<Map<String, String>>();
|
||||
var headers = <String, String>{'accept': 'application/json'};
|
||||
var localOpts = AngelAuthOptions<Map<String, String>>(
|
||||
failureRedirect: '/failure', successRedirect: '/success');
|
||||
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 null;
|
||||
}
|
||||
|
||||
Future wireAuth(Angel app) async {
|
||||
auth.serializer = (user) async => 1337;
|
||||
auth.deserializer = (id) async => sampleUser;
|
||||
|
||||
auth.strategies['local'] = LocalAuthStrategy(verifier);
|
||||
await app.configure(auth.configureServer);
|
||||
}
|
||||
|
||||
main() async {
|
||||
Angel app;
|
||||
AngelHttp angelHttp;
|
||||
http.Client client;
|
||||
String url;
|
||||
String basicAuthUrl;
|
||||
|
||||
setUp(() async {
|
||||
client = http.Client();
|
||||
app = Angel();
|
||||
angelHttp = AngelHttp(app, useZone: false);
|
||||
await app.configure(wireAuth);
|
||||
app.get('/hello', (req, res) => 'Woo auth',
|
||||
middleware: [auth.authenticate('local')]);
|
||||
app.post('/login', (req, res) => 'This should not be shown',
|
||||
middleware: [auth.authenticate('local', localOpts)]);
|
||||
app.get('/success', (req, res) => "yep", middleware: [
|
||||
requireAuthentication<Map<String, String>>(),
|
||||
]);
|
||||
app.get('/failure', (req, res) => "nope");
|
||||
|
||||
app.logger = Logger('angel_auth')
|
||||
..onRecord.listen((rec) {
|
||||
if (rec.error != null) {
|
||||
print(rec.error);
|
||||
print(rec.stackTrace);
|
||||
}
|
||||
});
|
||||
|
||||
HttpServer 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("$url/success", headers: {'Accept': 'application/json'});
|
||||
print(response.body);
|
||||
expect(response.statusCode, equals(403));
|
||||
});
|
||||
|
||||
test('successRedirect', () async {
|
||||
Map postData = {'username': 'username', 'password': 'password'};
|
||||
var response = await client.post("$url/login",
|
||||
body: json.encode(postData),
|
||||
headers: {'content-type': 'application/json'});
|
||||
expect(response.statusCode, equals(302));
|
||||
expect(response.headers['location'], equals('/success'));
|
||||
});
|
||||
|
||||
test('failureRedirect', () async {
|
||||
Map postData = {'username': 'password', 'password': 'username'};
|
||||
var response = await client.post("$url/login",
|
||||
body: json.encode(postData),
|
||||
headers: {'content-type': 'application/json'});
|
||||
print("Login response: ${response.body}");
|
||||
expect(response.headers['location'], equals('/failure'));
|
||||
expect(response.statusCode, equals(401));
|
||||
});
|
||||
|
||||
test('allow basic', () async {
|
||||
String authString = base64.encode("username:password".runes.toList());
|
||||
var response = await client
|
||||
.get("$url/hello", headers: {'authorization': 'Basic $authString'});
|
||||
expect(response.body, equals('"Woo auth"'));
|
||||
});
|
||||
|
||||
test('allow basic via URL encoding', () async {
|
||||
var response = await client.get("$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("$url/hello", headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
print(response.headers);
|
||||
print('Body <${response.body}>');
|
||||
expect(response.headers['www-authenticate'], equals('Basic realm="test"'));
|
||||
});
|
||||
}
|
44
packages/auth/test/protect_cookie_test.dart
Normal file
44
packages/auth/test/protect_cookie_test.dart
Normal file
|
@ -0,0 +1,44 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:angel_auth/angel_auth.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const Duration threeDays = const Duration(days: 3);
|
||||
|
||||
void main() {
|
||||
Cookie defaultCookie;
|
||||
var auth = AngelAuth(
|
||||
secureCookies: true,
|
||||
cookieDomain: 'SECURE',
|
||||
jwtLifeSpan: threeDays.inMilliseconds,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue