Switched from session to JWT

This commit is contained in:
thosakwe 2016-09-21 19:09:23 -04:00
parent e168ea7ba3
commit 6785ca5f1b
10 changed files with 199 additions and 67 deletions

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.dart" />
<method />
</configuration>
</component>

View file

@ -2,6 +2,7 @@ library angel_auth;
export 'src/middleware/require_auth.dart'; export 'src/middleware/require_auth.dart';
export 'src/strategies/strategies.dart'; export 'src/strategies/strategies.dart';
export 'src/auth_token.dart';
export 'src/defs.dart'; export 'src/defs.dart';
export 'src/options.dart'; export 'src/options.dart';
export 'src/plugin.dart'; export 'src/plugin.dart';

65
lib/src/auth_token.dart Normal file
View file

@ -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<String, String> _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
});
}
}

View file

@ -6,7 +6,8 @@ import 'package:angel_framework/angel_framework.dart';
/// Restricts access to a resource via authentication. /// Restricts access to a resource via authentication.
class RequireAuthorizationMiddleware extends BaseMiddleware { class RequireAuthorizationMiddleware extends BaseMiddleware {
@override @override
Future<bool> call(RequestContext req, ResponseContext res, {bool throwError: true}) async { Future<bool> call(RequestContext req, ResponseContext res,
{bool throwError: true}) async {
bool _reject(ResponseContext res) { bool _reject(ResponseContext res) {
if (throwError) { if (throwError) {
res.status(HttpStatus.FORBIDDEN); res.status(HttpStatus.FORBIDDEN);
@ -15,28 +16,9 @@ class RequireAuthorizationMiddleware extends BaseMiddleware {
return false; return false;
} }
if (req.properties.containsKey('user'))
if (req.session.containsKey('userId'))
return true; return true;
else if (req.headers.value("Authorization") != null) { else
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
return _reject(res); return _reject(res);
} }
} }

View file

@ -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:angel_framework/angel_framework.dart';
import 'package:crypto/crypto.dart';
import 'middleware/require_auth.dart'; import 'middleware/require_auth.dart';
import 'auth_token.dart';
import 'defs.dart'; import 'defs.dart';
import 'options.dart'; import 'options.dart';
import 'strategy.dart'; import 'strategy.dart';
class AngelAuth extends AngelPlugin { 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<AuthStrategy> strategies = []; List<AuthStrategy> strategies = [];
UserSerializer serializer; UserSerializer serializer;
UserDeserializer deserializer; UserDeserializer deserializer;
String _randomString({int length: 32, String validChars: "ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"}) {
var chars = <int>[];
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 @override
call(Angel app) async { call(Angel app) async {
app.container.singleton(this); app.container.singleton(this);
if (runtimeType != AngelAuth) app.container.singleton(this, as: AngelAuth);
if (runtimeType != AngelAuth) app.before.add(_decodeJwt);
app.container.singleton(this, as: AngelAuth);
app.registerMiddleware('auth', _requireAuth); app.registerMiddleware('auth', _requireAuth);
app.before.add(_serializationMiddleware);
} }
_serializationMiddleware(RequestContext req, ResponseContext res) async { _decodeJwt(RequestContext req, ResponseContext res) async {
if (await _requireAuth(req, res, throwError: false)) { String jwt = null;
req.properties['user'] = await deserializer(req.session['userId']); 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; return true;
@ -32,19 +79,39 @@ class AngelAuth extends AngelPlugin {
authenticate(String type, [AngelAuthOptions options]) { authenticate(String type, [AngelAuthOptions options]) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
AuthStrategy strategy = AuthStrategy strategy =
strategies.firstWhere((AuthStrategy x) => x.name == type); strategies.firstWhere((AuthStrategy x) => x.name == type);
var result = await strategy.authenticate(req, res, options); var result = await strategy.authenticate(req, res, options);
if (result == true) if (result == true)
return result; return result;
else if (result != false) { 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; return true;
} else { } else {
throw new AngelHttpException.NotAuthenticated(); await authenticationFailure(req, res);
} }
}; };
} }
Future authenticationFailure(RequestContext req, ResponseContext res) async {
throw new AngelHttpException.NotAuthenticated();
}
logout([AngelAuthOptions options]) { logout([AngelAuthOptions options]) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
for (AuthStrategy strategy in strategies) { 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 && if (options != null &&
options.successRedirect != null && options.successRedirect != null &&

View file

@ -12,7 +12,6 @@ bool _validateString(String str) => str != null && str.isNotEmpty;
typedef Future LocalAuthVerifier(String username, String password); typedef Future LocalAuthVerifier(String username, String password);
class LocalAuthStrategy extends AuthStrategy { class LocalAuthStrategy extends AuthStrategy {
AngelAuth _plugin;
RegExp _rgxBasic = new RegExp(r'^Basic (.+)$', caseSensitive: false); RegExp _rgxBasic = new RegExp(r'^Basic (.+)$', caseSensitive: false);
RegExp _rgxUsrPass = new RegExp(r'^([^:]+):(.+)$'); RegExp _rgxUsrPass = new RegExp(r'^([^:]+):(.+)$');
@ -26,11 +25,11 @@ class LocalAuthStrategy extends AuthStrategy {
bool forceBasic; bool forceBasic;
String realm; String realm;
LocalAuthStrategy(AngelAuth this._plugin, LocalAuthVerifier this.verifier, LocalAuthStrategy(LocalAuthVerifier this.verifier,
{String this.usernameField: 'username', {String this.usernameField: 'username',
String this.passwordField: 'password', String this.passwordField: 'password',
String this.invalidMessage: String this.invalidMessage:
'Please provide a valid username and password.', 'Please provide a valid username and password.',
bool this.allowBasic: true, bool this.allowBasic: true,
bool this.forceBasic: false, bool this.forceBasic: false,
String this.realm: 'Authentication is required.'}) {} String this.realm: 'Authentication is required.'}) {}
@ -41,7 +40,7 @@ class LocalAuthStrategy extends AuthStrategy {
} }
@override @override
Future<bool> authenticate(RequestContext req, ResponseContext res, Future authenticate(RequestContext req, ResponseContext res,
[AngelAuthOptions options_]) async { [AngelAuthOptions options_]) async {
AngelAuthOptions options = options_ ?? new AngelAuthOptions(); AngelAuthOptions options = options_ ?? new AngelAuthOptions();
var verificationResult; var verificationResult;
@ -50,14 +49,14 @@ class LocalAuthStrategy extends AuthStrategy {
String authHeader = req.headers.value(HttpHeaders.AUTHORIZATION) ?? ""; String authHeader = req.headers.value(HttpHeaders.AUTHORIZATION) ?? "";
if (_rgxBasic.hasMatch(authHeader)) { if (_rgxBasic.hasMatch(authHeader)) {
String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1); String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1);
String authString = new String.fromCharCodes( String authString =
BASE64.decode(base64AuthString)); new String.fromCharCodes(BASE64.decode(base64AuthString));
if (_rgxUsrPass.hasMatch(authString)) { if (_rgxUsrPass.hasMatch(authString)) {
Match usrPassMatch = _rgxUsrPass.firstMatch(authString); Match usrPassMatch = _rgxUsrPass.firstMatch(authString);
verificationResult = verificationResult =
await verifier(usrPassMatch.group(1), usrPassMatch.group(2)); await verifier(usrPassMatch.group(1), usrPassMatch.group(2));
} else throw new AngelHttpException.BadRequest( } else
errors: [invalidMessage]); throw new AngelHttpException.BadRequest(errors: [invalidMessage]);
} }
} }
@ -65,15 +64,15 @@ class LocalAuthStrategy extends AuthStrategy {
if (_validateString(req.body[usernameField]) && if (_validateString(req.body[usernameField]) &&
_validateString(req.body[passwordField])) { _validateString(req.body[passwordField])) {
verificationResult = verificationResult =
await verifier(req.body[usernameField], req.body[passwordField]); await verifier(req.body[usernameField], req.body[passwordField]);
} }
} }
if (verificationResult == false || verificationResult == null) { if (verificationResult == false || verificationResult == null) {
if (options.failureRedirect != null && if (options.failureRedirect != null &&
options.failureRedirect.isNotEmpty) { options.failureRedirect.isNotEmpty) {
return res.redirect( res.redirect(options.failureRedirect, code: HttpStatus.UNAUTHORIZED);
options.failureRedirect, code: HttpStatus.FORBIDDEN); return false;
} }
if (forceBasic) { if (forceBasic) {
@ -82,17 +81,10 @@ class LocalAuthStrategy extends AuthStrategy {
..header(HttpHeaders.WWW_AUTHENTICATE, 'Basic realm="$realm"') ..header(HttpHeaders.WWW_AUTHENTICATE, 'Basic realm="$realm"')
..end(); ..end();
return false; return false;
} else return false; } else
} return false;
} else if (verificationResult != null && verificationResult != false) {
else if (verificationResult != null && verificationResult != false) { return verificationResult;
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 { } else {
throw new AngelHttpException.NotAuthenticated(); throw new AngelHttpException.NotAuthenticated();
} }

View file

@ -1,6 +1,6 @@
name: angel_auth name: angel_auth
description: A complete authentication plugin for Angel. description: A complete authentication plugin for Angel.
version: 1.0.0-dev+6 version: 1.0.0-dev+7
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/angel_auth homepage: https://github.com/angel-dart/angel_auth
dependencies: dependencies:

View file

@ -3,6 +3,7 @@ import 'package:angel_framework/angel_framework.dart';
import 'package:angel_auth/angel_auth.dart'; import 'package:angel_auth/angel_auth.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'auth_token.dart' as authToken;
import 'local.dart' as local; import 'local.dart' as local;
wireAuth(Angel app) async { wireAuth(Angel app) async {
@ -30,6 +31,7 @@ main() async {
url = null; url = null;
}); });
group("JWT (de)serialization", authToken.main);
group("local", local.main); group("local", local.main);
test('can use login as middleware', () async { test('can use login as middleware', () async {

16
test/auth_token.dart Normal file
View file

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

View file

@ -23,7 +23,7 @@ wireAuth(Angel app) async {
Auth.serializer = (user) async => 1337; Auth.serializer = (user) async => 1337;
Auth.deserializer = (id) async => sampleUser; Auth.deserializer = (id) async => sampleUser;
Auth.strategies.add(new LocalAuthStrategy(Auth, verifier)); Auth.strategies.add(new LocalAuthStrategy(verifier));
await app.configure(Auth); await app.configure(Auth);
} }
@ -79,28 +79,29 @@ main() async {
var response = await client.post("$url/login", var response = await client.post("$url/login",
body: JSON.encode(postData), body: JSON.encode(postData),
headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType}); 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.headers[HttpHeaders.LOCATION], equals('/failure'));
expect(response.statusCode, equals(401));
}); });
test('allow basic', () async { test('allow basic', () async {
String authString = BASE64.encode("username:password".runes.toList()); String authString = BASE64.encode("username:password".runes.toList());
Map auth = {HttpHeaders.AUTHORIZATION: 'Basic $authString'}; var response = await client.get("$url/hello",
var response = headers: {HttpHeaders.AUTHORIZATION: 'Basic $authString'});
await client.get("$url/hello", headers: mergeMap([auth, headers]));
expect(response.body, equals('"Woo auth"')); expect(response.body, equals('"Woo auth"'));
}); });
test('allow basic via URL encoding', () async { 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"')); expect(response.body, equals('"Woo auth"'));
}); });
test('force basic', () async { test('force basic', () async {
Auth.strategies.clear(); Auth.strategies.clear();
Auth.strategies.add(new LocalAuthStrategy(Auth, verifier, Auth.strategies.add(new LocalAuthStrategy(verifier,
forceBasic: true, realm: 'test')); forceBasic: true, realm: 'test'));
var response = await client.get("$url/hello", headers: headers); var response = await client.get("$url/hello", headers: headers);
print(response.headers);
expect(response.headers[HttpHeaders.WWW_AUTHENTICATE], expect(response.headers[HttpHeaders.WWW_AUTHENTICATE],
equals('Basic realm="test"')); equals('Basic realm="test"'));
}); });