Skip to content

Instantly share code, notes, and snippets.

@ziqq
Forked from PlugFox/jwt.dart
Created February 13, 2024 13:05
Show Gist options
  • Save ziqq/aaad16283f1a2ffc09540470d6ebc18d to your computer and use it in GitHub Desktop.
Save ziqq/aaad16283f1a2ffc09540470d6ebc18d to your computer and use it in GitHub Desktop.
JWT Hmac SHA256 HS256
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:meta/meta.dart';
/// Токен JWT состоит из трех частей: заголовка (header), полезной нагрузки (payload) и подписи или данных шифрования.
/// Первые два элемента — это JSON объекты определенной структуры.
/// Третий элемент вычисляется на основании первых и зависит от выбранного алгоритма
/// (в случае использования неподписанного JWT может быть опущен).
/// Токены могут быть перекодированы в компактное представление (JWS/JWE Compact Serialization):
/// к заголовку и полезной нагрузке применяется алгоритм кодирования Base64-URL,
/// после чего добавляется подпись и все три элемента разделяются точками («.»).
@immutable
abstract class JWT {
/// Decode a string JWT token into a `Map<String, Object>`
/// containing the decoded JSON payload.
///
/// Note: header and signature are not returned by this method.
///
/// Throws [FormatException] if parameter is not a valid JWT token.
factory JWT.decode(String token) = _JWTImpl.decode;
/// Can return null, static method, not factory
// ignore: prefer_constructors_over_static_methods
static JWT? tryDecode(String token) {
try {
return JWT.decode(token);
} on Object {
return null;
}
}
/// Генерирует JWT
/// Реализован только алгоритм HS256
factory JWT.create({
/// Payload / Data
required final Map<String, Object?> payload,
/// Header, algorithm & token type
final Map<String, Object?>? header,
/// Secret for creating a signed token
final String? secret,
}) =>
_JWTImpl.create(
header: header ??
<String, Object?>{
'alg': 'HS256',
'typ': 'JWT',
},
payload: payload,
secret: secret ?? 'secret',
);
/// Токен подписан, [signature] не пустая
bool get isSigned;
/// Header, algorithm & token type
JWTHeader get header;
/// Payload / Data
JWTPayload get payload;
/// Verify signature
String get signature;
/// Validates and returns a list of validation errors.
/// Empty set indicates there were no validation errors.
/// Проверяет заполнение содержимого
Set<String> validatePayload({
/// Проверить алгоритм на соответсвие
final String? algorithm,
/// Текущее время для проверки актуальности токена, по умолчанию - DateTime.now()
final DateTime? dateTime,
/// Допуск расхождения с текущим временем (с точностью до секунд)
final Duration? tolerance,
/// Проверить истекание
final bool expiresAt = true,
/// Проверить выпуск
final bool issuedAt = false,
/// Проверить время с которого он начинает действовать
final bool notBefore = false,
/// Проверить идентификатор проекта
final String? audience,
/// Проверить выпустившего токен
final String? issuer,
/// Проверить принадлежность пользователю
final String? subject,
});
/// Получить исходный токен в виде строки
@override
String toString();
}
/// HashMap<String, Object?>
@immutable
abstract class _ImmutableJSON extends MapBase<String, Object?> {
Map<String, Object?> get _source;
@override
Object? operator [](Object? key) => _source[key];
@override
Iterable<String> get keys => _source.keys;
@override
void operator []=(String key, Object? value) => throw UnsupportedError('Cannot modify unmodifiable map');
@override
void clear() => throw UnsupportedError('Cannot modify unmodifiable map');
@override
Object? remove(Object? key) => throw UnsupportedError('Cannot modify unmodifiable map');
@override
int get hashCode => _source.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) || (other is _ImmutableJSON && const DeepCollectionEquality.unordered().equals(_source, other._source));
}
/// В заголовке указывается необходимая информация для описания самого токена.
@immutable
abstract class JWTHeader extends _ImmutableJSON {
/// List of standard (reserved) claims.
static const Set<String> reservedClaims = <String>{'alg', 'kid', 'typ', 'cty'};
/// Algorithm (alg)
/// Алгоритм, используемый для подписи/шифрования (в случае неподписанного JWT используется значение «none»).
String get algorithm;
/// Key ID (kid)
String? get keyID;
/// Type of token (typ)
/// Тип токена (type). Используется в случае, когда токены смешиваются с другими объектами,
/// имеющими JOSE заголовки. Должно иметь значение «JWT».
String? get tokenType;
/// Content type (cty)
/// Тип содержимого (content type).
/// Если в токене помимо зарегистрированных служебных ключей есть пользовательские,
/// то данный ключ не должен присутствовать. В противном случае должно иметь значение «JWT»
String? get contentType;
}
/// В данной секции указывается пользовательская информация (например, имя пользователя и уровень его доступа),
/// а также могут быть использованы некоторые служебные ключи. Все они являются необязательными
abstract class JWTPayload extends _ImmutableJSON {
/// List of standard (reserved) claims / payload.
static const Set<String> reservedClaims = <String>{'iss', 'aud', 'iat', 'exp', 'nbf', 'sub', 'jti'};
/// The issuer of this token (iss).
/// Чувствительная к регистру строка или URI,
/// которая является уникальным идентификатором стороны, генерирующей токен (issuer).
String? get issuer;
/// The audience of this token (aud).
/// Массив чувствительных к регистру строк или URI, являющийся списком получателей данного токена.
/// Когда принимающая сторона получает JWT с данным ключом,
/// она должна проверить наличие себя в получателях — иначе проигнорировать токен (audience).
Object? get audience;
/// The time this token was issued (iat).
/// Время в формате Unix Time, определяющее момент, когда токен был создан.
/// [issuedAt] и [notBefore] могут не совпадать, например, если токен был создан раньше,
/// чем время, когда он должен стать валидным
int? get issuedAt;
/// The expiration time of this token (exp).
/// Время в формате Unix Time, определяющее момент, когда токен станет невалидным (expiration).
int? get expiresAt;
/// The time before which this token must not be accepted (nbf).
/// В противоположность ключу [expiresAt], это время в формате Unix Time, определяющее момент,
/// когда токен станет валидным (not before).
int? get notBefore;
/// Identifies the principal that is the subject of this token (sub).
/// Чувствительная к регистру строка или URI, которая является уникальным идентификатором стороны,
/// о которой содержится информация в данном токене (subject).
/// Значения с этим ключом должны быть уникальны в контексте стороны, генерирующей JWT.
/// По сути это UID пользователя.
String? get subject;
/// Unique identifier of this token (jti).
/// Строка, определяющая уникальный идентификатор данного токена (JWT ID).
String? get jwtID;
}
class _JWTImpl with _JWTValidationMixin implements JWT {
factory _JWTImpl.decode(String token) {
final splitToken = token.split('.'); // Split the token by '.'
final length = splitToken.length;
if (length < 2 || length > 3) {
throw const FormatException('Invalid token');
}
return _JWTImpl._(
header: _JWTHeaderImpl.decode(splitToken[0]),
payload: _JWTPayloadImpl.decode(splitToken[1]),
signature: length < 3 ? '' : splitToken[2],
token: token,
);
}
_JWTImpl._({
required final this.header,
required final this.payload,
required final this.signature,
required final String token,
}) : _token = token;
factory _JWTImpl.create({
required final Map<String, Object?> header,
required final Map<String, Object?> payload,
required final String? secret,
}) {
final createdHeader = _JWTHeaderImpl.create(header);
final createdPayload = _JWTPayloadImpl.create(payload);
final _jsonToBase64Url = json.fuse(utf8.fuse(base64Url));
final buffer = StringBuffer();
final headerBase64 = _base64Unpadded(_jsonToBase64Url.encode(createdHeader));
buffer.write(headerBase64);
final payloadBase64 = _base64Unpadded(_jsonToBase64Url.encode(createdPayload));
buffer
..write('.')
..write(payloadBase64);
if (secret != null) {
final algorithm = JWTAlgorithm.fromName(createdHeader.algorithm);
final body = Uint8List.fromList(utf8.encode(buffer.toString()));
final signature = _base64Unpadded(base64Url.encode(algorithm.sign(secret, body)));
buffer
..write('.')
..write(signature);
}
final jwt = _JWTImpl.decode(buffer.toString());
buffer.clear();
return jwt;
}
/// Исходный токен
final String _token;
@override
bool get isSigned => signature.isNotEmpty;
@override
final JWTHeader header;
@override
final JWTPayload payload;
@override
final String signature;
@override
String toString() => _token;
@override
int get hashCode => _token.hashCode;
@override
bool operator ==(Object other) => identical(this, other) || (other is _JWTImpl && _token == other._token);
}
class _JWTHeaderImpl extends JWTHeader {
factory _JWTHeaderImpl.decode(final String header) {
try {
// Base64 should be multiple of 4. Normalize the payload before decode it
final normalizedPayload = base64.normalize(header);
// Decode payload, the result is a String
final payloadString = utf8.decode(base64.decode(normalizedPayload));
// Parse the String to a Map<String, Object?>
final data = jsonDecode(payloadString) as Map<String, Object?>;
return _JWTHeaderImpl._(data);
} on Object {
throw const FormatException('Invalid header');
}
}
factory _JWTHeaderImpl.create(Map<String, Object?> source) = _JWTHeaderImpl._;
_JWTHeaderImpl._(Map<String, Object?> source)
: _source = source,
algorithm = source['alg']?.toString() ?? '',
contentType = source['cty']?.toString(),
keyID = source['kid']?.toString(),
tokenType = source['typ']?.toString();
@override
final Map<String, Object?> _source;
@override
final String algorithm;
@override
final String? contentType;
@override
final String? keyID;
@override
final String? tokenType;
}
class _JWTPayloadImpl extends JWTPayload {
factory _JWTPayloadImpl.decode(final String payload) {
try {
// Base64 should be multiple of 4. Normalize the payload before decode it
final normalizedPayload = base64.normalize(payload);
// Decode payload, the result is a String
final payloadString = utf8.decode(base64.decode(normalizedPayload));
// Parse the String to a Map<String, Object?>
final data = jsonDecode(payloadString) as Map<String, Object?>;
return _JWTPayloadImpl._(data);
} on Object {
throw const FormatException('Invalid payload');
}
}
factory _JWTPayloadImpl.create(Map<String, Object?> source) = _JWTPayloadImpl._;
_JWTPayloadImpl._(Map<String, Object?> source)
: _source = source,
audience = source['aud'],
expiresAt = _getInt(source['exp']),
issuedAt = _getInt(source['iat']),
issuer = source['iss']?.toString(),
jwtID = source['jti']?.toString(),
notBefore = _getInt(source['nbf']),
subject = source['sub']?.toString();
@override
final Map<String, Object?> _source;
@override
final Object? audience;
@override
final int? expiresAt;
@override
final int? issuedAt;
@override
final String? issuer;
@override
final String? jwtID;
@override
final int? notBefore;
@override
final String? subject;
static int? _getInt(Object? obj) {
if (obj is int) {
return obj;
} else if (obj is String) {
return int.tryParse(obj);
} else {
return null;
}
}
}
mixin _JWTValidationMixin implements JWT {
@override
Set<String> validatePayload({
final String? algorithm,
final DateTime? dateTime,
final Duration? tolerance,
final bool expiresAt = true,
final bool issuedAt = true,
final bool notBefore = false,
final String? audience,
final String? issuer,
final String? subject,
}) {
final errors = <String>{};
final currentTimestamp = (dateTime ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000;
final toleranceSec = tolerance?.inSeconds ?? 0;
if (header.algorithm.isEmpty) {
errors.add('Algorithm must be a non-empty string');
}
if (algorithm != null && algorithm != header.algorithm) {
errors.add('Algorithm is not the same');
}
if (expiresAt) {
final data = payload.expiresAt;
if (data == null || data - currentTimestamp + toleranceSec < 0) {
errors.add('The token has expired');
}
}
if (issuedAt) {
final data = payload.issuedAt;
if (data == null || currentTimestamp - data + toleranceSec < 0) {
errors.add('The token issuedAt time is in future');
}
}
if (notBefore) {
final data = payload.notBefore;
if (data == null || currentTimestamp - data + toleranceSec < 0) {
errors.add('The token can not be accepted due to notBefore policy');
}
}
if (audience != null) {
if (audience != payload.audience) {
errors.add('Audience must be your project ID');
}
}
if (issuer != null) {
if (issuer != payload.issuer) {
errors.add('Issuer is not the same');
}
}
if (subject != null) {
if (subject != payload.subject) {
errors.add('Subject must be a non-empty string and must be the uid of the user or device');
}
}
return errors;
}
}
/// Алгоритм взаимодействия с подписью JWT
abstract class JWTAlgorithm {
/// HMAC using SHA-256 hash algorithm
static const JWTAlgorithm hs256 = _HMAC256Algorithm();
/// Return the `JWTAlgorithm` from his string name
static JWTAlgorithm fromName(String name) {
switch (name) {
case 'HS256':
return JWTAlgorithm.hs256;
default:
throw const FormatException('Unknown algorithm');
}
}
const JWTAlgorithm();
/// `JWTAlgorithm` name
String get name;
/// Create a signature of the `body` with `key`
///
/// return the signature as bytes
Uint8List sign(String key, Uint8List body);
/// Verify the `signature` of `body` with `key`
///
/// return `true` if the signature is correct `false` otherwise
bool verify(String key, Uint8List body, Uint8List signature);
}
class _HMAC256Algorithm extends JWTAlgorithm {
const _HMAC256Algorithm();
@override
String get name => 'HS256';
@override
Uint8List sign(String key, Uint8List body) {
final hmac = Hmac(sha256, utf8.encode(key));
return Uint8List.fromList(hmac.convert(body).bytes);
}
@override
bool verify(String key, Uint8List body, Uint8List signature) {
try {
// Приведем подпись к тому же Base64 виду, что и исходная
final actual = Uint8List.fromList(utf8.encode(_base64Unpadded(base64Url.encode(sign(key, body)))));
if (actual.length != signature.length) {
return false;
}
for (var i = 0; i < actual.length; i++) {
if (actual[i] != signature[i]) return false;
}
return true;
} on Object {
return false;
}
}
}
String _base64Unpadded(String value) {
if (value.endsWith('==')) return value.substring(0, value.length - 2);
if (value.endsWith('=')) return value.substring(0, value.length - 1);
return value;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment