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