mirror of
https://github.com/bitwarden/mobile
synced 2026-01-04 01:23:15 +00:00
[AC-1070] Enforce master password policy on login/unlock (#2410)
* [AC-1070] Add EnforceOnLogin property to MasterPasswordPolicyOptions
* [AC-1070] Add MasterPasswordPolicy property to Identity responses
* [AC-1070] Add policy service dependency to auth service
* [AC-1070] Introduce logic to evaluate master password after successful login
* [AC-1070] Add optional ForcePasswordResetReason to profile / state service
* [AC-1070] Save ForcePasswordResetReason to state when a weak master password is found during login
- Additionally, save the AdminForcePasswordReset reason if the identity result indicates an admin password reset is in effect.
* [AC-1070] Check for a saved ForcePasswordReset reason on TabsPage load force show the update password page
* [AC-1070] Make InitAsync virtual
Allow the UpdateTempPasswordPage to override the InitAsync method to check for a reset password reason in the state service
* [AC-1070] Modify UpdateTempPassword page appearance
- Load the force password reset reason from the state service
- Make warning text dynamic based on force password reason
- Conditionally show the Current master password field if updating a weak master password
* [AC-1070] Add update password method to Api service
* [AC-1070] Introduce logic to update both temp and regular passwords
- Check the Reason to use the appropriate request/endpoint when submitting.
- Verify the users current password locally using the user verification service.
* [AC-1070] Introduce VerifyMasterPasswordResponse
* [AC-1070] Add logic to evaluate master password on unlock
* [AC-1070] Add support 2FA login flow
Keep track of the reset password reason after a password login requires 2FA. During 2FA submission, check if there is a saved reason, and if so, force the user to update their password.
* [AC-1070] Formatting
* [AC-1070] Remove string key from service resolution
* [AC-1070] Change master password options to method variable to avoid class field
Add null check for password strength result and log an error as this is an unexpected flow
* [AC-1070] Remove usage of i18nService
* [AC-1070] Use AsyncCommand for SubmitCommand
* [AC-1070] Remove type from ShowToast call
* [AC-1070] Simplify UpdatePassword methods to accept string for the new encryption key
* [AC-1070] Use full text for key for the CurrentMasterPassword resource
* [AC-1070] Convert Reason to a private class field
* [AC-1070] Formatting changes
* [AC-1070] Simplify if statements in master password options policy service method
* [AC-1070] Use the saved force password reset reason after 2FA login
* [AC-1070] Use constant for ForceUpdatePassword message command
* [AC-1070] Move shared RequirePasswordChangeOnLogin method into PolicyService
* Revert "[AC-1070] Move shared RequirePasswordChangeOnLogin method into PolicyService"
This reverts commit e4feac130f.
* [AC-1070] Add check for null password strength response
* [AC-1070] Fix broken show password icon
* [AC-1070] Add show password icon for current master password
This commit is contained in:
@@ -27,7 +27,7 @@ namespace Bit.Core.Abstractions
|
||||
Task<ProfileResponse> GetProfileAsync();
|
||||
Task<SyncResponse> GetSyncAsync();
|
||||
Task PostAccountKeysAsync(KeysRequest request);
|
||||
Task PostAccountVerifyPasswordAsync(PasswordVerificationRequest request);
|
||||
Task<VerifyMasterPasswordResponse> PostAccountVerifyPasswordAsync(PasswordVerificationRequest request);
|
||||
Task PostAccountRequestOTP();
|
||||
Task PostAccountVerifyOTPAsync(VerifyOTPRequest request);
|
||||
Task<CipherResponse> PostCipherAsync(CipherRequest request);
|
||||
@@ -62,6 +62,7 @@ namespace Bit.Core.Abstractions
|
||||
Task PutDeviceTokenAsync(string identifier, DeviceTokenRequest request);
|
||||
Task PostEventsCollectAsync(IEnumerable<EventRequest> request);
|
||||
Task PutUpdateTempPasswordAsync(UpdateTempPasswordRequest request);
|
||||
Task PostPasswordAsync(PasswordRequest request);
|
||||
Task DeleteAccountAsync(DeleteAccountRequest request);
|
||||
Task<OrganizationKeysResponse> GetOrganizationKeysAsync(string id);
|
||||
Task<OrganizationAutoEnrollStatusResponse> GetOrganizationAutoEnrollStatusAsync(string identifier);
|
||||
|
||||
@@ -134,6 +134,8 @@ namespace Bit.Core.Abstractions
|
||||
Task SetPushRegisteredTokenAsync(string value);
|
||||
Task<bool> GetUsesKeyConnectorAsync(string userId = null);
|
||||
Task SetUsesKeyConnectorAsync(bool? value, string userId = null);
|
||||
Task<ForcePasswordResetReason?> GetForcePasswordResetReasonAsync(string userId = null);
|
||||
Task SetForcePasswordResetReasonAsync(ForcePasswordResetReason? value, string userId = null);
|
||||
Task<Dictionary<string, OrganizationData>> GetOrganizationsAsync(string userId = null);
|
||||
Task SetOrganizationsAsync(Dictionary<string, OrganizationData> organizations, string userId = null);
|
||||
Task<PasswordGenerationOptions> GetPasswordGenerationOptionsAsync(string userId = null);
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
public const string OtpAuthScheme = "otpauth";
|
||||
public const string AppLocaleKey = "appLocale";
|
||||
public const string ClearSensitiveFields = "clearSensitiveFields";
|
||||
public const string ForceUpdatePassword = "forceUpdatePassword";
|
||||
public const int SelectFileRequestCode = 42;
|
||||
public const int SelectFilePermissionRequestCode = 43;
|
||||
public const int SaveFileRequestCode = 44;
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace Bit.Core.Models.Domain
|
||||
EmailVerified = copy.EmailVerified;
|
||||
HasPremiumPersonally = copy.HasPremiumPersonally;
|
||||
AvatarColor = copy.AvatarColor;
|
||||
ForcePasswordResetReason = copy.ForcePasswordResetReason;
|
||||
}
|
||||
|
||||
public string UserId;
|
||||
@@ -66,6 +67,7 @@ namespace Bit.Core.Models.Domain
|
||||
public int? KdfParallelism;
|
||||
public bool? EmailVerified;
|
||||
public bool? HasPremiumPersonally;
|
||||
public ForcePasswordResetReason? ForcePasswordResetReason;
|
||||
}
|
||||
|
||||
public class AccountTokens
|
||||
|
||||
16
src/Core/Models/Domain/ForcePasswordResetReason.cs
Normal file
16
src/Core/Models/Domain/ForcePasswordResetReason.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Bit.Core.Models.Domain
|
||||
{
|
||||
public enum ForcePasswordResetReason
|
||||
{
|
||||
/// <summary>
|
||||
/// Occurs when an organization admin forces a user to reset their password.
|
||||
/// </summary>
|
||||
AdminForcePasswordReset,
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a user logs in with a master password that does not meet an organization's master password
|
||||
/// policy that is enforced on login.
|
||||
/// </summary>
|
||||
WeakMasterPasswordOnLogin
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
public bool RequireLower { get; set; }
|
||||
public bool RequireNumbers { get; set; }
|
||||
public bool RequireSpecial { get; set; }
|
||||
public bool EnforceOnLogin { get; set; }
|
||||
|
||||
public bool InEffect()
|
||||
{
|
||||
|
||||
10
src/Core/Models/Request/PasswordRequest.cs
Normal file
10
src/Core/Models/Request/PasswordRequest.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Core.Models.Request
|
||||
{
|
||||
public class PasswordRequest
|
||||
{
|
||||
public string MasterPasswordHash { get; set; }
|
||||
public string NewMasterPasswordHash { get; set; }
|
||||
public string MasterPasswordHint { get; set; }
|
||||
public string Key { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Bit.Core.Enums;
|
||||
using System.Collections.Generic;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Core.Models.Response
|
||||
@@ -24,6 +26,7 @@ namespace Bit.Core.Models.Response
|
||||
public int? KdfParallelism { get; set; }
|
||||
public bool ForcePasswordReset { get; set; }
|
||||
public string KeyConnectorUrl { get; set; }
|
||||
public MasterPasswordPolicyOptions MasterPasswordPolicy { get; set; }
|
||||
[JsonIgnore]
|
||||
public KdfConfig KdfConfig => new KdfConfig(Kdf, KdfIterations, KdfMemory, KdfParallelism);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Core.Models.Response
|
||||
@@ -8,6 +9,7 @@ namespace Bit.Core.Models.Response
|
||||
{
|
||||
public List<TwoFactorProviderType> TwoFactorProviders { get; set; }
|
||||
public Dictionary<TwoFactorProviderType, Dictionary<string, object>> TwoFactorProviders2 { get; set; }
|
||||
public MasterPasswordPolicyOptions MasterPasswordPolicy { get; set; }
|
||||
[JsonProperty("CaptchaBypassToken")]
|
||||
public string CaptchaToken { get; set; }
|
||||
}
|
||||
|
||||
9
src/Core/Models/Response/VerifyMasterPasswordResponse.cs
Normal file
9
src/Core/Models/Response/VerifyMasterPasswordResponse.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Bit.Core.Models.Domain;
|
||||
|
||||
namespace Bit.Core.Models.Response
|
||||
{
|
||||
public class VerifyMasterPasswordResponse
|
||||
{
|
||||
public MasterPasswordPolicyOptions MasterPasswordPolicy { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -176,10 +176,10 @@ namespace Bit.Core.Services
|
||||
return SendAsync<KeysRequest, object>(HttpMethod.Post, "/accounts/keys", request, true, false);
|
||||
}
|
||||
|
||||
public Task PostAccountVerifyPasswordAsync(PasswordVerificationRequest request)
|
||||
public Task<VerifyMasterPasswordResponse> PostAccountVerifyPasswordAsync(PasswordVerificationRequest request)
|
||||
{
|
||||
return SendAsync<PasswordVerificationRequest, object>(HttpMethod.Post, "/accounts/verify-password", request,
|
||||
true, false);
|
||||
return SendAsync<PasswordVerificationRequest, VerifyMasterPasswordResponse>(HttpMethod.Post, "/accounts/verify-password", request,
|
||||
true, true);
|
||||
}
|
||||
|
||||
public Task PostAccountRequestOTP()
|
||||
@@ -199,6 +199,11 @@ namespace Bit.Core.Services
|
||||
request, true, false);
|
||||
}
|
||||
|
||||
public Task PostPasswordAsync(PasswordRequest request)
|
||||
{
|
||||
return SendAsync<PasswordRequest, object>(HttpMethod.Post, "/accounts/password", request, true, false);
|
||||
}
|
||||
|
||||
public Task DeleteAccountAsync(DeleteAccountRequest request)
|
||||
{
|
||||
return SendAsync<DeleteAccountRequest, object>(HttpMethod.Delete, "/accounts", request, true, false);
|
||||
|
||||
@@ -26,11 +26,16 @@ namespace Bit.Core.Services
|
||||
private readonly IMessagingService _messagingService;
|
||||
private readonly IKeyConnectorService _keyConnectorService;
|
||||
private readonly IPasswordGenerationService _passwordGenerationService;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly bool _setCryptoKeys;
|
||||
|
||||
private readonly LazyResolve<IWatchDeviceService> _watchDeviceService = new LazyResolve<IWatchDeviceService>();
|
||||
private SymmetricCryptoKey _key;
|
||||
|
||||
private string _authedUserId;
|
||||
private MasterPasswordPolicyOptions _masterPasswordPolicy;
|
||||
private ForcePasswordResetReason? _2faForcePasswordResetReason;
|
||||
|
||||
public AuthService(
|
||||
ICryptoService cryptoService,
|
||||
ICryptoFunctionService cryptoFunctionService,
|
||||
@@ -44,6 +49,7 @@ namespace Bit.Core.Services
|
||||
IVaultTimeoutService vaultTimeoutService,
|
||||
IKeyConnectorService keyConnectorService,
|
||||
IPasswordGenerationService passwordGenerationService,
|
||||
IPolicyService policyService,
|
||||
bool setCryptoKeys = true)
|
||||
{
|
||||
_cryptoService = cryptoService;
|
||||
@@ -57,6 +63,7 @@ namespace Bit.Core.Services
|
||||
_messagingService = messagingService;
|
||||
_keyConnectorService = keyConnectorService;
|
||||
_passwordGenerationService = passwordGenerationService;
|
||||
_policyService = policyService;
|
||||
_setCryptoKeys = setCryptoKeys;
|
||||
|
||||
TwoFactorProviders = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
@@ -136,11 +143,56 @@ namespace Bit.Core.Services
|
||||
public async Task<AuthResult> LogInAsync(string email, string masterPassword, string captchaToken)
|
||||
{
|
||||
SelectedTwoFactorProviderType = null;
|
||||
_2faForcePasswordResetReason = null;
|
||||
var key = await MakePreloginKeyAsync(masterPassword, email);
|
||||
var hashedPassword = await _cryptoService.HashPasswordAsync(masterPassword, key);
|
||||
var localHashedPassword = await _cryptoService.HashPasswordAsync(masterPassword, key, HashPurpose.LocalAuthorization);
|
||||
return await LogInHelperAsync(email, hashedPassword, localHashedPassword, null, null, null, key, null, null,
|
||||
null, captchaToken);
|
||||
var result = await LogInHelperAsync(email, hashedPassword, localHashedPassword, null, null, null, key, null, null, null, captchaToken);
|
||||
|
||||
if (await RequirePasswordChangeAsync(email, masterPassword))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_authedUserId))
|
||||
{
|
||||
// Authentication was successful, save the WeakMasterPasswordOnLogin flag for the user
|
||||
result.ForcePasswordReset = true;
|
||||
await _stateService.SetForcePasswordResetReasonAsync(
|
||||
ForcePasswordResetReason.WeakMasterPasswordOnLogin, _authedUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Authentication not fully successful (likely 2FA), store flag for LogInTwoFactorAsync()
|
||||
_2faForcePasswordResetReason = ForcePasswordResetReason.WeakMasterPasswordOnLogin;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the supplied master password against the master password policy provided by the Identity response.
|
||||
/// </summary>
|
||||
/// <param name="email"></param>
|
||||
/// <param name="masterPassword"></param>
|
||||
/// <returns>True if the master password does NOT meet any policy requirements, false otherwise (or if no policy present)</returns>
|
||||
private async Task<bool> RequirePasswordChangeAsync(string email, string masterPassword)
|
||||
{
|
||||
// No policy with EnforceOnLogin enabled, we're done.
|
||||
if (!(_masterPasswordPolicy is { EnforceOnLogin: true }))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var strength = _passwordGenerationService.PasswordStrength(
|
||||
masterPassword,
|
||||
_passwordGenerationService.GetPasswordStrengthUserInput(email)
|
||||
)?.Score;
|
||||
|
||||
if (!strength.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !await _policyService.EvaluateMasterPassword(strength.Value, masterPassword, _masterPasswordPolicy);
|
||||
}
|
||||
|
||||
public async Task<AuthResult> LogInPasswordlessAsync(string email, string accessCode, string authRequestId, byte[] decryptionKey, string userKeyCiphered, string localHashedPasswordCiphered)
|
||||
@@ -157,15 +209,26 @@ namespace Bit.Core.Services
|
||||
return await LogInHelperAsync(null, null, null, code, codeVerifier, redirectUrl, null, orgId: orgId);
|
||||
}
|
||||
|
||||
public Task<AuthResult> LogInTwoFactorAsync(TwoFactorProviderType twoFactorProvider, string twoFactorToken,
|
||||
public async Task<AuthResult> LogInTwoFactorAsync(TwoFactorProviderType twoFactorProvider, string twoFactorToken,
|
||||
string captchaToken, bool? remember = null)
|
||||
{
|
||||
if (captchaToken != null)
|
||||
{
|
||||
CaptchaToken = captchaToken;
|
||||
}
|
||||
return LogInHelperAsync(Email, MasterPasswordHash, LocalMasterPasswordHash, Code, CodeVerifier, SsoRedirectUrl, _key,
|
||||
var result = await LogInHelperAsync(Email, MasterPasswordHash, LocalMasterPasswordHash, Code, CodeVerifier, SsoRedirectUrl, _key,
|
||||
twoFactorProvider, twoFactorToken, remember, CaptchaToken, authRequestId: AuthRequestId);
|
||||
|
||||
// If we successfully authenticated and we have a saved _2faForcePasswordResetReason reason from LogInAsync()
|
||||
if (!string.IsNullOrEmpty(_authedUserId) && _2faForcePasswordResetReason.HasValue)
|
||||
{
|
||||
// Save the forcePasswordReset reason with the state service to force a password reset for the user
|
||||
result.ForcePasswordReset = true;
|
||||
await _stateService.SetForcePasswordResetReasonAsync(
|
||||
_2faForcePasswordResetReason, _authedUserId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<AuthResult> LogInCompleteAsync(string email, string masterPassword,
|
||||
@@ -367,6 +430,7 @@ namespace Bit.Core.Services
|
||||
TwoFactorProvidersData = response.TwoFactorResponse.TwoFactorProviders2;
|
||||
result.TwoFactorProviders = response.TwoFactorResponse.TwoFactorProviders2;
|
||||
CaptchaToken = response.TwoFactorResponse.CaptchaToken;
|
||||
_masterPasswordPolicy = response.TwoFactorResponse.MasterPasswordPolicy;
|
||||
await _tokenService.ClearTwoFactorTokenAsync(email);
|
||||
return result;
|
||||
}
|
||||
@@ -374,6 +438,7 @@ namespace Bit.Core.Services
|
||||
var tokenResponse = response.TokenResponse;
|
||||
result.ResetMasterPassword = tokenResponse.ResetMasterPassword;
|
||||
result.ForcePasswordReset = tokenResponse.ForcePasswordReset;
|
||||
_masterPasswordPolicy = tokenResponse.MasterPasswordPolicy;
|
||||
if (tokenResponse.TwoFactorToken != null)
|
||||
{
|
||||
await _tokenService.SetTwoFactorTokenAsync(tokenResponse.TwoFactorToken, email);
|
||||
@@ -391,6 +456,9 @@ namespace Bit.Core.Services
|
||||
KdfMemory = tokenResponse.KdfMemory,
|
||||
KdfParallelism = tokenResponse.KdfParallelism,
|
||||
HasPremiumPersonally = _tokenService.GetPremium(),
|
||||
ForcePasswordResetReason = result.ForcePasswordReset
|
||||
? ForcePasswordResetReason.AdminForcePasswordReset
|
||||
: (ForcePasswordResetReason?)null,
|
||||
},
|
||||
new Account.AccountTokens()
|
||||
{
|
||||
@@ -473,6 +541,7 @@ namespace Bit.Core.Services
|
||||
|
||||
}
|
||||
|
||||
_authedUserId = _tokenService.GetUserId();
|
||||
await _stateService.SetBiometricLockedAsync(false);
|
||||
_messagingService.Send("loggedIn");
|
||||
return result;
|
||||
@@ -490,6 +559,8 @@ namespace Bit.Core.Services
|
||||
SsoRedirectUrl = null;
|
||||
TwoFactorProvidersData = null;
|
||||
SelectedTwoFactorProviderType = null;
|
||||
_masterPasswordPolicy = null;
|
||||
_authedUserId = null;
|
||||
}
|
||||
|
||||
public async Task<List<PasswordlessLoginResponse>> GetPasswordlessLoginRequestsAsync()
|
||||
|
||||
@@ -148,28 +148,34 @@ namespace Bit.Core.Services
|
||||
}
|
||||
|
||||
var requireUpper = GetPolicyBool(currentPolicy, "requireUpper");
|
||||
if (requireUpper != null && (bool)requireUpper)
|
||||
if (requireUpper == true)
|
||||
{
|
||||
enforcedOptions.RequireUpper = true;
|
||||
}
|
||||
|
||||
var requireLower = GetPolicyBool(currentPolicy, "requireLower");
|
||||
if (requireLower != null && (bool)requireLower)
|
||||
if (requireLower == true)
|
||||
{
|
||||
enforcedOptions.RequireLower = true;
|
||||
}
|
||||
|
||||
var requireNumbers = GetPolicyBool(currentPolicy, "requireNumbers");
|
||||
if (requireNumbers != null && (bool)requireNumbers)
|
||||
if (requireNumbers == true)
|
||||
{
|
||||
enforcedOptions.RequireNumbers = true;
|
||||
}
|
||||
|
||||
var requireSpecial = GetPolicyBool(currentPolicy, "requireSpecial");
|
||||
if (requireSpecial != null && (bool)requireSpecial)
|
||||
if (requireSpecial == true)
|
||||
{
|
||||
enforcedOptions.RequireSpecial = true;
|
||||
}
|
||||
|
||||
var enforceOnLogin = GetPolicyBool(currentPolicy, "enforceOnLogin");
|
||||
if (enforceOnLogin == true)
|
||||
{
|
||||
enforcedOptions.EnforceOnLogin = true;
|
||||
}
|
||||
}
|
||||
|
||||
return enforcedOptions;
|
||||
|
||||
@@ -1039,6 +1039,22 @@ namespace Bit.Core.Services
|
||||
await SetValueAsync(Constants.UsesKeyConnectorKey(reconciledOptions.UserId), value, reconciledOptions);
|
||||
}
|
||||
|
||||
public async Task<ForcePasswordResetReason?> GetForcePasswordResetReasonAsync(string userId = null)
|
||||
{
|
||||
var reconcileOptions = ReconcileOptions(new StorageOptions { UserId = userId },
|
||||
await GetDefaultStorageOptionsAsync());
|
||||
return (await GetAccountAsync(reconcileOptions))?.Profile?.ForcePasswordResetReason;
|
||||
}
|
||||
|
||||
public async Task SetForcePasswordResetReasonAsync(ForcePasswordResetReason? value, string userId = null)
|
||||
{
|
||||
var reconcileOptions = ReconcileOptions(new StorageOptions { UserId = userId },
|
||||
await GetDefaultStorageOptionsAsync());
|
||||
var account = await GetAccountAsync(reconcileOptions);
|
||||
account.Profile.ForcePasswordResetReason = value;
|
||||
await SaveAccountAsync(account, reconcileOptions);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, OrganizationData>> GetOrganizationsAsync(string userId = null)
|
||||
{
|
||||
var reconciledOptions = ReconcileOptions(new StorageOptions { UserId = userId },
|
||||
|
||||
@@ -80,7 +80,7 @@ namespace Bit.Core.Utilities
|
||||
var totpService = new TotpService(cryptoFunctionService);
|
||||
var authService = new AuthService(cryptoService, cryptoFunctionService, apiService, stateService,
|
||||
tokenService, appIdService, i18nService, platformUtilsService, messagingService, vaultTimeoutService,
|
||||
keyConnectorService, passwordGenerationService);
|
||||
keyConnectorService, passwordGenerationService, policyService);
|
||||
var exportService = new ExportService(folderService, cipherService, cryptoService);
|
||||
var auditService = new AuditService(cryptoFunctionService, apiService);
|
||||
var environmentService = new EnvironmentService(apiService, stateService, conditionedRunner);
|
||||
|
||||
Reference in New Issue
Block a user