This commit is contained in:
Tobe O 2018-06-27 12:36:31 -04:00
parent c891d130b2
commit c8444a7c7b
12 changed files with 152 additions and 51 deletions

View file

@ -2,6 +2,7 @@
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.pub" /> <excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" /> <excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/build" /> <excludeFolder url="file://$MODULE_DIR$/build" />

View file

@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests in protect_cookie_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
<option name="filePath" value="$PROJECT_DIR$/test/protect_cookie_test.dart" />
<option name="testRunnerOptions" value="-j4" />
<method />
</configuration>
</component>

View file

@ -1 +1,4 @@
language: dart language: dart
dart:
- dev
- stable

View file

@ -1,3 +1,6 @@
# 1.1.1
* Added `protectCookie`, to better protect data sent in cookies.
# 1.1.0+2 # 1.1.0+2
* `LocalAuthStrategy` returns `true` on `Basic` authentication. * `LocalAuthStrategy` returns `true` on `Basic` authentication.

View file

@ -1,2 +1,3 @@
analyzer: analyzer:
strong-mode: true strong-mode:
implicit-casts: false

View file

@ -1,6 +1,6 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'package:dart2_constant/convert.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
/// Calls [BASE64URL], but also works for strings with lengths /// Calls [BASE64URL], but also works for strings with lengths
@ -21,7 +21,7 @@ String decodeBase64(String str) {
throw 'Illegal base64url string!"'; throw 'Illegal base64url string!"';
} }
return UTF8.decode(BASE64URL.decode(output)); return utf8.decode(base64Url.decode(output));
} }
class AuthToken { class AuthToken {
@ -39,21 +39,23 @@ class AuthToken {
this.lifeSpan: -1, this.lifeSpan: -1,
this.userId, this.userId,
DateTime issuedAt, DateTime issuedAt,
Map<String, dynamic> payload: const {}}) { Map payload: const {}}) {
this.issuedAt = issuedAt ?? new DateTime.now(); this.issuedAt = issuedAt ?? new DateTime.now();
this.payload.addAll(payload ?? {}); this.payload.addAll(
payload?.keys?.fold({}, (out, k) => out..[k.toString()] = payload[k]) ??
{});
} }
factory AuthToken.fromJson(String json) => factory AuthToken.fromJson(String jsons) =>
new AuthToken.fromMap(JSON.decode(json)); new AuthToken.fromMap(json.decode(jsons) as Map);
factory AuthToken.fromMap(Map data) { factory AuthToken.fromMap(Map data) {
return new AuthToken( return new AuthToken(
ipAddress: data["aud"], ipAddress: data["aud"].toString(),
lifeSpan: data["exp"], lifeSpan: data["exp"] as num,
issuedAt: DateTime.parse(data["iat"]), issuedAt: DateTime.parse(data["iat"].toString()),
userId: data["sub"], userId: data["sub"],
payload: data["pld"] ?? {}); payload: data["pld"] as Map ?? {});
} }
factory AuthToken.parse(String jwt) { factory AuthToken.parse(String jwt) {
@ -63,7 +65,7 @@ class AuthToken {
throw new AngelHttpException.notAuthenticated(message: "Invalid JWT."); throw new AngelHttpException.notAuthenticated(message: "Invalid JWT.");
var payloadString = decodeBase64(split[1]); var payloadString = decodeBase64(split[1]);
return new AuthToken.fromMap(JSON.decode(payloadString)); return new AuthToken.fromMap(json.decode(payloadString) as Map);
} }
factory AuthToken.validate(String jwt, Hmac hmac) { factory AuthToken.validate(String jwt, Hmac hmac) {
@ -75,21 +77,21 @@ class AuthToken {
// var headerString = decodeBase64(split[0]); // var headerString = decodeBase64(split[0]);
var payloadString = decodeBase64(split[1]); var payloadString = decodeBase64(split[1]);
var data = split[0] + "." + split[1]; var data = split[0] + "." + split[1];
var signature = BASE64URL.encode(hmac.convert(data.codeUnits).bytes); var signature = base64Url.encode(hmac.convert(data.codeUnits).bytes);
if (signature != split[2]) if (signature != split[2])
throw new AngelHttpException.notAuthenticated( throw new AngelHttpException.notAuthenticated(
message: "JWT payload does not match hashed version."); message: "JWT payload does not match hashed version.");
return new AuthToken.fromMap(JSON.decode(payloadString)); return new AuthToken.fromMap(json.decode(payloadString) as Map);
} }
String serialize(Hmac hmac) { String serialize(Hmac hmac) {
var headerString = BASE64URL.encode(JSON.encode(_header).codeUnits); var headerString = base64Url.encode(json.encode(_header).codeUnits);
var payloadString = BASE64URL.encode(JSON.encode(toJson()).codeUnits); var payloadString = base64Url.encode(json.encode(toJson()).codeUnits);
var data = headerString + "." + payloadString; var data = headerString + "." + payloadString;
var signature = hmac.convert(data.codeUnits).bytes; var signature = hmac.convert(data.codeUnits).bytes;
return data + "." + BASE64URL.encode(signature); return data + "." + base64Url.encode(signature);
} }
Map toJson() { Map toJson() {

View file

@ -12,7 +12,7 @@ import 'strategy.dart';
/// Handles authentication within an Angel application. /// Handles authentication within an Angel application.
class AngelAuth<T> { class AngelAuth<T> {
Hmac _hs256; Hmac _hs256;
num _jwtLifeSpan; int _jwtLifeSpan;
final StreamController<T> _onLogin = new StreamController<T>(), final StreamController<T> _onLogin = new StreamController<T>(),
_onLogout = new StreamController<T>(); _onLogout = new StreamController<T>();
Math.Random _random = new Math.Random.secure(); Math.Random _random = new Math.Random.secure();
@ -24,13 +24,22 @@ class AngelAuth<T> {
/// If `true` (default), then users can include a JWT in the query string as `token`. /// If `true` (default), then users can include a JWT in the query string as `token`.
final bool allowTokenInQuery; final bool allowTokenInQuery;
/// Whether emitted cookies should have the `secure` and `HttpOnly` flags,
/// as well as being restricted to a specific domain.
final bool secureCookies;
/// A domain to restrict emitted cookies to.
///
/// Only applies if [secureCookies] is `true`.
final String cookieDomain;
/// The name to register [requireAuth] as. Default: `auth`. /// The name to register [requireAuth] as. Default: `auth`.
String middlewareName; String middlewareName;
/// If `true` (default), then JWT's will be considered invalid if used from a different IP than the first user's it was issued to. /// If `true` (default), then JWT's will be considered invalid if used from a different IP than the first user's it was issued to.
/// ///
/// This is a security provision. Even if a user's JWT is stolen, a remote attacker will not be able to impersonate anyone. /// This is a security provision. Even if a user's JWT is stolen, a remote attacker will not be able to impersonate anyone.
bool enforceIp; final bool enforceIp;
/// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`. /// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`.
String reviveTokenEndpoint; String reviveTokenEndpoint;
@ -62,17 +71,20 @@ class AngelAuth<T> {
return new String.fromCharCodes(chars); return new String.fromCharCodes(chars);
} }
/// `jwtLifeSpan` - should be in *milliseconds*.
AngelAuth( AngelAuth(
{String jwtKey, {String jwtKey,
num jwtLifeSpan, num jwtLifeSpan,
this.allowCookie: true, this.allowCookie: true,
this.allowTokenInQuery: true, this.allowTokenInQuery: true,
this.enforceIp: true, this.enforceIp: true,
this.cookieDomain,
this.secureCookies: true,
this.middlewareName: 'auth', this.middlewareName: 'auth',
this.reviveTokenEndpoint: "/auth/token"}) this.reviveTokenEndpoint: "/auth/token"})
: super() { : super() {
_hs256 = new Hmac(sha256, (jwtKey ?? _randomString()).codeUnits); _hs256 = new Hmac(sha256, (jwtKey ?? _randomString()).codeUnits);
_jwtLifeSpan = jwtLifeSpan ?? -1; _jwtLifeSpan = jwtLifeSpan?.toInt() ?? -1;
} }
Future configureServer(Angel app) async { Future configureServer(Angel app) async {
@ -121,7 +133,7 @@ class AngelAuth<T> {
} }
if (token.lifeSpan > -1) { if (token.lifeSpan > -1) {
token.issuedAt.add(new Duration(milliseconds: token.lifeSpan)); token.issuedAt.add(new Duration(milliseconds: token.lifeSpan.toInt()));
if (!token.issuedAt.isAfter(new DateTime.now())) if (!token.issuedAt.isAfter(new DateTime.now()))
throw new AngelHttpException.forbidden(message: "Expired JWT."); throw new AngelHttpException.forbidden(message: "Expired JWT.");
@ -146,20 +158,40 @@ class AngelAuth<T> {
req.cookies.any((cookie) => cookie.name == "token")) { req.cookies.any((cookie) => cookie.name == "token")) {
return req.cookies.firstWhere((cookie) => cookie.name == "token").value; return req.cookies.firstWhere((cookie) => cookie.name == "token").value;
} else if (allowTokenInQuery && req.query['token'] is String) { } else if (allowTokenInQuery && req.query['token'] is String) {
return req.query['token']; return req.query['token']?.toString();
} }
return null; return null;
} }
/// Applies security protections to a [cookie].
Cookie protectCookie(Cookie cookie) {
if (secureCookies != false) {
cookie.httpOnly = true;
cookie.secure = true;
cookie.domain ??= cookieDomain;
}
cookie.maxAge ??=
_jwtLifeSpan < 0 ? -1 : _jwtLifeSpan ~/ Duration.millisecondsPerSecond;
if (_jwtLifeSpan > 0) {
cookie.expires ??=
new DateTime.now().add(new Duration(milliseconds: _jwtLifeSpan));
}
return cookie;
}
/// Attempts to revive an expired (or still alive) JWT. /// Attempts to revive an expired (or still alive) JWT.
Future<Map<String, dynamic>> reviveJwt(RequestContext req, ResponseContext res) async { Future<Map<String, dynamic>> reviveJwt(
RequestContext req, ResponseContext res) async {
try { try {
var jwt = getJwt(req); var jwt = getJwt(req);
if (jwt == null) { if (jwt == null) {
var body = await req.lazyBody(); var body = await req.lazyBody();
jwt = body['token']; jwt = body['token']?.toString();
} }
if (jwt == null) { if (jwt == null) {
throw new AngelHttpException.forbidden(message: "No JWT provided"); throw new AngelHttpException.forbidden(message: "No JWT provided");
@ -172,7 +204,7 @@ class AngelAuth<T> {
} }
if (token.lifeSpan > -1) { if (token.lifeSpan > -1) {
token.issuedAt.add(new Duration(milliseconds: token.lifeSpan)); token.issuedAt.add(new Duration(milliseconds: token.lifeSpan.toInt()));
if (!token.issuedAt.isAfter(new DateTime.now())) { if (!token.issuedAt.isAfter(new DateTime.now())) {
print( print(
@ -183,7 +215,8 @@ class AngelAuth<T> {
} }
if (allowCookie) if (allowCookie)
res.cookies.add(new Cookie('token', token.serialize(_hs256))); res.cookies
.add(protectCookie(new Cookie('token', token.serialize(_hs256))));
final data = await deserializer(token.userId); final data = await deserializer(token.userId);
return {'data': data, 'token': token.serialize(_hs256)}; return {'data': data, 'token': token.serialize(_hs256)};
@ -207,7 +240,7 @@ class AngelAuth<T> {
List<String> names = []; List<String> names = [];
var arr = type is Iterable ? type.toList() : [type]; var arr = type is Iterable ? type.toList() : [type];
for (var t in arr) { for (String t in arr) {
var n = t var n = t
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
@ -227,7 +260,7 @@ class AngelAuth<T> {
if (result == true) if (result == true)
return result; return result;
else if (result != false) { else if (result != false) {
var userId = await serializer(result); var userId = await serializer(result as T);
// Create JWT // Create JWT
var token = new AuthToken( var token = new AuthToken(
@ -242,7 +275,8 @@ class AngelAuth<T> {
_apply(req, token, result); _apply(req, token, result);
if (allowCookie) res.cookies.add(new Cookie("token", jwt)); if (allowCookie)
res.cookies.add(protectCookie(new Cookie("token", jwt)));
if (options?.callback != null) { if (options?.callback != null) {
return await options.callback(req, res, jwt); return await options.callback(req, res, jwt);
@ -256,7 +290,7 @@ class AngelAuth<T> {
(req.headers.value("accept").contains("application/json") || (req.headers.value("accept").contains("application/json") ||
req.headers.value("accept").contains("*/*") || req.headers.value("accept").contains("*/*") ||
req.headers.value("accept").contains("application/*"))) { req.headers.value("accept").contains("application/*"))) {
var user = await deserializer(await serializer(result)); var user = await deserializer(await serializer(result as T));
_onLogin.add(user); _onLogin.add(user);
return {"data": user, "token": jwt}; return {"data": user, "token": jwt};
} }
@ -283,7 +317,8 @@ class AngelAuth<T> {
_onLogin.add(user); _onLogin.add(user);
if (allowCookie) if (allowCookie)
res.cookies.add(new Cookie('token', token.serialize(_hs256))); res.cookies
.add(protectCookie(new Cookie('token', token.serialize(_hs256))));
} }
/// Log a user in on-demand. /// Log a user in on-demand.
@ -295,7 +330,8 @@ class AngelAuth<T> {
_onLogin.add(user); _onLogin.add(user);
if (allowCookie) if (allowCookie)
res.cookies.add(new Cookie('token', token.serialize(_hs256))); res.cookies
.add(protectCookie(new Cookie('token', token.serialize(_hs256))));
} }
/// Log an authenticated user out. /// Log an authenticated user out.
@ -314,7 +350,7 @@ class AngelAuth<T> {
} }
var user = req.grab('user'); var user = req.grab('user');
if (user != null) _onLogout.add(user); if (user != null) _onLogout.add(user as T);
req.injections..remove(AuthToken)..remove('user'); req.injections..remove(AuthToken)..remove('user');
req.properties.remove('user'); req.properties.remove('user');

View file

@ -1,5 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'package:dart2_constant/convert.dart';
import 'dart:io'; import 'dart:io';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import '../options.dart'; import '../options.dart';
@ -50,7 +50,7 @@ class LocalAuthStrategy extends AuthStrategy {
if (_rgxBasic.hasMatch(authHeader)) { if (_rgxBasic.hasMatch(authHeader)) {
String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1); String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1);
String authString = String authString =
new String.fromCharCodes(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 =
@ -73,10 +73,10 @@ class LocalAuthStrategy extends AuthStrategy {
if (verificationResult == null) { if (verificationResult == null) {
await req.parse(); await req.parse();
if (_validateString(req.body[usernameField]) && if (_validateString(req.body[usernameField]?.toString()) &&
_validateString(req.body[passwordField])) { _validateString(req.body[passwordField]?.toString())) {
verificationResult = verificationResult =
await verifier(req.body[usernameField], req.body[passwordField]); await verifier(req.body[usernameField]?.toString(), req.body[passwordField]?.toString());
} }
} }

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.1.0+2 version: 1.1.1
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
environment: environment:

View file

@ -13,6 +13,7 @@ class User extends Model {
main() { main() {
Angel app; Angel app;
AngelHttp angelHttp;
AngelAuth auth; AngelAuth auth;
http.Client client; http.Client client;
HttpServer server; HttpServer server;
@ -20,14 +21,15 @@ main() {
setUp(() async { setUp(() async {
app = new Angel(); app = new Angel();
angelHttp = new AngelHttp(app, useZone: false);
app.use('/users', new TypedService<User>(new MapService())); app.use('/users', new TypedService<User>(new MapService()));
await app await app
.service('users') .service('users')
.create({'username': 'jdoe1', 'password': 'password'}); .create({'username': 'jdoe1', 'password': 'password'});
auth = new AngelAuth(); auth = new AngelAuth<User>();
auth.serializer = (User user) async => user.id; auth.serializer = (u) => u.id;
auth.deserializer = app.service('users').read; auth.deserializer = app.service('users').read;
await app.configure(auth.configureServer); await app.configure(auth.configureServer);
@ -52,13 +54,13 @@ main() {
}))); })));
client = new http.Client(); client = new http.Client();
server = await app.startServer(); server = await angelHttp.startServer();
url = 'http://${server.address.address}:${server.port}'; url = 'http://${server.address.address}:${server.port}';
}); });
tearDown(() async { tearDown(() async {
client.close(); client.close();
await server.close(force: true); await angelHttp.close();
app = null; app = null;
client = null; client = null;
url = null; url = null;

View file

@ -1,13 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'package:angel_auth/angel_auth.dart'; import 'package:angel_auth/angel_auth.dart';
import 'package:dart2_constant/convert.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';
final AngelAuth auth = new AngelAuth(); final AngelAuth auth = new AngelAuth();
Map headers = {HttpHeaders.ACCEPT: ContentType.JSON.mimeType}; var headers = <String, String>{HttpHeaders.ACCEPT: ContentType.JSON.mimeType};
AngelAuthOptions localOpts = new AngelAuthOptions( AngelAuthOptions localOpts = new AngelAuthOptions(
failureRedirect: '/failure', successRedirect: '/success'); failureRedirect: '/failure', successRedirect: '/success');
Map sampleUser = {'hello': 'world'}; Map sampleUser = {'hello': 'world'};
@ -30,6 +30,7 @@ Future wireAuth(Angel app) async {
main() async { main() async {
Angel app; Angel app;
AngelHttp angelHttp;
http.Client client; http.Client client;
String url; String url;
String basicAuthUrl; String basicAuthUrl;
@ -37,6 +38,7 @@ main() async {
setUp(() async { setUp(() async {
client = new http.Client(); client = new http.Client();
app = new Angel(); app = new Angel();
angelHttp = new AngelHttp(app, useZone: false);
await app.configure(wireAuth); await app.configure(wireAuth);
app.get('/hello', 'Woo auth', middleware: [auth.authenticate('local')]); app.get('/hello', 'Woo auth', middleware: [auth.authenticate('local')]);
app.post('/login', 'This should not be shown', app.post('/login', 'This should not be shown',
@ -45,14 +47,14 @@ main() async {
app.get('/failure', "nope"); app.get('/failure', "nope");
HttpServer server = HttpServer server =
await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); await angelHttp.startServer('127.0.0.1', 0);
url = "http://${server.address.host}:${server.port}"; url = "http://${server.address.host}:${server.port}";
basicAuthUrl = basicAuthUrl =
"http://username:password@${server.address.host}:${server.port}"; "http://username:password@${server.address.host}:${server.port}";
}); });
tearDown(() async { tearDown(() async {
await app.httpServer.close(force: true); await angelHttp.close();
client = null; client = null;
url = null; url = null;
basicAuthUrl = null; basicAuthUrl = null;
@ -68,7 +70,7 @@ main() async {
test('successRedirect', () async { test('successRedirect', () async {
Map postData = {'username': 'username', 'password': 'password'}; Map postData = {'username': 'username', 'password': 'password'};
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(200)); expect(response.statusCode, equals(200));
expect(response.headers[HttpHeaders.LOCATION], equals('/success')); expect(response.headers[HttpHeaders.LOCATION], equals('/success'));
@ -77,7 +79,7 @@ main() async {
test('failureRedirect', () async { test('failureRedirect', () async {
Map postData = {'username': 'password', 'password': 'username'}; Map postData = {'username': 'password', 'password': 'username'};
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});
print("Login response: ${response.body}"); print("Login response: ${response.body}");
expect(response.headers[HttpHeaders.LOCATION], equals('/failure')); expect(response.headers[HttpHeaders.LOCATION], equals('/failure'));
@ -85,7 +87,7 @@ main() async {
}); });
test('allow basic', () async { test('allow basic', () async {
String authString = BASE64.encode("username:password".runes.toList()); String authString = base64.encode("username:password".runes.toList());
var response = await client.get("$url/hello", var response = await client.get("$url/hello",
headers: {HttpHeaders.AUTHORIZATION: 'Basic $authString'}); headers: {HttpHeaders.AUTHORIZATION: 'Basic $authString'});
expect(response.body, equals('"Woo auth"')); expect(response.body, equals('"Woo auth"'));

View file

@ -0,0 +1,44 @@
import 'dart:io';
import 'package:angel_auth/angel_auth.dart';
import 'package:test/test.dart';
const Duration threeDays = const Duration(days: 3);
void main() {
Cookie defaultCookie;
var auth = new AngelAuth(
secureCookies: true,
cookieDomain: 'SECURE',
jwtLifeSpan: threeDays.inMilliseconds,
);
setUp(() => defaultCookie = new Cookie('a', 'b'));
test('sets maxAge', () {
expect(auth.protectCookie(defaultCookie).maxAge, threeDays.inSeconds);
});
test('sets expires', () {
var now = new DateTime.now();
var expiry = auth.protectCookie(defaultCookie).expires;
var diff = expiry.difference(now);
expect(diff.inSeconds, threeDays.inSeconds);
});
test('sets httpOnly', () {
expect(auth.protectCookie(defaultCookie).httpOnly, true);
});
test('sets secure', () {
expect(auth.protectCookie(defaultCookie).secure, true);
});
test('sets domain', () {
expect(auth.protectCookie(defaultCookie).domain, 'SECURE');
});
test('preserves domain if present', () {
expect(auth.protectCookie(defaultCookie..domain = 'foo').domain, 'foo');
});
}