diff --git a/src/OneScript.StandardLibrary/Http/HttpConnectionContext.cs b/src/OneScript.StandardLibrary/Http/HttpConnectionContext.cs index 78472bfbc..a55b77dfe 100644 --- a/src/OneScript.StandardLibrary/Http/HttpConnectionContext.cs +++ b/src/OneScript.StandardLibrary/Http/HttpConnectionContext.cs @@ -13,6 +13,7 @@ This Source Code Form is subject to the terms of the using System.Text; using OneScript.Contexts; using OneScript.Exceptions; +using OneScript.Execution; using OneScript.StandardLibrary.Collections; using OneScript.Types; using RegExp = System.Text.RegularExpressions; @@ -130,9 +131,9 @@ public int Timeout /// Строка. Имя файла, в который нужно записать ответ. Необязательный параметр. /// HTTPОтвет. Ответ сервера. [ContextMethod("Получить", "Get")] - public HttpResponseContext Get(HttpRequestContext request, string output = null) + public HttpResponseContext Get(IBslProcess process, HttpRequestContext request, string output = null) { - return GetResponse(request, "GET", output); + return GetResponse(process, request, "GET", output); } /// @@ -141,9 +142,9 @@ public HttpResponseContext Get(HttpRequestContext request, string output = null) /// HTTPЗапрос. Данные и заголовки запроса http /// HTTPОтвет. Ответ сервера. [ContextMethod("Записать", "Put")] - public HttpResponseContext Put(HttpRequestContext request) + public HttpResponseContext Put(IBslProcess process, HttpRequestContext request) { - return GetResponse(request, "PUT"); + return GetResponse(process, request, "PUT", null); } /// @@ -153,9 +154,9 @@ public HttpResponseContext Put(HttpRequestContext request) /// Строка. Имя файла, в который нужно записать ответ. Необязательный параметр. /// HTTPОтвет. Ответ сервера. [ContextMethod("ОтправитьДляОбработки", "Post")] - public HttpResponseContext Post(HttpRequestContext request, string output = null) + public HttpResponseContext Post(IBslProcess process, HttpRequestContext request, string output = null) { - return GetResponse(request, "POST", output); + return GetResponse(process, request, "POST", output); } /// @@ -164,9 +165,9 @@ public HttpResponseContext Post(HttpRequestContext request, string output = null /// HTTPЗапрос. Данные и заголовки запроса http /// HTTPОтвет. Ответ сервера. [ContextMethod("Удалить", "Delete")] - public HttpResponseContext Delete(HttpRequestContext request) + public HttpResponseContext Delete(IBslProcess process, HttpRequestContext request) { - return GetResponse(request, "DELETE"); + return GetResponse(process, request, "DELETE"); } /// @@ -175,9 +176,9 @@ public HttpResponseContext Delete(HttpRequestContext request) /// HTTPЗапрос. Данные и заголовки запроса http /// HTTPОтвет. Ответ сервера. [ContextMethod("Изменить", "Patch")] - public HttpResponseContext Patch(HttpRequestContext request) + public HttpResponseContext Patch(IBslProcess process, HttpRequestContext request) { - return GetResponse(request, "PATCH"); + return GetResponse(process, request, "PATCH"); } /// @@ -186,9 +187,9 @@ public HttpResponseContext Patch(HttpRequestContext request) /// HTTPЗапрос. Данные и заголовки запроса http /// HTTPОтвет. Ответ сервера. [ContextMethod("ПолучитьЗаголовки", "Head")] - public HttpResponseContext Head(HttpRequestContext request) + public HttpResponseContext Head(IBslProcess process, HttpRequestContext request) { - return GetResponse(request, "HEAD"); + return GetResponse(process, request, "HEAD"); } /// @@ -199,9 +200,9 @@ public HttpResponseContext Head(HttpRequestContext request) /// Строка. Имя выходного файла /// HTTPОтвет. Ответ сервера. [ContextMethod("ВызватьHTTPМетод", "CallHTTPMethod")] - public HttpResponseContext Patch(string method, HttpRequestContext request, string output = null) + public HttpResponseContext CallHTTPMethod(IBslProcess process, string method, HttpRequestContext request, string output = null) { - return GetResponse(request, method, output); + return GetResponse(process, request, method, output); } private HttpWebRequest CreateRequest(string resource) @@ -335,12 +336,12 @@ private static List ParseRange(string rangeHeader) return range; } - private HttpResponseContext GetResponse(HttpRequestContext request, string method, string output = null) + private HttpResponseContext GetResponse(IBslProcess process, HttpRequestContext request, string method, string output = null) { var webRequest = CreateRequest(request.ResourceAddress); webRequest.AllowAutoRedirect = AllowAutoRedirect; webRequest.Method = method; - SetRequestHeaders(request, webRequest); + SetRequestHeaders(request, webRequest, process); if (ContentBodyAllowed(method)) SetRequestBody(request, webRequest); @@ -385,7 +386,7 @@ private static void SetRequestBody(HttpRequestContext request, HttpWebRequest we } } - private static void SetRequestHeaders(HttpRequestContext request, HttpWebRequest webRequest) + private static void SetRequestHeaders(HttpRequestContext request, HttpWebRequest webRequest, IBslProcess process) { foreach (var item in request.Headers.Select(x => x.GetRawValue() as KeyAndValueImpl)) { @@ -483,15 +484,19 @@ private static void SetRequestHeaders(HttpRequestContext request, HttpWebRequest default: webRequest.Headers.Set(key, value); break; - } + } - // fix #1151 - if (webRequest.UserAgent == default) - { - webRequest.UserAgent = $"1Script v${Assembly.GetExecutingAssembly().GetName().Version}"; - } + // fix #1151 + if (webRequest.UserAgent == default) + { + webRequest.UserAgent = $"1Script v${Assembly.GetExecutingAssembly().GetName().Version}"; + } + if (request.AccessToken != null) + { + string token = request.AccessToken.ToString(process); + webRequest.Headers.Set(HttpRequestHeader.Authorization, $"Bearer {token}"); } } diff --git a/src/OneScript.StandardLibrary/Http/HttpRequestContext.cs b/src/OneScript.StandardLibrary/Http/HttpRequestContext.cs index d28946b1a..a190ff265 100644 --- a/src/OneScript.StandardLibrary/Http/HttpRequestContext.cs +++ b/src/OneScript.StandardLibrary/Http/HttpRequestContext.cs @@ -11,6 +11,7 @@ This Source Code Form is subject to the terms of the using OneScript.Exceptions; using OneScript.StandardLibrary.Binary; using OneScript.StandardLibrary.Collections; +using OneScript.StandardLibrary.Security.Tokens; using OneScript.StandardLibrary.Text; using ScriptEngine.Machine; using ScriptEngine.Machine.Contexts; @@ -44,6 +45,8 @@ private void SetBody(IHttpRequestBody newBody) public Stream Body => _body?.GetDataStream(); + public AccessTokenContext AccessToken { get; private set; } + /// /// Относительный путь к ресурсу на сервере (не включает имя сервера) /// @@ -115,6 +118,16 @@ public GenericStream GetBodyAsStream() return new GenericStream(_body.GetDataStream()); } + /// + /// Добавляет токен доступа к HTTP-запросу. Устанавливает заголовок Authorization. + /// + /// Токен доступа + [ContextMethod("ДобавитьТокенДоступа", "AddAccessToken")] + public void AddAccessToken(AccessTokenContext accessToken) + { + AccessToken = accessToken; + } + [ScriptConstructor(Name = "Формирование неинициализированного объекта")] public static HttpRequestContext Constructor() { diff --git a/src/OneScript.StandardLibrary/OneScript.StandardLibrary.csproj b/src/OneScript.StandardLibrary/OneScript.StandardLibrary.csproj index 1c2bbf009..2191c573f 100644 --- a/src/OneScript.StandardLibrary/OneScript.StandardLibrary.csproj +++ b/src/OneScript.StandardLibrary/OneScript.StandardLibrary.csproj @@ -26,6 +26,7 @@ + diff --git a/src/OneScript.StandardLibrary/Security/Tokens/AccessTokenContext.cs b/src/OneScript.StandardLibrary/Security/Tokens/AccessTokenContext.cs new file mode 100644 index 000000000..f0dbc1395 --- /dev/null +++ b/src/OneScript.StandardLibrary/Security/Tokens/AccessTokenContext.cs @@ -0,0 +1,448 @@ +/*---------------------------------------------------------- +This Source Code Form is subject to the terms of the +Mozilla Public License, v.2.0. If a copy of the MPL +was not distributed with this file, You can obtain one +at http://mozilla.org/MPL/2.0/. +----------------------------------------------------------*/ + +using System; +using System.Collections.Generic; +using System.Text; +using System.Security.Cryptography; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Tokens; +using OneScript.Contexts; +using OneScript.Exceptions; +using OneScript.Execution; +using OneScript.StandardLibrary.Collections; +using OneScript.Types; +using ScriptEngine.Machine; +using ScriptEngine.Machine.Contexts; + +namespace OneScript.StandardLibrary.Security.Tokens +{ + /// + /// Описывает структуру токена доступа в формате JSON Web Token. + /// + [ContextClass("ТокенДоступа", "AccessToken")] + public class AccessTokenContext : AutoContext, IDisposable + { + private int? _lifeTime; + private int? _creationTime; + private string _tokenId; + private string _userMatchingKey; + private string _issuer; + private string _token; + private bool _isSigned = false; + private RSA _rsa; + private ECDsa _ecdsa; + private bool _disposed = false; + + /// + /// Определяет время жизни токена в секундах. + /// Устанавливает значение для ключа 'exp' равное сумме времени создания и времени жизни. + /// + /// Число (Number) + [ContextProperty("ВремяЖизни", "LifeTime")] + public int LifeTime + { + get => _lifeTime ?? 0; + set => _lifeTime = value; + } + + /// + /// Числовое значение времени создания токена доступа в формате UnixTime (количество секунд, прошедших с + /// полуночи 01.01.1970). Соответствует ключам 'iat' и 'nbf'. + /// + /// Число (Number) + [ContextProperty("ВремяСоздания", "CreationTime")] + public int CreationTime + { + get => _creationTime ?? 0; + set => _creationTime = value; + } + + /// + /// Заголовки токена доступа. + /// + /// Соответствие (Map) + [ContextProperty("Заголовки", "Headers")] + public MapImpl Headers { get; set; } + + /// + /// Идентификатор токена. + /// + /// Строка (String) + [ContextProperty("Идентификатор", "ID")] + public string TokenId + { + get => _tokenId ?? ""; + set => _tokenId = value; + } + + /// + /// Ключ сопоставления пользователя. Соответствует ключу 'sub' токена доступа. + /// + /// Строка (String) + [ContextProperty("КлючСопоставленияПользователя", "UserMatchingKey")] + public string UserMatchingKey + { + get => _userMatchingKey ?? ""; + set => _userMatchingKey = value; + } + + /// + /// Полезная нагрузка токена доступа. + /// + /// Соответствие (Map) + [ContextProperty("ПолезнаяНагрузка", "Payload")] + public MapImpl Payload { get; set; } + + /// + /// Массив строк, который содержит идентификаторы получателей токена. Соответствует ключу 'aud'. + /// + /// Массив (Array) + [ContextProperty("Получатели", "Recipients")] + public ArrayImpl Recipients { get; set; } + + /// + /// Идентификатор эмитента, выпустившего токен. Соответствует ключу 'iss'. + /// + /// Строка (String) + [ContextProperty("Эмитент", "Issuer")] + public string Issuer + { + get => _issuer ?? ""; + set => _issuer = value; + } + + /// + /// Добавляет токену доступа подпись по указанному в параметрах алгоритму. + /// + /// Алгоритм подписи токена доступа. + /// Информация о ключе, который используется для формирования подписи в формате PEM. + /// Данный параметр является необязательным только, если не указан алгоритм подписи. + [ContextMethod("Подписать", "Sign")] + public void Sign(IBslProcess process, AccessTokenSignAlgorithmEnum algorithm, string secretKey = "") + { + CreateToken(process, algorithm, secretKey); + _isSigned = true; + } + + private void CreateUnsignedToken(IBslProcess process) + { + CreateToken(process, AccessTokenSignAlgorithmEnum.None); + } + + private void CreateToken(IBslProcess process, AccessTokenSignAlgorithmEnum algorithm, string secretKey = "") + { + try + { + var header = CreateJwtHeader(process, algorithm, secretKey); + var payload = CreateJwtPayload(process); + var jwtToken = new JwtSecurityToken(header, payload); + + var tokenHandler = new JwtSecurityTokenHandler + { + SetDefaultTimesOnTokenCreation = false + }; + + _token = tokenHandler.WriteToken(jwtToken); + } + catch (Exception ex) + { + throw new SecurityTokenException($"Ошибка при создании токена: {ex.Message}", ex); + } + } + + private JwtHeader CreateJwtHeader(IBslProcess process, AccessTokenSignAlgorithmEnum algorithm, string secretKey) + { + JwtHeader header; + + if (algorithm == AccessTokenSignAlgorithmEnum.None) + { + header = new JwtHeader(); + header["alg"] = "none"; + } + else + { + var signingCredentials = GetSigningCredentials(algorithm, secretKey); + header = new JwtHeader(signingCredentials); + } + + if (Headers != null) + { + foreach (var headerItem in Headers) + { + if (headerItem.Key.SystemType != BasicTypes.String) + throw RuntimeException.InvalidArgumentType(); + + var key = headerItem.Key.ToString(); + var value = headerItem.Value.AsString(process); + + if(!String.IsNullOrEmpty(key)) + header[key] = value; + } + } + + return header; + } + + private JwtPayload CreateJwtPayload(IBslProcess process) + { + var payload = new JwtPayload(); + + AddStandardClaimsToPayload(payload); + AddAudienceToPayload(process, payload); + AddCustomClaimsToPayload(process, payload); + + return payload; + } + + private void AddStandardClaimsToPayload(JwtPayload payload) + { + if (_issuer != null) + payload[JwtRegisteredClaimNames.Iss] = _issuer; + + if (CreationTime != 0) + { + payload[JwtRegisteredClaimNames.Iat] = CreationTime; + payload[JwtRegisteredClaimNames.Nbf] = CreationTime; + } + + if (CreationTime != 0 || LifeTime != 0) + { + int expires = CreationTime + LifeTime; + payload[JwtRegisteredClaimNames.Exp] = expires; + } + + if (_tokenId != null) + payload[JwtRegisteredClaimNames.Jti] = _tokenId; + + if(_userMatchingKey != null) + payload[JwtRegisteredClaimNames.Sub] = _userMatchingKey; + } + + private void AddAudienceToPayload(IBslProcess process, JwtPayload payload) + { + if (Recipients == null || Recipients.Count() == 0) + return; + + if (Recipients.Count() == 1) + { + payload[JwtRegisteredClaimNames.Aud] = Recipients[0].AsString(process); + } + else + { + var recipientsStrings = new List(); + foreach (var recipient in Recipients) + { + if (recipient.SystemType != BasicTypes.String) + throw RuntimeException.InvalidArgumentType(); + + recipientsStrings.Add(recipient.ToString()); + } + + payload[JwtRegisteredClaimNames.Aud] = recipientsStrings; + } + } + + private void AddCustomClaimsToPayload(IBslProcess process, JwtPayload payload) + { + if (Payload == null || Payload.Count() == 0) + return; + + foreach (var payloadItem in Payload) + { + if (payloadItem.Key.SystemType != BasicTypes.String) + throw RuntimeException.InvalidArgumentType(); + + var key = payloadItem.Key.ToString(); + var value = ConvertToClrObject(process, payloadItem.Value); + + if(!String.IsNullOrEmpty(key) && value != null) + payload[key] = value; + } + } + + private SigningCredentials GetSigningCredentials(AccessTokenSignAlgorithmEnum algorithm, string secretKey) + { + if (algorithm == AccessTokenSignAlgorithmEnum.None) + return null; + + if (string.IsNullOrEmpty(secretKey)) + throw new ArgumentException("Ключ подписи не может быть пустым для выбранного алгоритма"); + + var key = GetSigningKey(algorithm, secretKey); + return new SigningCredentials(key, GetSecurityAlgorithm(algorithm)); + } + + private SecurityKey GetSigningKey(AccessTokenSignAlgorithmEnum algorithm, string secretKey) + { + return algorithm switch + { + // Симметричные алгоритмы (HMAC) + AccessTokenSignAlgorithmEnum.HS256 => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + AccessTokenSignAlgorithmEnum.HS384 => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + AccessTokenSignAlgorithmEnum.HS512 => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + + // Асимметричные алгоритмы (RSA) + AccessTokenSignAlgorithmEnum.RS256 => CreateRsaSecurityKey(secretKey), + AccessTokenSignAlgorithmEnum.RS384 => CreateRsaSecurityKey(secretKey), + AccessTokenSignAlgorithmEnum.RS512 => CreateRsaSecurityKey(secretKey), + AccessTokenSignAlgorithmEnum.PS256 => CreateRsaSecurityKey(secretKey), + AccessTokenSignAlgorithmEnum.PS384 => CreateRsaSecurityKey(secretKey), + AccessTokenSignAlgorithmEnum.PS512 => CreateRsaSecurityKey(secretKey), + + // Эллиптические кривые (ECDSA) + AccessTokenSignAlgorithmEnum.ES256 => CreateEcdsaSecurityKey(secretKey), + AccessTokenSignAlgorithmEnum.ES384 => CreateEcdsaSecurityKey(secretKey), + AccessTokenSignAlgorithmEnum.ES512 => CreateEcdsaSecurityKey(secretKey), + + _ => throw new ArgumentException($"Неподдерживаемый алгоритм для ключа: {algorithm}") + }; + } + + private SecurityKey CreateRsaSecurityKey(string secretKey) + { + _rsa?.Dispose(); + _rsa = RSA.Create(); + try + { + _rsa.ImportFromPem(secretKey); + return new RsaSecurityKey(_rsa); + } + catch (Exception ex) + { + _rsa.Dispose(); + _rsa = null; + throw new ArgumentException($"Ошибка при создании RSA ключа: {ex.Message}", ex); + } + } + + private SecurityKey CreateEcdsaSecurityKey(string secretKey) + { + _ecdsa?.Dispose(); + _ecdsa = ECDsa.Create(); + try + { + _ecdsa.ImportFromPem(secretKey); + return new ECDsaSecurityKey(_ecdsa); + } + catch (Exception ex) + { + _ecdsa.Dispose(); + _ecdsa = null; + throw new ArgumentException($"Ошибка при создании ECDSA ключа: {ex.Message}", ex); + } + } + + private string GetSecurityAlgorithm(AccessTokenSignAlgorithmEnum algorithm) + { + return algorithm switch + { + // HMAC алгоритмы + AccessTokenSignAlgorithmEnum.HS256 => SecurityAlgorithms.HmacSha256, + AccessTokenSignAlgorithmEnum.HS384 => SecurityAlgorithms.HmacSha384, + AccessTokenSignAlgorithmEnum.HS512 => SecurityAlgorithms.HmacSha512, + + // RSA алгоритмы + AccessTokenSignAlgorithmEnum.RS256 => SecurityAlgorithms.RsaSha256, + AccessTokenSignAlgorithmEnum.RS384 => SecurityAlgorithms.RsaSha384, + AccessTokenSignAlgorithmEnum.RS512 => SecurityAlgorithms.RsaSha512, + + // RSA-PSS алгоритмы + AccessTokenSignAlgorithmEnum.PS256 => SecurityAlgorithms.RsaSsaPssSha256, + AccessTokenSignAlgorithmEnum.PS384 => SecurityAlgorithms.RsaSsaPssSha384, + AccessTokenSignAlgorithmEnum.PS512 => SecurityAlgorithms.RsaSsaPssSha512, + + // ECDSA алгоритмы + AccessTokenSignAlgorithmEnum.ES256 => SecurityAlgorithms.EcdsaSha256, + AccessTokenSignAlgorithmEnum.ES384 => SecurityAlgorithms.EcdsaSha384, + AccessTokenSignAlgorithmEnum.ES512 => SecurityAlgorithms.EcdsaSha512, + + _ => throw new ArgumentException($"Неподдерживаемый алгоритм: {algorithm}") + }; + } + + private object ConvertToClrObject(IBslProcess process, IValue value) + { + if (value == null) + return null; + + switch (value) + { + case ArrayImpl array: + case FixedArrayImpl fixedArray: + var list = new List(); + foreach (var item in (IEnumerable)value) + { + list.Add(ConvertToClrObject(process, item)); + } + return list; + case StructureImpl structure: + case FixedStructureImpl fixedStructure: + case MapImpl map: + case FixedMapImpl fixedMap: + var dict = new Dictionary(); + foreach (var item in (IEnumerable)value) + { + var key = item.Key.AsString(process); + dict[key] = ConvertToClrObject(process, item.Value); + } + return dict; + default: + var unwarpedValue = value.UnwrapToClrObject() ?? ""; + return unwarpedValue.GetType().IsValueType ? unwarpedValue : value.AsString(process); + } + } + + [ScriptConstructor(Name = "По умолчанию")] + public static AccessTokenContext Constructor() + { + return new AccessTokenContext(); + } + + [ScriptConstructor(Name = "По заголовкам и полезной нагрузке")] + public static AccessTokenContext Constructor(MapImpl headers, MapImpl payload) + { + return new AccessTokenContext(headers, payload); + } + + public override string ToString(IBslProcess process) + { + if (_isSigned) + return _token; + + CreateUnsignedToken(process); + + return _token; + } + + private AccessTokenContext() + { + Headers = new MapImpl(); + Payload = new MapImpl(); + Recipients = new ArrayImpl(); + } + + private AccessTokenContext(MapImpl headers, MapImpl payload) : this() + { + Headers = headers; + Payload = payload; + } + + public void Dispose() + { + if (_disposed) + return; + + _rsa?.Dispose(); + _ecdsa?.Dispose(); + _rsa = null; + _ecdsa = null; + + _disposed = true; + } + } +} diff --git a/src/OneScript.StandardLibrary/Security/Tokens/AccessTokenSignAlgorithmEnum.cs b/src/OneScript.StandardLibrary/Security/Tokens/AccessTokenSignAlgorithmEnum.cs new file mode 100644 index 000000000..6ca4424a7 --- /dev/null +++ b/src/OneScript.StandardLibrary/Security/Tokens/AccessTokenSignAlgorithmEnum.cs @@ -0,0 +1,57 @@ +/*---------------------------------------------------------- +This Source Code Form is subject to the terms of the +Mozilla Public License, v.2.0. If a copy of the MPL +was not distributed with this file, You can obtain one +at http://mozilla.org/MPL/2.0/. +----------------------------------------------------------*/ + +using OneScript.Contexts.Enums; + +namespace OneScript.StandardLibrary.Security.Tokens +{ + /// + /// Алгоритмы подписи токена доступа. + /// + [EnumerationType("АлгоритмПодписиТокенаДоступа", "AccessTokenSignAlgorithm")] + public enum AccessTokenSignAlgorithmEnum + { + [EnumValue("ES256")] + ES256, + + [EnumValue("ES384")] + ES384, + + [EnumValue("ES512")] + ES512, + + [EnumValue("HS256")] + HS256, + + [EnumValue("HS384")] + HS384, + + [EnumValue("HS512")] + HS512, + + [EnumValue("PS256")] + PS256, + + [EnumValue("PS384")] + PS384, + + [EnumValue("PS512")] + PS512, + + [EnumValue("RS256")] + RS256, + + [EnumValue("RS384")] + RS384, + + [EnumValue("RS512")] + RS512, + + [EnumValue("Нет", "None")] + None + } +} diff --git a/tests/http.os b/tests/http.os index d2f89a6d5..da6f3f5a0 100644 --- a/tests/http.os +++ b/tests/http.os @@ -37,6 +37,8 @@ ВсеТесты.Добавить("ТестДолженПроверитьЧтоМетодыБезТелаПриУстановленномТелеУспешноВыполняются"); + ВсеТесты.Добавить("ТестДолженПроверитьПередачуТокенаЧерезДобавитьТокенДоступа"); + Возврат ВсеТесты; КонецФункции @@ -376,6 +378,27 @@ КонецПроцедуры +Процедура ТестДолженПроверитьПередачуТокенаЧерезДобавитьТокенДоступа() Экспорт + + ТокенДоступа = Новый ТокенДоступа(); + ТокенДоступа.Эмитент = "ERP"; + ТокенДоступа.Получатели.Добавить("ДО"); + ТокенДоступа.ВремяСоздания = ТекущаяУниверсальнаяДата() - Дата(1970, 1, 1, 0, 0, 0); + ТокенДоступа.ВремяЖизни = 3600; + ТокенДоступа.Подписать(АлгоритмПодписиТокенаДоступа.HS256, ПрочитатьПриватныйКлючRSA()); + + Запрос = Новый HttpЗапрос("/get"); + Запрос.ДобавитьТокенДоступа(ТокенДоступа); + + Соединение = Новый HttpСоединение(мАдресРесурса); + Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос); + ТелоОтвета = Ответ.ПолучитьТелоКакСтроку(); + + юТест.ПроверитьРавенство(200, Ответ.КодСостояния); + юТест.ПроверитьВхождение(JsonВОбъект(ТелоОтвета)["headers"]["Authorization"], "Bearer " + ТокенДоступа); + +КонецПроцедуры + Функция JsonВОбъект(Json) ЧтениеJSON = Новый ЧтениеJSON; @@ -432,4 +455,10 @@ Сообщить("HTTP тесты: не удалось определить доступный хост, используется значение по умолчанию https://httpbin.org"); Возврат "https://httpbin.org"; +КонецФункции + +Функция ПрочитатьПриватныйКлючRSA() + ПутьКФайлу = ОбъединитьПути(ТекущийСценарий().Каталог, "./security/test_rsa_key.pem"); + ЧтениеТекста = Новый ЧтениеТекста(ПутьКФайлу); + Возврат ЧтениеТекста.Прочитать(); КонецФункции \ No newline at end of file diff --git a/tests/security/access-token.os b/tests/security/access-token.os new file mode 100644 index 000000000..ddd9bffb9 --- /dev/null +++ b/tests/security/access-token.os @@ -0,0 +1,269 @@ +Перем юТест; + +Функция ПолучитьСписокТестов(ЮнитТестирование) Экспорт + юТест = ЮнитТестирование; + + ВсеТесты = Новый Массив; + + ВсеТесты.Добавить("ТестДолжен_ПроверитьКонструкторПоУмолчанию"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьКонструкторСЗаголовкамиИПолезнойНагрузкой"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьГенерациюТокена"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьГенерациюТокенаСКлючом"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьГенерациюТокенаБезКлюча"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьОшибкуПриПодписиБезКлюча"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьГенерациюТокенаСОднимПолучателем"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьГенерациюТокенаСДвумяПолучателями"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьГенерациюТокенаCРазличнымиТипамиЗначений"); + + Возврат ВсеТесты; +КонецФункции + +Процедура ТестДолжен_ПроверитьКонструкторПоУмолчанию() Экспорт + + Токен = Новый ТокенДоступа(); + + юТест.ПроверитьРавенство(Токен.ВремяЖизни, 0); + юТест.ПроверитьРавенство(Токен.ВремяСоздания, 0); + юТест.ПроверитьРавенство(Токен.Идентификатор, ""); + юТест.ПроверитьРавенство(Токен.КлючСопоставленияПользователя, ""); + юТест.ПроверитьРавенство(Токен.Эмитент, ""); + юТест.ПроверитьРавенство(Токен.Заголовки.Количество(), 0); + юТест.ПроверитьРавенство(Токен.ПолезнаяНагрузка.Количество(), 0); + юТест.ПроверитьРавенство(Токен.Получатели.Количество(), 0); + + РазобранныйТокен = РазобратьJWT(Токен); + юТест.ПроверитьРавенство(РазобранныйТокен.Заголовки, "{""alg"":""none""}", "Заголовки"); + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, "{}", "Полезная нагрузка"); + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьКонструкторСЗаголовкамиИПолезнойНагрузкой() Экспорт + + Заголовки = Новый Соответствие; + Заголовки["typ"] = "JWT"; + Заголовки["any"] = "some"; + + ПолезнаяНагрузка = Новый Соответствие; + ПолезнаяНагрузка["sub"] = "user123"; + ПолезнаяНагрузка["name"] = "Test User"; + + Токен = Новый ТокенДоступа(Заголовки, ПолезнаяНагрузка); + РазобранныйТокен = РазобратьJWT(Токен); + + юТест.ПроверитьРавенство(Токен.Заголовки.Количество(), 2); + юТест.ПроверитьРавенство(Токен.ПолезнаяНагрузка.Количество(), 2); + юТест.ПроверитьРавенство(Токен.Заголовки["typ"], "JWT"); + юТест.ПроверитьРавенство(Токен.Заголовки["any"], "some"); + юТест.ПроверитьРавенство(Токен.ПолезнаяНагрузка["sub"], "user123"); + юТест.ПроверитьРавенство(Токен.ПолезнаяНагрузка["name"], "Test User"); + юТест.ПроверитьРавенство(РазобранныйТокен.Заголовки, "{""alg"":""none"",""typ"":""JWT"",""any"":""some""}", "Заголовки"); + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, "{""sub"":""user123"",""name"":""Test User""}", "Полезная нагрузка"); + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьГенерациюТокена() Экспорт + + Токен = Новый ТокенДоступа(); + Токен.ВремяЖизни = 3600; + Токен.ВремяСоздания = 1640995200; // 2022-01-01 00:00:00 UTC + Токен.Идентификатор = "unique-token-id"; + Токен.КлючСопоставленияПользователя = "john.doe@example.com"; + Токен.Эмитент = "https://auth.example.com"; + + Токен.Заголовки["typ"] = "JWT"; + + Токен.ПолезнаяНагрузка["role"] = "admin"; + Токен.ПолезнаяНагрузка["permissions"] = "read,write,delete"; + + Токен.Получатели.Добавить("api.example.com"); + Токен.Получатели.Добавить("mobile.app"); + + РазобранныйТокен = РазобратьJWT(Токен); + + юТест.ПроверитьРавенство(РазобранныйТокен.Заголовки, "{""alg"":""none"",""typ"":""JWT""}", "Заголовки"); + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, "{""iss"":""https://auth.example.com"",""iat"":1640995200,""nbf"":1640995200,""exp"":1640998800,""jti"":""unique-token-id"",""sub"":""john.doe@example.com"",""aud"":[""api.example.com"",""mobile.app""],""role"":""admin"",""permissions"":""read,write,delete""}", "Полезная нагрузка"); + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьГенерациюТокенаСКлючом() Экспорт + + ПриватныйКлючRSA = ПрочитатьТекстовыйФайл("./test_rsa_key.pem"); + ПриватныйКлючECDSA = ПрочитатьТекстовыйФайл("./test_ecdsa_key.pem"); + + ТестовыеСлучаи = Новый Соответствие(); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.ES256, ПриватныйКлючECDSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.ES384, ПриватныйКлючECDSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.ES512, ПриватныйКлючECDSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.HS256, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.HS384, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.HS512, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.PS256, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.PS384, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.PS512, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.RS256, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.RS384, ПриватныйКлючRSA); + ТестовыеСлучаи.Вставить(АлгоритмПодписиТокенаДоступа.RS512, ПриватныйКлючRSA); + + Токен = ТестовыйТокенДоступа(); + ОжидаемыеЧастиТокена = ОжидаемыеЧастиТестовогоТокена(); + + Для Каждого ТестовыйСлучай Из ТестовыеСлучаи Цикл + Алгоритм = ТестовыйСлучай.Ключ; + ПриватныйКлюч = ТестовыйСлучай.Значение; + + Токен.Подписать(Алгоритм, ПриватныйКлюч); + + РазобранныйТокен = РазобратьJWT(Токен); + + юТест.ПроверитьРавенство(РазобранныйТокен.Заголовки, СтрШаблон(ОжидаемыеЧастиТокена.Заголовки, Алгоритм), "Заголовки для " + Алгоритм); + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, ОжидаемыеЧастиТокена.ПолезнаяНагрузка, "Полезная нагрузка для " + Алгоритм); + юТест.ПроверитьЗаполненность(РазобранныйТокен.Сигнатура, "Сигнатура для " + Алгоритм); + КонецЦикла; + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьГенерациюТокенаБезКлюча() Экспорт + + Токен = ТестовыйТокенДоступа(); + Токен.Подписать(АлгоритмПодписиТокенаДоступа.Нет); + + РазобранныйТокен = РазобратьJWT(Токен); + ОжидаемыеЧастиТокена = ОжидаемыеЧастиТестовогоТокена(); + + юТест.ПроверитьРавенство(РазобранныйТокен.Заголовки, СтрШаблон(ОжидаемыеЧастиТокена.Заголовки, "none"), "Заголовки"); + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, ОжидаемыеЧастиТокена.ПолезнаяНагрузка, "Полезная нагрузка"); + юТест.ПроверитьНеЗаполненность(РазобранныйТокен.Сигнатура, "Сигнатура"); + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьГенерациюТокенаСОднимПолучателем() Экспорт + + Токен = Новый ТокенДоступа(); + Токен.Получатели.Добавить("mobile.app"); + + РазобранныйТокен = РазобратьJWT(Токен); + + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, "{""aud"":""mobile.app""}"); + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьГенерациюТокенаСДвумяПолучателями() Экспорт + + Токен = Новый ТокенДоступа(); + Токен.Получатели.Добавить("api.example.com"); + Токен.Получатели.Добавить("mobile.app"); + + РазобранныйТокен = РазобратьJWT(Токен); + + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, "{""aud"":[""api.example.com"",""mobile.app""]}"); + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьГенерациюТокенаCРазличнымиТипамиЗначений() Экспорт + + ОжидаемоеЗначение = "{""Число"":1000.10,""Булево"":true,""Дата"":""2001-01-01T17:20:20.0000000Z"",""Нуль"":"""",""Неопределено"":"""",""Массив"":[100,{""Ключ1"":""Значение1""},{""Ключ2"":""Значение2"",""Структура"":{""Ключ1"":""Значение1""}}],""Структура"":{""Ключ1"":""Значение1""},""Соответствие"":{""Ключ2"":""Значение2"",""Структура"":{""Ключ1"":""Значение1""}}}"; + + Структура = Новый Структура("Ключ1", "Значение1"); + + Соответствие = Новый Соответствие(); + Соответствие.Вставить("Ключ2", "Значение2"); + Соответствие.Вставить("Структура", Структура); + + Массив = Новый Массив(); + Массив.Добавить(100); + Массив.Добавить(Структура); + Массив.Добавить(Соответствие); + + Токен = Новый ТокенДоступа(); + Токен.ПолезнаяНагрузка.Вставить("Число", 1000.10); + Токен.ПолезнаяНагрузка.Вставить("Булево", Истина); + Токен.ПолезнаяНагрузка.Вставить("Дата", Дата(2001, 1, 1, 20, 20, 20)); + Токен.ПолезнаяНагрузка.Вставить("Нуль", null); + Токен.ПолезнаяНагрузка.Вставить("Неопределено", Неопределено); + Токен.ПолезнаяНагрузка.Вставить("Массив", Массив); + Токен.ПолезнаяНагрузка.Вставить("Структура", Структура); + Токен.ПолезнаяНагрузка.Вставить("Соответствие", Соответствие); + + РазобранныйТокен = РазобратьJWT(Токен); + + юТест.ПроверитьРавенство(РазобранныйТокен.ПолезнаяНагрузка, ОжидаемоеЗначение); + +КонецПроцедуры + +Процедура ТестДолжен_ПроверитьОшибкуПриПодписиБезКлюча() Экспорт + юТест.ПроверитьКодСОшибкой( + "Токен = Новый ТокенДоступа(); + |Токен.Подписать(АлгоритмПодписиТокенаДоступа.ES256)", + "Ключ подписи не может быть пустым для выбранного алгоритма", + "Должна была возникнуть ошибка при подписи без ключа" + ); +КонецПроцедуры + +Функция ТестовыйТокенДоступа() + + Токен = Новый ТокенДоступа(); + Токен.ВремяЖизни = 3600; + Токен.ВремяСоздания = 1640995200; // 2022-01-01 00:00:00 UTC + Токен.Идентификатор = "unique-token-id"; + Токен.КлючСопоставленияПользователя = "john.doe@example.com"; + Токен.Эмитент = "https://auth.example.com"; + + Токен.Заголовки["typ"] = "JWT"; + + Токен.ПолезнаяНагрузка["role"] = "admin"; + Токен.ПолезнаяНагрузка["permissions"] = "read,write,delete"; + + Токен.Получатели.Добавить("api.example.com"); + Токен.Получатели.Добавить("mobile.app"); + + Возврат Токен; + +КонецФункции + +Функция ОжидаемыеЧастиТестовогоТокена() + + ОжидаемыеЗаголовки = "{""alg"":""%1"",""typ"":""JWT""}"; + ОжидаемаяПолезнаяНагрузка = "{""iss"":""https://auth.example.com"",""iat"":1640995200,""nbf"":1640995200,""exp"":1640998800,""jti"":""unique-token-id"",""sub"":""john.doe@example.com"",""aud"":[""api.example.com"",""mobile.app""],""role"":""admin"",""permissions"":""read,write,delete""}"; + + Возврат Новый Структура("Заголовки, ПолезнаяНагрузка", ОжидаемыеЗаголовки, ОжидаемаяПолезнаяНагрузка); + +КонецФункции + +Функция РазобратьJWT(Токен) + + Части = СтрРазделить(Токен, "."); + юТест.ПроверитьРавенство(Части.Количество(), 3, "Количество частей"); + + Результат = Новый Структура("Заголовки, ПолезнаяНагрузка, Сигнатура"); + Результат.Заголовки = ПолучитьСтрокуИзДвоичныхДанных(Base64Значение(НормализоватьBase64(Части[0]))); + Результат.ПолезнаяНагрузка = ПолучитьСтрокуИзДвоичныхДанных(Base64Значение(НормализоватьBase64(Части[1]))); + Результат.Сигнатура = Части[2]; + + Возврат Результат; + +КонецФункции + +Функция НормализоватьBase64(Знач Значение) + + Значение = СтрЗаменить(Значение, "-", "+"); + Значение = СтрЗаменить(Значение, "_", "/"); + + Длина = СтрДлина(Значение); + Остаток = Длина % 4; + Паддинг = ""; + + Если Остаток = 2 Тогда + Паддинг = "=="; + ИначеЕсли Остаток = 3 Тогда + Паддинг = "="; + КонецЕсли; + + Возврат Значение + Паддинг; + +КонецФункции + +Функция ПрочитатьТекстовыйФайл(ОтносительныйПуть) + ПутьКФайлу = ОбъединитьПути(ТекущийСценарий().Каталог, ОтносительныйПуть); + ЧтениеТекста = Новый ЧтениеТекста(ПутьКФайлу); + Возврат ЧтениеТекста.Прочитать(); +КонецФункции \ No newline at end of file diff --git a/tests/security/test_ecdsa_key.pem b/tests/security/test_ecdsa_key.pem new file mode 100644 index 000000000..b406ad462 --- /dev/null +++ b/tests/security/test_ecdsa_key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZYdbovRCqelX1FPY +EE8VAbDJ/cT99z8r8ZisRgftCt6hRANCAASFDFrXFeOV0rYTDuIDJHKUdwqc7Z3z +gCxaeTCRJ+KBCw7AsGearOWLn67Y424YAx73wr3T9BIrQ3yGgPXZmzUR +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/tests/security/test_rsa_key.pem b/tests/security/test_rsa_key.pem new file mode 100644 index 000000000..fda2e75f1 --- /dev/null +++ b/tests/security/test_rsa_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCT4Ws/kRkM92gs +rljP85+ZaBoE/aJa5yBoWOJI5notYGDwAU66WofBunLIlUNn/YuETvs12mCCClBX +ODurlwi+wLs5HyXf1gqNzVP4JN5QFUpqd2RQrUZLqBoONjZAok9P3oAw4JuJDnBm +JODFLQjbCUOvorxR33iCPDfflvSa8kIxkQ3hA6zB7vu0j5s8HGCXczRI0pSblAH3 +GEPSPp8D4IBmVKi90lQjCVVlCMY/hVkecqjc2hhfsh2cF/R2Q3NptNSLtw+lEMOU +uUQtgVnULhTYDRaRJ9HripxQvQ2Se/BnozFVPfKokouh6f0ti9b4vWSDN3EjuMEu +wf+ttUPhAgMBAAECggEAZRT3lzraMbfHmIsQIAz6MgUri+/HE3Xa/BiNjKEmMHAp +ssRiCRWqqCyHGz6UFbgErsrCAhykTR85rXS9rNuPWbwp/bCh1e/bxPPuCrdA0uLK +vB2iD1dxrnNYZBCAYwvPRNvlKgPNrRCWmetpTL4syHP7tUl6ikhDelopIwdvaZ1W +cQeDq6QqkzOcJFdXHIHxZ2qbnjJoinyfPzmA98Ijdi8ynjIqKxj0f+0r567nxe/q +Z4FwbPi7bRSmfMsTCM0SagwMuQoqvB6iktrZEYYUJmcN7V/gjN1viqVkjudk/CC5 +RgMBp7yhrt3SP2oErAaAlTWldRVJ8qquJSpXkBhTxQKBgQDM8S9WIZgl2HwWsK9c +C2JTC0peB8W1B3rM5IYvrCN0uB/P/KZ/Igwkla3wsOhy2SDPBYqzP/4OPzz4atOL +PFDdBGrIQgseSajPp7nuD91IjI9KFXUT1EUBbKhWmATZwQqCOlHtSoi4zl+dJWgr +dVzQxykh3ME85mIU3cxWNxk+swKBgQC4uPSsE9aJ5JK5LCfpcsVYeeatko72rA2t +N/aPActklWwkR/tvJ43jG5RwKwbY4N2V6a5Tz5xQIe550vvBd56ku6tmEJDLL+jc +QLh5lajV3JJq+tazdyZkztaSwOucuZve9R+eYAPM3uqYO8gAJSrJQQwlAoJkrWk6 +zoISqvk9GwKBgFJQsVWkCqtwx26JqvWKcQSv7T/VWVi711wCkc8GEfuolMaCURGR +SFVNdV7Of57ZjS75p9sVYeKxDbktlyg+orATPRyQQkz5Av/c+3YeEyA3rLnx4mOD +h+/ph7e4OYKI4cKq0AtCO6YW5hqFUtDZw9zrkZ7TPx5J3q1I5PDgEpi3AoGAYokO +wwfmBjJubeZ299loGdUUzvwB1OVcek0C8a/kXigywnu/TMDNuBrLKLZa9a+lo8U+ +X6i8WiZvfn6kGsSDVJ5jRJOLmUdaMLs5bGn/4OzDEdvyUVM6oMWQwkG0rSp5Ugpx +rlrLTCqQ+1nQSvuIgkh3gPqAsdGq81qbGfgn8YcCgYEAjodWERs0eCSMNGIshss4 +/GN50lbOiI0+ke99160kT/hpep1WCEpXBfCeXLBamcG36Uk8c34DrxJ2Uj8LC895 +/tB4HFgbQ52VxQdQCNGcs9hOxPuLlXlSdKNuplvfAkg2JhMrES7KtAxOBAcK77kx +JzEREQEFN8CmwDvSpw8Mz9k= +-----END PRIVATE KEY----- \ No newline at end of file