platform/lib/src/plugin.dart

381 lines
12 KiB
Dart
Raw Normal View History

2016-09-21 23:09:23 +00:00
import 'dart:async';
import 'dart:io';
import 'dart:math' as Math;
2016-09-21 06:19:52 +00:00
import 'package:angel_framework/angel_framework.dart';
2016-09-21 23:09:23 +00:00
import 'package:crypto/crypto.dart';
import 'auth_token.dart';
2016-09-21 06:19:52 +00:00
import 'defs.dart';
import 'options.dart';
import 'strategy.dart';
2017-06-03 21:39:55 +00:00
/// Handles authentication within an Angel application.
2018-09-11 22:03:35 +00:00
class AngelAuth<User> {
2016-09-21 23:09:23 +00:00
Hmac _hs256;
2018-06-27 16:36:31 +00:00
int _jwtLifeSpan;
2018-09-11 22:03:35 +00:00
final StreamController<User> _onLogin = new StreamController<User>(),
_onLogout = new StreamController<User>();
2016-09-21 23:09:23 +00:00
Math.Random _random = new Math.Random.secure();
final RegExp _rgxBearer = new RegExp(r"^Bearer");
2017-06-03 21:39:55 +00:00
/// If `true` (default), then JWT's will be stored and retrieved from a `token` cookie.
2016-11-23 20:37:40 +00:00
final bool allowCookie;
2017-06-03 21:39:55 +00:00
/// If `true` (default), then users can include a JWT in the query string as `token`.
2016-11-23 20:37:40 +00:00
final bool allowTokenInQuery;
2017-06-03 21:39:55 +00:00
2018-06-27 16:36:31 +00:00
/// 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.
///
2018-06-27 18:10:56 +00:00
/// Only applies if [allowCookie] is `true`.
2018-06-27 16:36:31 +00:00
final String cookieDomain;
2018-06-27 18:10:56 +00:00
/// A path to restrict emitted cookies to.
///
/// Only applies if [allowCookie] is `true`.
final String cookiePath;
2017-06-03 21:39:55 +00:00
/// 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.
2018-06-27 16:36:31 +00:00
final bool enforceIp;
2017-06-03 21:39:55 +00:00
/// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`.
2016-09-22 09:26:38 +00:00
String reviveTokenEndpoint;
2017-06-03 21:39:55 +00:00
/// A set of [AuthStrategy] instances used to authenticate users.
2018-09-11 22:03:35 +00:00
Map<String, AuthStrategy<User>> strategies = {};
2016-09-21 06:19:52 +00:00
2017-06-03 21:39:55 +00:00
/// Serializes a user into a unique identifier associated only with one identity.
2018-09-11 22:03:35 +00:00
UserSerializer<User> serializer;
2017-06-03 21:39:55 +00:00
/// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance.
2018-09-11 22:03:35 +00:00
UserDeserializer<User> deserializer;
2017-06-03 21:39:55 +00:00
/// Fires the result of [deserializer] whenever a user signs in to the application.
2018-09-11 22:03:35 +00:00
Stream<User> get onLogin => _onLogin.stream;
2017-06-03 21:39:55 +00:00
/// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application.
2018-09-11 22:03:35 +00:00
Stream<User> get onLogout => _onLogout.stream;
2017-06-03 21:39:55 +00:00
/// The [Hmac] being used to encode JWT's.
2017-01-20 23:15:21 +00:00
Hmac get hmac => _hs256;
2016-10-08 11:39:39 +00:00
String _randomString(
{int length: 32,
String validChars:
"ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"}) {
2016-09-21 23:09:23 +00:00
var chars = <int>[];
while (chars.length < length) chars.add(_random.nextInt(validChars.length));
return new String.fromCharCodes(chars);
}
2018-06-27 16:36:31 +00:00
/// `jwtLifeSpan` - should be in *milliseconds*.
2016-10-08 11:39:39 +00:00
AngelAuth(
{String jwtKey,
num jwtLifeSpan,
2016-11-23 20:37:40 +00:00
this.allowCookie: true,
this.allowTokenInQuery: true,
2016-10-08 11:39:39 +00:00
this.enforceIp: true,
2018-06-27 16:36:31 +00:00
this.cookieDomain,
2018-06-27 18:10:56 +00:00
this.cookiePath: '/',
2018-06-27 16:36:31 +00:00
this.secureCookies: true,
2016-10-08 11:39:39 +00:00
this.reviveTokenEndpoint: "/auth/token"})
: super() {
2016-09-21 23:09:23 +00:00
_hs256 = new Hmac(sha256, (jwtKey ?? _randomString()).codeUnits);
2018-06-27 16:36:31 +00:00
_jwtLifeSpan = jwtLifeSpan?.toInt() ?? -1;
2016-09-21 23:09:23 +00:00
}
2017-09-24 04:32:38 +00:00
Future configureServer(Angel app) async {
2017-06-03 21:39:55 +00:00
if (serializer == null)
throw new StateError(
'An `AngelAuth` plug-in was called without its `serializer` being set. All authentication will fail.');
if (deserializer == null)
throw new StateError(
'An `AngelAuth` plug-in was called without its `deserializer` being set. All authentication will fail.');
2018-08-26 23:11:37 +00:00
app.container.registerSingleton(this);
if (runtimeType != AngelAuth)
app.container.registerSingleton(this, as: AngelAuth);
2016-09-22 09:26:38 +00:00
if (reviveTokenEndpoint != null) {
2016-11-23 20:37:40 +00:00
app.post(reviveTokenEndpoint, reviveJwt);
2016-09-22 09:26:38 +00:00
}
2017-06-03 21:39:55 +00:00
2017-09-24 04:32:38 +00:00
app.shutdownHooks.add((_) {
2017-06-03 21:39:55 +00:00
_onLogin.close();
});
2016-09-21 06:19:52 +00:00
}
2018-08-26 23:11:37 +00:00
void _apply(
2018-09-11 22:03:35 +00:00
RequestContext req, ResponseContext res, AuthToken token, User user) {
if (!req.container.has<User>()) {
req.container
..registerSingleton<AuthToken>(token)
..registerSingleton<User>(user);
}
2018-06-27 16:59:40 +00:00
if (allowCookie == true) {
2018-06-27 18:10:56 +00:00
_addProtectedCookie(res, 'token', token.serialize(_hs256));
2018-06-27 16:59:40 +00:00
}
2017-04-25 03:02:50 +00:00
}
2017-06-03 21:39:55 +00:00
/// A middleware that decodes a JWT from a request, and injects a corresponding user.
2017-09-24 04:32:38 +00:00
Future decodeJwt(RequestContext req, ResponseContext res) async {
2016-10-08 11:39:39 +00:00
if (req.method == "POST" && req.path == reviveTokenEndpoint) {
2016-11-23 20:37:40 +00:00
return await reviveJwt(req, res);
}
2016-10-08 11:39:39 +00:00
2016-11-23 20:37:40 +00:00
String jwt = getJwt(req);
2016-09-21 23:09:23 +00:00
if (jwt != null) {
var token = new AuthToken.validate(jwt, _hs256);
if (enforceIp) {
2016-11-23 20:37:40 +00:00
if (req.ip != null && req.ip != token.ipAddress)
2017-01-13 15:50:38 +00:00
throw new AngelHttpException.forbidden(
2016-09-21 23:09:23 +00:00
message: "JWT cannot be accessed from this IP address.");
}
if (token.lifeSpan > -1) {
2018-06-27 17:43:46 +00:00
var expiry = token.issuedAt
.add(new Duration(milliseconds: token.lifeSpan.toInt()));
2016-09-21 23:09:23 +00:00
2018-06-27 17:43:46 +00:00
if (!expiry.isAfter(new DateTime.now()))
2017-01-13 15:50:38 +00:00
throw new AngelHttpException.forbidden(message: "Expired JWT.");
2016-11-23 20:37:40 +00:00
}
2016-12-13 16:39:20 +00:00
final user = await deserializer(token.userId);
2018-06-27 16:59:40 +00:00
_apply(req, res, token, user);
2016-09-21 06:19:52 +00:00
}
return true;
}
2017-06-03 21:39:55 +00:00
/// Retrieves a JWT from a request, if any was sent at all.
2017-09-24 04:32:38 +00:00
String getJwt(RequestContext req) {
2016-09-22 09:26:38 +00:00
if (req.headers.value("Authorization") != null) {
2016-11-23 20:37:40 +00:00
final authHeader = req.headers.value("Authorization");
// Allow Basic auth to fall through
if (_rgxBearer.hasMatch(authHeader))
return authHeader.replaceAll(_rgxBearer, "").trim();
2016-12-03 18:23:11 +00:00
} else if (allowCookie &&
req.cookies.any((cookie) => cookie.name == "token")) {
2016-09-22 09:26:38 +00:00
return req.cookies.firstWhere((cookie) => cookie.name == "token").value;
2018-08-26 23:11:37 +00:00
} else if (allowTokenInQuery &&
req.uri.queryParameters['token'] is String) {
return req.uri.queryParameters['token']?.toString();
2016-09-22 09:26:38 +00:00
}
return null;
}
2018-06-27 18:10:56 +00:00
void _addProtectedCookie(ResponseContext res, String name, String value) {
if (!res.cookies.any((c) => c.name == name)) {
res.cookies.add(protectCookie(new Cookie(name, value)));
}
}
2018-06-27 16:36:31 +00:00
/// Applies security protections to a [cookie].
Cookie protectCookie(Cookie cookie) {
if (secureCookies != false) {
cookie.httpOnly = true;
cookie.secure = true;
}
if (_jwtLifeSpan > 0) {
2018-07-12 17:11:54 +00:00
cookie.maxAge ??= _jwtLifeSpan < 0 ? -1 : _jwtLifeSpan ~/ 1000;
2018-06-27 16:36:31 +00:00
cookie.expires ??=
new DateTime.now().add(new Duration(milliseconds: _jwtLifeSpan));
}
2018-06-27 18:10:56 +00:00
cookie.domain ??= cookieDomain;
cookie.path ??= cookiePath;
2018-06-27 16:36:31 +00:00
return cookie;
}
2017-06-03 21:39:55 +00:00
/// Attempts to revive an expired (or still alive) JWT.
2018-06-27 16:36:31 +00:00
Future<Map<String, dynamic>> reviveJwt(
RequestContext req, ResponseContext res) async {
2016-09-22 09:26:38 +00:00
try {
2016-11-23 20:37:40 +00:00
var jwt = getJwt(req);
2016-09-22 09:26:38 +00:00
2017-04-26 13:59:27 +00:00
if (jwt == null) {
2018-08-26 23:11:37 +00:00
var body = await req.parseBody();
2018-06-27 16:36:31 +00:00
jwt = body['token']?.toString();
2017-04-26 13:59:27 +00:00
}
2016-09-22 09:26:38 +00:00
if (jwt == null) {
2017-01-13 15:50:38 +00:00
throw new AngelHttpException.forbidden(message: "No JWT provided");
2016-09-22 09:26:38 +00:00
} else {
var token = new AuthToken.validate(jwt, _hs256);
if (enforceIp) {
if (req.ip != token.ipAddress)
2017-01-13 15:50:38 +00:00
throw new AngelHttpException.forbidden(
2016-09-22 09:26:38 +00:00
message: "JWT cannot be accessed from this IP address.");
}
if (token.lifeSpan > -1) {
2018-06-27 17:43:46 +00:00
var expiry = token.issuedAt
2018-06-27 16:59:40 +00:00
.add(new Duration(milliseconds: token.lifeSpan.toInt()));
2016-09-22 09:26:38 +00:00
2018-06-27 17:43:46 +00:00
if (!expiry.isAfter(new DateTime.now())) {
//print(
// 'Token has indeed expired! Resetting assignment date to current timestamp...');
2016-09-22 09:26:38 +00:00
// Extend its lifespan by changing iat
token.issuedAt = new DateTime.now();
}
2016-10-08 11:39:39 +00:00
}
2018-06-27 18:10:56 +00:00
if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256));
}
2016-12-03 18:23:11 +00:00
final data = await deserializer(token.userId);
return {'data': data, 'token': token.serialize(_hs256)};
2016-09-22 09:26:38 +00:00
}
2017-09-24 04:32:38 +00:00
} catch (e) {
2016-10-08 11:39:39 +00:00
if (e is AngelHttpException) rethrow;
2017-01-13 15:50:38 +00:00
throw new AngelHttpException.badRequest(message: "Malformed JWT");
2016-09-22 09:26:38 +00:00
}
}
2017-06-03 21:39:55 +00:00
/// Attempts to authenticate a user using one or more strategies.
///
2017-09-24 04:32:38 +00:00
/// [type] is a strategy name to try, or a `List` of such.
2017-06-03 21:39:55 +00:00
///
/// If a strategy returns `null` or `false`, either the next one is tried,
/// or a `401 Not Authenticated` is thrown, if it is the last one.
///
/// Any other result is considered an authenticated user, and terminates the loop.
2017-09-24 04:32:38 +00:00
RequestHandler authenticate(type, [AngelAuthOptions options]) {
2016-09-21 06:19:52 +00:00
return (RequestContext req, ResponseContext res) async {
2017-09-24 04:32:38 +00:00
List<String> names = [];
var arr = type is Iterable ? type.toList() : [type];
2018-06-27 16:36:31 +00:00
for (String t in arr) {
2017-09-24 04:32:38 +00:00
var n = t
.split(',')
.map((s) => s.trim())
.where((String s) => s.isNotEmpty)
.toList();
names.addAll(n);
}
2017-06-03 21:39:55 +00:00
for (int i = 0; i < names.length; i++) {
var name = names[i];
2018-09-11 22:03:35 +00:00
var strategy = strategies[name] ??=
throw new ArgumentError('No strategy "$name" found.');
2018-06-27 17:17:44 +00:00
2018-09-11 22:03:35 +00:00
var hasExisting = req.container.has<User>();
2018-06-27 17:17:44 +00:00
var result = hasExisting
2018-09-11 22:03:35 +00:00
? req.container.make<User>()
: await strategy.authenticate(req, res, options);
2017-06-03 21:39:55 +00:00
if (result == true)
return result;
else if (result != false) {
2018-08-26 23:11:37 +00:00
var userId = await serializer(result);
2017-06-03 21:39:55 +00:00
// Create JWT
var token = new AuthToken(
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
var jwt = token.serialize(_hs256);
if (options?.tokenCallback != null) {
2018-09-11 22:03:35 +00:00
if (!req.container.has<User>()) {
req.container.registerSingleton<User>(result);
}
2018-08-26 23:11:37 +00:00
var r = await options.tokenCallback(req, res, token, result);
2017-06-03 21:39:55 +00:00
if (r != null) return r;
2018-06-27 18:10:56 +00:00
jwt = token.serialize(_hs256);
2017-06-03 21:39:55 +00:00
}
2017-01-20 23:15:21 +00:00
2018-06-27 16:59:40 +00:00
_apply(req, res, token, result);
2016-11-23 20:37:40 +00:00
2018-06-27 18:10:56 +00:00
if (allowCookie) {
_addProtectedCookie(res, 'token', jwt);
}
2016-09-21 23:09:23 +00:00
2017-06-03 21:39:55 +00:00
if (options?.callback != null) {
return await options.callback(req, res, jwt);
}
2016-12-07 23:09:21 +00:00
2017-06-03 21:39:55 +00:00
if (options?.successRedirect?.isNotEmpty == true) {
2018-07-12 17:11:54 +00:00
res.redirect(options.successRedirect, code: 200);
2017-06-03 21:39:55 +00:00
return false;
} else if (options?.canRespondWithJson != false &&
2018-06-27 17:17:44 +00:00
req.accepts('application/json')) {
var user = hasExisting
2018-08-26 23:11:37 +00:00
? result
: await deserializer(await serializer(result));
2017-06-03 21:39:55 +00:00
_onLogin.add(user);
return {"data": user, "token": jwt};
}
2016-09-21 23:09:23 +00:00
2017-06-03 21:39:55 +00:00
return true;
} else {
if (i < names.length - 1) continue;
// Check if not redirect
if (res.statusCode == 301 ||
res.statusCode == 302 ||
2018-07-12 17:11:54 +00:00
res.headers.containsKey('location'))
2017-06-03 21:39:55 +00:00
return false;
else
throw new AngelHttpException.notAuthenticated();
}
2016-09-21 06:19:52 +00:00
}
};
}
2017-01-20 23:15:21 +00:00
/// Log a user in on-demand.
Future login(AuthToken token, RequestContext req, ResponseContext res) async {
var user = await deserializer(token.userId);
2018-06-27 16:59:40 +00:00
_apply(req, res, token, user);
2017-06-03 21:39:55 +00:00
_onLogin.add(user);
2017-01-20 23:15:21 +00:00
2018-06-27 18:10:56 +00:00
if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256));
}
2017-01-20 23:15:21 +00:00
}
/// Log a user in on-demand.
Future loginById(userId, RequestContext req, ResponseContext res) async {
var user = await deserializer(userId);
var token = new AuthToken(
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
2018-06-27 16:59:40 +00:00
_apply(req, res, token, user);
2017-06-03 21:39:55 +00:00
_onLogin.add(user);
2017-01-20 23:15:21 +00:00
2018-06-27 18:10:56 +00:00
if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256));
}
2017-01-20 23:15:21 +00:00
}
2017-06-03 21:39:55 +00:00
/// Log an authenticated user out.
2018-08-26 23:11:37 +00:00
RequestHandler logout([AngelAuthOptions options]) {
2016-09-21 06:19:52 +00:00
return (RequestContext req, ResponseContext res) async {
2018-09-11 22:03:35 +00:00
if (req.container.has<User>()) {
var user = req.container.make<User>();
2018-08-26 23:11:37 +00:00
_onLogout.add(user);
}
2017-06-03 21:39:55 +00:00
2018-06-27 17:43:46 +00:00
if (allowCookie == true) {
2017-06-03 21:39:55 +00:00
res.cookies.removeWhere((cookie) => cookie.name == "token");
2018-06-27 18:10:56 +00:00
_addProtectedCookie(res, 'token', '');
2018-06-27 17:43:46 +00:00
}
2016-09-21 06:19:52 +00:00
if (options != null &&
options.successRedirect != null &&
options.successRedirect.isNotEmpty) {
2017-09-24 04:32:38 +00:00
res.redirect(options.successRedirect);
2016-09-21 06:19:52 +00:00
}
return true;
};
}
2016-09-21 23:09:23 +00:00
}