1.1.1
This commit is contained in:
parent
c891d130b2
commit
c8444a7c7b
12 changed files with 152 additions and 51 deletions
|
@ -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" />
|
||||
|
|
|
@ -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>
|
|
@ -1 +1,4 @@
|
|||
language: dart
|
||||
dart:
|
||||
- dev
|
||||
- stable
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
analyzer:
|
||||
strong-mode: true
|
||||
strong-mode:
|
||||
implicit-casts: false
|
|
@ -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() {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"'));
|
||||
|
|
44
test/protect_cookie_test.dart
Normal file
44
test/protect_cookie_test.dart
Normal 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');
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue