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/strategies/strategies.dart';
export 'src/auth_token.dart';
export 'src/defs.dart';
export 'src/options.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.
class RequireAuthorizationMiddleware extends BaseMiddleware {
@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) {
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);
}
}
}

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: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<AuthStrategy> strategies = [];
UserSerializer serializer;
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
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;
};
}
}
}

View file

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

View file

@ -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 <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/angel_auth
dependencies:

View file

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

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