Add 'packages/auth/' from commit '1274ad6b0d9c288ab5366e8f3f977e50418166af'

git-subtree-dir: packages/auth
git-subtree-mainline: 6890bbf53f
git-subtree-split: 1274ad6b0d
This commit is contained in:
Tobe O 2020-02-15 18:28:27 -05:00
commit e887b1d21f
33 changed files with 1867 additions and 0 deletions

76
packages/auth/.gitignore vendored Normal file
View 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

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1,4 @@
language: dart
dart:
- dev
- stable

View 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
View 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
View 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
});
```

View file

@ -0,0 +1,4 @@
include: package:pedantic/analysis_options.yaml
analyzer:
strong-mode:
implicit-casts: false

View 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();
}

View 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';

View file

@ -0,0 +1,4 @@
/// Stand-alone JWT library.
library angel_auth.auth_token;
export 'src/auth_token.dart';

View 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;
}

View 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();
}
}

View 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);
};
}

View 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});
}

View 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);
}

View 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;
};
}

View 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();
}
}
}

View file

@ -0,0 +1 @@
export 'local.dart';

View 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]);
}

View 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

View 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));
});
}

View 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'));
});
}

View 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': [],
},
);
});
});
}

View 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"'));
});
}

View 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');
});
}