Switched from session to JWT
This commit is contained in:
parent
e168ea7ba3
commit
6785ca5f1b
10 changed files with 199 additions and 67 deletions
6
.idea/runConfigurations/Auth_Token_Tests.xml
Normal file
6
.idea/runConfigurations/Auth_Token_Tests.xml
Normal 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>
|
|
@ -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
65
lib/src/auth_token.dart
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
16
test/auth_token.dart
Normal 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());
|
||||
});
|
||||
}
|
|
@ -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"'));
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue