diff --git a/.idea/runConfigurations/Auth_Token_Tests.xml b/.idea/runConfigurations/Auth_Token_Tests.xml
new file mode 100644
index 00000000..c72b7c75
--- /dev/null
+++ b/.idea/runConfigurations/Auth_Token_Tests.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/angel_auth.dart b/lib/angel_auth.dart
index d90e95d0..984fcb44 100644
--- a/lib/angel_auth.dart
+++ b/lib/angel_auth.dart
@@ -2,6 +2,7 @@ library angel_auth;
export 'src/middleware/require_auth.dart';
export 'src/strategies/strategies.dart';
+export 'src/auth_token.dart';
export 'src/defs.dart';
export 'src/options.dart';
export 'src/plugin.dart';
diff --git a/lib/src/auth_token.dart b/lib/src/auth_token.dart
new file mode 100644
index 00000000..b4a3a26d
--- /dev/null
+++ b/lib/src/auth_token.dart
@@ -0,0 +1,65 @@
+import 'dart:collection';
+import 'dart:convert';
+import 'package:angel_framework/angel_framework.dart';
+import 'package:crypto/crypto.dart';
+
+class AuthToken {
+ final SplayTreeMap _header =
+ new SplayTreeMap.from({"alg": "HS256", "typ": "JWT"});
+
+ String ipAddress;
+ DateTime issuedAt;
+ num lifeSpan;
+ var userId;
+
+ AuthToken(
+ {this.ipAddress, this.lifeSpan: -1, this.userId, DateTime issuedAt}) {
+ this.issuedAt = issuedAt ?? new DateTime.now();
+ }
+
+ factory AuthToken.fromJson(String json) => new AuthToken.fromMap(JSON.decode(json));
+
+ factory AuthToken.fromMap(Map data) {
+ return new AuthToken(
+ ipAddress: data["aud"],
+ lifeSpan: data["exp"],
+ issuedAt: DateTime.parse(data["iat"]),
+ userId: data["sub"]);
+ }
+
+ factory AuthToken.validate(String jwt, Hmac hmac) {
+ var split = jwt.split(".");
+
+ if (split.length != 3)
+ throw new AngelHttpException.NotAuthenticated(message: "Invalid JWT.");
+
+ var headerString = new String.fromCharCodes(BASE64URL.decode(split[0]));
+ var payloadString = new String.fromCharCodes(BASE64URL.decode(split[1]));
+ var data = split[0] + "." + split[1];
+ var signature = BASE64URL.encode(hmac.convert(data.codeUnits).bytes);
+
+ if (signature != split[2])
+ throw new AngelHttpException.NotAuthenticated(
+ message: "JWT payload does not match hashed version.");
+
+ return new AuthToken.fromMap(JSON.decode(payloadString));
+ }
+
+ String serialize(Hmac hmac) {
+ var headerString = BASE64URL.encode(JSON.encode(_header).codeUnits);
+ var payloadString = BASE64URL.encode(JSON.encode(this).codeUnits);
+ var data = headerString + "." + payloadString;
+ var signature = hmac.convert(data.codeUnits).bytes;
+ return data + "." + BASE64URL.encode(signature);
+ }
+
+ Map toJson() {
+ return new SplayTreeMap.from({
+ "iss": "angel_auth",
+ "aud": ipAddress,
+ "exp": lifeSpan,
+ "iat": issuedAt.toIso8601String(),
+ "sub": userId
+ });
+ }
+}
diff --git a/lib/src/middleware/require_auth.dart b/lib/src/middleware/require_auth.dart
index c5d95ff0..02299f16 100644
--- a/lib/src/middleware/require_auth.dart
+++ b/lib/src/middleware/require_auth.dart
@@ -6,7 +6,8 @@ import 'package:angel_framework/angel_framework.dart';
/// Restricts access to a resource via authentication.
class RequireAuthorizationMiddleware extends BaseMiddleware {
@override
- Future call(RequestContext req, ResponseContext res, {bool throwError: true}) async {
+ Future call(RequestContext req, ResponseContext res,
+ {bool throwError: true}) async {
bool _reject(ResponseContext res) {
if (throwError) {
res.status(HttpStatus.FORBIDDEN);
@@ -15,28 +16,9 @@ class RequireAuthorizationMiddleware extends BaseMiddleware {
return false;
}
-
- if (req.session.containsKey('userId'))
+ if (req.properties.containsKey('user'))
return true;
- else if (req.headers.value("Authorization") != null) {
- var jwt = req.headers
- .value("Authorization")
- .replaceAll(new RegExp(r"^Bearer", caseSensitive: false), "")
- .trim();
-
- var split = jwt.split(".");
- if (split.length != 3) return _reject(res);
-
- Map header = JSON.decode(UTF8.decode(BASE64URL.decode(split[0])));
-
- if (header['typ'] != "JWT" || header['alg'] != "HS256")
- return _reject(res);
-
- Map payload = JSON.decode(UTF8.decode(BASE64URL.decode(split[1])));
-
- // Todo: JWT
- return false;
- } else
+ else
return _reject(res);
}
-}
\ No newline at end of file
+}
diff --git a/lib/src/plugin.dart b/lib/src/plugin.dart
index b7144739..c1a8dcf5 100644
--- a/lib/src/plugin.dart
+++ b/lib/src/plugin.dart
@@ -1,29 +1,76 @@
+import 'dart:async';
+import 'dart:collection';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math' as Math;
import 'package:angel_framework/angel_framework.dart';
+import 'package:crypto/crypto.dart';
import 'middleware/require_auth.dart';
+import 'auth_token.dart';
import 'defs.dart';
import 'options.dart';
import 'strategy.dart';
class AngelAuth extends AngelPlugin {
- RequireAuthorizationMiddleware _requireAuth = new RequireAuthorizationMiddleware();
+ Hmac _hs256;
+ num _jwtLifeSpan;
+ Math.Random _random = new Math.Random.secure();
+ final RegExp _rgxBearer = new RegExp(r"^Bearer");
+ RequireAuthorizationMiddleware _requireAuth =
+ new RequireAuthorizationMiddleware();
+ bool enforceIp;
List strategies = [];
UserSerializer serializer;
UserDeserializer deserializer;
+ String _randomString({int length: 32, String validChars: "ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"}) {
+ var chars = [];
+
+ while (chars.length < length) chars.add(_random.nextInt(validChars.length));
+
+ return new String.fromCharCodes(chars);
+ }
+
+ AngelAuth({String jwtKey, num jwtLifeSpan, this.enforceIp}) : super() {
+ _hs256 = new Hmac(sha256, (jwtKey ?? _randomString()).codeUnits);
+ _jwtLifeSpan = jwtLifeSpan ?? -1;
+ }
+
@override
call(Angel app) async {
app.container.singleton(this);
+ if (runtimeType != AngelAuth) app.container.singleton(this, as: AngelAuth);
- if (runtimeType != AngelAuth)
- app.container.singleton(this, as: AngelAuth);
-
+ app.before.add(_decodeJwt);
app.registerMiddleware('auth', _requireAuth);
- app.before.add(_serializationMiddleware);
}
- _serializationMiddleware(RequestContext req, ResponseContext res) async {
- if (await _requireAuth(req, res, throwError: false)) {
- req.properties['user'] = await deserializer(req.session['userId']);
+ _decodeJwt(RequestContext req, ResponseContext res) async {
+ String jwt = null;
+ if (req.headers.value("Authorization") != null) {
+ var jwt =
+ req.headers.value("Authorization").replaceAll(_rgxBearer, "").trim();
+ } else if (req.cookies.any((cookie) => cookie.name == "token")) {
+ jwt = req.cookies.firstWhere((cookie) => cookie.name == "token").value;
+ }
+
+ if (jwt != null) {
+ var token = new AuthToken.validate(jwt, _hs256);
+
+ if (enforceIp) {
+ if (req.ip != token.ipAddress)
+ throw new AngelHttpException.Forbidden(
+ message: "JWT cannot be accessed from this IP address.");
+ }
+
+ if (token.lifeSpan > -1) {
+ token.issuedAt.add(new Duration(milliseconds: token.lifeSpan));
+
+ if (!token.issuedAt.isAfter(new DateTime.now()))
+ throw new AngelHttpException.Forbidden(message: "Expired JWT.");
+ }
+
+ req.properties["user"] = await deserializer(token.userId);
}
return true;
@@ -32,19 +79,39 @@ class AngelAuth extends AngelPlugin {
authenticate(String type, [AngelAuthOptions options]) {
return (RequestContext req, ResponseContext res) async {
AuthStrategy strategy =
- strategies.firstWhere((AuthStrategy x) => x.name == type);
+ strategies.firstWhere((AuthStrategy x) => x.name == type);
var result = await strategy.authenticate(req, res, options);
if (result == true)
return result;
else if (result != false) {
- req.session['userId'] = await serializer(result);
+ var userId = await serializer(result);
+
+ // Create JWT
+ var jwt = new AuthToken(userId: userId, lifeSpan: _jwtLifeSpan)
+ .serialize(_hs256);
+ req.cookies.add(new Cookie("token", jwt));
+
+ if (req.headers.value("accept") != null &&
+ (req.headers.value("accept").contains("application/json") ||
+ req.headers.value("accept").contains("*/*") ||
+ req.headers.value("accept").contains("application/*"))) {
+ return {"data": result, "token": jwt};
+ } else if (options != null && options.successRedirect != null &&
+ options.successRedirect.isNotEmpty) {
+ return res.redirect(options.successRedirect, code: HttpStatus.OK);
+ }
+
return true;
} else {
- throw new AngelHttpException.NotAuthenticated();
+ await authenticationFailure(req, res);
}
};
}
+ Future authenticationFailure(RequestContext req, ResponseContext res) async {
+ throw new AngelHttpException.NotAuthenticated();
+ }
+
logout([AngelAuthOptions options]) {
return (RequestContext req, ResponseContext res) async {
for (AuthStrategy strategy in strategies) {
@@ -59,7 +126,7 @@ class AngelAuth extends AngelPlugin {
}
}
- req.session.remove('userId');
+ req.cookies.removeWhere((cookie) => cookie.name == "token");
if (options != null &&
options.successRedirect != null &&
@@ -70,4 +137,4 @@ class AngelAuth extends AngelPlugin {
return true;
};
}
-}
\ No newline at end of file
+}
diff --git a/lib/src/strategies/local.dart b/lib/src/strategies/local.dart
index 7a2cfb44..3153d33a 100644
--- a/lib/src/strategies/local.dart
+++ b/lib/src/strategies/local.dart
@@ -12,7 +12,6 @@ bool _validateString(String str) => str != null && str.isNotEmpty;
typedef Future LocalAuthVerifier(String username, String password);
class LocalAuthStrategy extends AuthStrategy {
- AngelAuth _plugin;
RegExp _rgxBasic = new RegExp(r'^Basic (.+)$', caseSensitive: false);
RegExp _rgxUsrPass = new RegExp(r'^([^:]+):(.+)$');
@@ -26,11 +25,11 @@ class LocalAuthStrategy extends AuthStrategy {
bool forceBasic;
String realm;
- LocalAuthStrategy(AngelAuth this._plugin, LocalAuthVerifier this.verifier,
+ LocalAuthStrategy(LocalAuthVerifier this.verifier,
{String this.usernameField: 'username',
String this.passwordField: 'password',
String this.invalidMessage:
- 'Please provide a valid username and password.',
+ 'Please provide a valid username and password.',
bool this.allowBasic: true,
bool this.forceBasic: false,
String this.realm: 'Authentication is required.'}) {}
@@ -41,7 +40,7 @@ class LocalAuthStrategy extends AuthStrategy {
}
@override
- Future authenticate(RequestContext req, ResponseContext res,
+ Future authenticate(RequestContext req, ResponseContext res,
[AngelAuthOptions options_]) async {
AngelAuthOptions options = options_ ?? new AngelAuthOptions();
var verificationResult;
@@ -50,14 +49,14 @@ class LocalAuthStrategy extends AuthStrategy {
String authHeader = req.headers.value(HttpHeaders.AUTHORIZATION) ?? "";
if (_rgxBasic.hasMatch(authHeader)) {
String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1);
- String authString = new String.fromCharCodes(
- BASE64.decode(base64AuthString));
+ String authString =
+ new 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 new AngelHttpException.BadRequest(
- errors: [invalidMessage]);
+ await verifier(usrPassMatch.group(1), usrPassMatch.group(2));
+ } else
+ throw new AngelHttpException.BadRequest(errors: [invalidMessage]);
}
}
@@ -65,15 +64,15 @@ class LocalAuthStrategy extends AuthStrategy {
if (_validateString(req.body[usernameField]) &&
_validateString(req.body[passwordField])) {
verificationResult =
- await verifier(req.body[usernameField], req.body[passwordField]);
+ await verifier(req.body[usernameField], req.body[passwordField]);
}
}
if (verificationResult == false || verificationResult == null) {
if (options.failureRedirect != null &&
options.failureRedirect.isNotEmpty) {
- return res.redirect(
- options.failureRedirect, code: HttpStatus.FORBIDDEN);
+ res.redirect(options.failureRedirect, code: HttpStatus.UNAUTHORIZED);
+ return false;
}
if (forceBasic) {
@@ -82,17 +81,10 @@ class LocalAuthStrategy extends AuthStrategy {
..header(HttpHeaders.WWW_AUTHENTICATE, 'Basic realm="$realm"')
..end();
return false;
- } else return false;
- }
-
- else if (verificationResult != null && verificationResult != false) {
- req.session['userId'] = await _plugin.serializer(verificationResult);
- if (options.successRedirect != null &&
- options.successRedirect.isNotEmpty) {
- return res.redirect(options.successRedirect, code: HttpStatus.OK);
- }
-
- return true;
+ } else
+ return false;
+ } else if (verificationResult != null && verificationResult != false) {
+ return verificationResult;
} else {
throw new AngelHttpException.NotAuthenticated();
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 2a35581a..8d1d3d7c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
name: angel_auth
description: A complete authentication plugin for Angel.
-version: 1.0.0-dev+6
+version: 1.0.0-dev+7
author: Tobe O
homepage: https://github.com/angel-dart/angel_auth
dependencies:
diff --git a/test/all_tests.dart b/test/all_tests.dart
index 2941da27..da6c84e2 100644
--- a/test/all_tests.dart
+++ b/test/all_tests.dart
@@ -3,6 +3,7 @@ import 'package:angel_framework/angel_framework.dart';
import 'package:angel_auth/angel_auth.dart';
import 'package:http/http.dart' as http;
import 'package:test/test.dart';
+import 'auth_token.dart' as authToken;
import 'local.dart' as local;
wireAuth(Angel app) async {
@@ -30,6 +31,7 @@ main() async {
url = null;
});
+ group("JWT (de)serialization", authToken.main);
group("local", local.main);
test('can use login as middleware', () async {
diff --git a/test/auth_token.dart b/test/auth_token.dart
new file mode 100644
index 00000000..34a3dd3d
--- /dev/null
+++ b/test/auth_token.dart
@@ -0,0 +1,16 @@
+import "package:angel_auth/src/auth_token.dart";
+import "package:crypto/crypto.dart";
+import "package:test/test.dart";
+
+main() async {
+ final Hmac hmac = new Hmac(sha256, "angel_auth".codeUnits);
+
+ test("sample serialization", () {
+ var token = new AuthToken(ipAddress: "localhost", userId: "thosakwe");
+ var jwt = token.serialize(hmac);
+ print(jwt);
+
+ var parsed = new AuthToken.validate(jwt, hmac);
+ print(parsed.toJson());
+ });
+}
\ No newline at end of file
diff --git a/test/local.dart b/test/local.dart
index 3015c84f..f31516a8 100644
--- a/test/local.dart
+++ b/test/local.dart
@@ -23,7 +23,7 @@ wireAuth(Angel app) async {
Auth.serializer = (user) async => 1337;
Auth.deserializer = (id) async => sampleUser;
- Auth.strategies.add(new LocalAuthStrategy(Auth, verifier));
+ Auth.strategies.add(new LocalAuthStrategy(verifier));
await app.configure(Auth);
}
@@ -79,28 +79,29 @@ main() async {
var response = await client.post("$url/login",
body: JSON.encode(postData),
headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType});
- expect(response.statusCode, equals(403));
+ print("Login response: ${response.body}");
expect(response.headers[HttpHeaders.LOCATION], equals('/failure'));
+ expect(response.statusCode, equals(401));
});
test('allow basic', () async {
String authString = BASE64.encode("username:password".runes.toList());
- Map auth = {HttpHeaders.AUTHORIZATION: 'Basic $authString'};
- var response =
- await client.get("$url/hello", headers: mergeMap([auth, headers]));
+ var response = await client.get("$url/hello",
+ headers: {HttpHeaders.AUTHORIZATION: 'Basic $authString'});
expect(response.body, equals('"Woo auth"'));
});
test('allow basic via URL encoding', () async {
- var response = await client.get("$basicAuthUrl/hello", headers: headers);
+ var response = await client.get("$basicAuthUrl/hello");
expect(response.body, equals('"Woo auth"'));
});
test('force basic', () async {
Auth.strategies.clear();
- Auth.strategies.add(new LocalAuthStrategy(Auth, verifier,
+ Auth.strategies.add(new LocalAuthStrategy(verifier,
forceBasic: true, realm: 'test'));
var response = await client.get("$url/hello", headers: headers);
+ print(response.headers);
expect(response.headers[HttpHeaders.WWW_AUTHENTICATE],
equals('Basic realm="test"'));
});