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">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<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
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
* `LocalAuthStrategy` returns `true` on `Basic` authentication.

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import 'strategy.dart';
/// Handles authentication within an Angel application.
class AngelAuth<T> {
Hmac _hs256;
num _jwtLifeSpan;
int _jwtLifeSpan;
final StreamController<T> _onLogin = new StreamController<T>(),
_onLogout = new StreamController<T>();
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`.
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`.
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.
///
/// 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`.
String reviveTokenEndpoint;
@ -62,17 +71,20 @@ class AngelAuth<T> {
return new String.fromCharCodes(chars);
}
/// `jwtLifeSpan` - should be in *milliseconds*.
AngelAuth(
{String jwtKey,
num jwtLifeSpan,
this.allowCookie: true,
this.allowTokenInQuery: true,
this.enforceIp: true,
this.cookieDomain,
this.secureCookies: true,
this.middlewareName: 'auth',
this.reviveTokenEndpoint: "/auth/token"})
: super() {
_hs256 = new Hmac(sha256, (jwtKey ?? _randomString()).codeUnits);
_jwtLifeSpan = jwtLifeSpan ?? -1;
_jwtLifeSpan = jwtLifeSpan?.toInt() ?? -1;
}
Future configureServer(Angel app) async {
@ -121,7 +133,7 @@ class AngelAuth<T> {
}
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()))
throw new AngelHttpException.forbidden(message: "Expired JWT.");
@ -146,20 +158,40 @@ class AngelAuth<T> {
req.cookies.any((cookie) => cookie.name == "token")) {
return req.cookies.firstWhere((cookie) => cookie.name == "token").value;
} else if (allowTokenInQuery && req.query['token'] is String) {
return req.query['token'];
return req.query['token']?.toString();
}
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.
Future<Map<String, dynamic>> reviveJwt(RequestContext req, ResponseContext res) async {
Future<Map<String, dynamic>> reviveJwt(
RequestContext req, ResponseContext res) async {
try {
var jwt = getJwt(req);
if (jwt == null) {
var body = await req.lazyBody();
jwt = body['token'];
jwt = body['token']?.toString();
}
if (jwt == null) {
throw new AngelHttpException.forbidden(message: "No JWT provided");
@ -172,7 +204,7 @@ class AngelAuth<T> {
}
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())) {
print(
@ -183,7 +215,8 @@ class AngelAuth<T> {
}
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);
return {'data': data, 'token': token.serialize(_hs256)};
@ -207,7 +240,7 @@ class AngelAuth<T> {
List<String> names = [];
var arr = type is Iterable ? type.toList() : [type];
for (var t in arr) {
for (String t in arr) {
var n = t
.split(',')
.map((s) => s.trim())
@ -227,7 +260,7 @@ class AngelAuth<T> {
if (result == true)
return result;
else if (result != false) {
var userId = await serializer(result);
var userId = await serializer(result as T);
// Create JWT
var token = new AuthToken(
@ -242,7 +275,8 @@ class AngelAuth<T> {
_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) {
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("*/*") ||
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);
return {"data": user, "token": jwt};
}
@ -283,7 +317,8 @@ class AngelAuth<T> {
_onLogin.add(user);
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.
@ -295,7 +330,8 @@ class AngelAuth<T> {
_onLogin.add(user);
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.
@ -314,7 +350,7 @@ class AngelAuth<T> {
}
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.properties.remove('user');

View file

@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:dart2_constant/convert.dart';
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import '../options.dart';
@ -50,7 +50,7 @@ class LocalAuthStrategy extends AuthStrategy {
if (_rgxBasic.hasMatch(authHeader)) {
String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1);
String authString =
new String.fromCharCodes(BASE64.decode(base64AuthString));
new String.fromCharCodes(base64.decode(base64AuthString));
if (_rgxUsrPass.hasMatch(authString)) {
Match usrPassMatch = _rgxUsrPass.firstMatch(authString);
verificationResult =
@ -73,10 +73,10 @@ class LocalAuthStrategy extends AuthStrategy {
if (verificationResult == null) {
await req.parse();
if (_validateString(req.body[usernameField]) &&
_validateString(req.body[passwordField])) {
if (_validateString(req.body[usernameField]?.toString()) &&
_validateString(req.body[passwordField]?.toString())) {
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
description: A complete authentication plugin for Angel.
version: 1.1.0+2
version: 1.1.1
author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/angel_auth
environment:

View file

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

View file

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