From fcaaebcc668238aba7808b81acde98349378bfa3 Mon Sep 17 00:00:00 2001 From: smizrahi Date: Wed, 27 Nov 2024 16:39:53 +0100 Subject: [PATCH] feat(core): Check password strength on login (SQL Source). Closes #6025. --- SoObjects/SOGo/LDAPSource.m | 17 +-- SoObjects/SOGo/SOGoDAVAuthenticator.m | 3 +- SoObjects/SOGo/SOGoUserManager.m | 12 +- SoObjects/SOGo/SOGoWebAuthenticator.h | 28 ++-- SoObjects/SOGo/SOGoWebAuthenticator.m | 10 +- SoObjects/SOGo/SQLSource.m | 102 ++++++++++---- UI/MainUI/English.lproj/Localizable.strings | 10 +- UI/MainUI/French.lproj/Localizable.strings | 10 +- UI/MainUI/SOGoRootPage.m | 58 ++++++-- UI/Templates/MainUI/SOGoRootPage.wox | 16 ++- .../js/Common/Authentication.service.js | 8 ++ UI/WebServerResources/js/Main/Main.app.js | 125 ++++++++++-------- UI/WebServerResources/scss/views/LoginUI.scss | 2 + 13 files changed, 269 insertions(+), 132 deletions(-) diff --git a/SoObjects/SOGo/LDAPSource.m b/SoObjects/SOGo/LDAPSource.m index ef8349083..1795dfbf5 100644 --- a/SoObjects/SOGo/LDAPSource.m +++ b/SoObjects/SOGo/LDAPSource.m @@ -2306,7 +2306,7 @@ _makeLDAPChanges (NGLdapConnection *ldapConnection, - (NSArray *) membersForGroupWithUID: (NSString *) uid { - NSMutableArray *dns, *uids, *userLogins; + NSMutableArray *dns, *uids; NSString *dn, *login; SOGoUserManager *um; NSDictionary *d, *contactInfos; @@ -2327,7 +2327,6 @@ _makeLDAPChanges (NGLdapConnection *ldapConnection, members = [NSMutableArray new]; uids = [NSMutableArray array]; dns = [NSMutableArray array]; - userLogins = [NSMutableArray array]; // We check if it's a static group // Fetch "members" - we get DNs @@ -2359,13 +2358,6 @@ _makeLDAPChanges (NGLdapConnection *ldapConnection, pool = [NSAutoreleasePool new]; dn = [dns objectAtIndex: i]; login = [um getLoginForDN: [dn lowercaseString]]; - if([userLogins containsObject: login]) - { - [pool release]; - continue; //user alrady fetch - } - if(login != nil) - [userLogins addObject: login]; user = [SOGoUser userWithLogin: login roles: nil]; if (user) { @@ -2393,13 +2385,6 @@ _makeLDAPChanges (NGLdapConnection *ldapConnection, { pool = [NSAutoreleasePool new]; login = [uids objectAtIndex: i]; - if([userLogins containsObject: login]) - { - [pool release]; - continue; //user alrady fetch - } - if(login != nil) - [userLogins addObject: login]; user = [SOGoUser userWithLogin: login roles: nil]; if (user) { diff --git a/SoObjects/SOGo/SOGoDAVAuthenticator.m b/SoObjects/SOGo/SOGoDAVAuthenticator.m index 5a5531dad..e5d0a5618 100644 --- a/SoObjects/SOGo/SOGoDAVAuthenticator.m +++ b/SoObjects/SOGo/SOGoDAVAuthenticator.m @@ -65,7 +65,8 @@ domain: &domain perr: &perr expire: &expire - grace: &grace] + grace: &grace + additionalInfo: nil] && perr == PolicyNoError); if (!rc) diff --git a/SoObjects/SOGo/SOGoUserManager.m b/SoObjects/SOGo/SOGoUserManager.m index db392f7f8..3694facbf 100644 --- a/SoObjects/SOGo/SOGoUserManager.m +++ b/SoObjects/SOGo/SOGoUserManager.m @@ -502,6 +502,7 @@ static const NSString *kObfuscatedSecondaryEmailKey = @"obfuscatedSecondaryEmail perr: (SOGoPasswordPolicyError *) perr expire: (int *) expire grace: (int *) grace + additionalInfo: (NSMutableDictionary **)_additionalInfo { NSObject *sogoSource; NSEnumerator *authIDs; @@ -520,8 +521,11 @@ static const NSString *kObfuscatedSecondaryEmailKey = @"obfuscatedSecondaryEmail perr: perr expire: expire grace: grace]; + if ([sogoSource userPasswordPolicy] && [[sogoSource userPasswordPolicy] count] > 0) { + [*_additionalInfo setObject:[sogoSource userPasswordPolicy] forKey:@"userPolicies"]; + } } - + if (checkOK && *domain == nil) { SOGoSystemDefaults *sd = [SOGoSystemDefaults sharedSystemDefaults]; @@ -554,6 +558,7 @@ static const NSString *kObfuscatedSecondaryEmailKey = @"obfuscatedSecondaryEmail perr: (SOGoPasswordPolicyError *) _perr expire: (int *) _expire grace: (int *) _grace + additionalInfo:(NSMutableDictionary **)_additionalInfo { return [self checkLogin: _login password: _pwd @@ -561,6 +566,7 @@ static const NSString *kObfuscatedSecondaryEmailKey = @"obfuscatedSecondaryEmail perr: _perr expire: _expire grace: _grace + additionalInfo: _additionalInfo useCache: YES]; } @@ -573,6 +579,7 @@ static const NSString *kObfuscatedSecondaryEmailKey = @"obfuscatedSecondaryEmail perr: (SOGoPasswordPolicyError *) _perr expire: (int *) _expire grace: (int *) _grace + additionalInfo: (NSMutableDictionary **)_additionalInfo useCache: (BOOL) useCache { NSString *dictPassword, *username, *jsonUser; @@ -726,7 +733,8 @@ static const NSString *kObfuscatedSecondaryEmailKey = @"obfuscatedSecondaryEmail domain: _domain perr: _perr expire: _expire - grace: _grace]) + grace: _grace + additionalInfo: _additionalInfo]) { checkOK = YES; if (!currentUser) diff --git a/SoObjects/SOGo/SOGoWebAuthenticator.h b/SoObjects/SOGo/SOGoWebAuthenticator.h index e225105bf..be8ebd261 100644 --- a/SoObjects/SOGo/SOGoWebAuthenticator.h +++ b/SoObjects/SOGo/SOGoWebAuthenticator.h @@ -37,20 +37,22 @@ + (id) sharedSOGoWebAuthenticator; -- (BOOL) checkLogin: (NSString *) _login - password: (NSString *) _pwd - domain: (NSString **) _domain - perr: (SOGoPasswordPolicyError *) _perr - expire: (int *) _expire - grace: (int *) _grace; +- (BOOL)checkLogin:(NSString *)_login + password:(NSString *)_pwd + domain:(NSString **)_domain + perr:(SOGoPasswordPolicyError *)_perr + expire:(int *)_expire + grace:(int *)_grace + additionalInfo:(NSMutableDictionary **)_additionalInfo; -- (BOOL) checkLogin: (NSString *) _login - password: (NSString *) _pwd - domain: (NSString **) _domain - perr: (SOGoPasswordPolicyError *) _perr - expire: (int *) _expire - grace: (int *) _grace - useCache: (BOOL) useCache; +- (BOOL)checkLogin:(NSString *)_login + password:(NSString *)_pwd + domain:(NSString **)_domain + perr:(SOGoPasswordPolicyError *)_perr + expire:(int *)_expire + grace:(int *)_grace + additionalInfo:(NSMutableDictionary **)_additionalInfo + useCache:(BOOL)useCache; - (WOCookie *) cookieWithUsername: (NSString *) username andPassword: (NSString *) password diff --git a/SoObjects/SOGo/SOGoWebAuthenticator.m b/SoObjects/SOGo/SOGoWebAuthenticator.m index 744ccfecc..5569b8837 100644 --- a/SoObjects/SOGo/SOGoWebAuthenticator.m +++ b/SoObjects/SOGo/SOGoWebAuthenticator.m @@ -102,7 +102,8 @@ domain: &domain perr: &perr expire: &expire - grace: &grace]; + grace: &grace + additionalInfo: nil]; } - (BOOL) checkLogin: (NSString *) _login @@ -111,6 +112,7 @@ perr: (SOGoPasswordPolicyError *) _perr expire: (int *) _expire grace: (int *) _grace + additionalInfo: (NSMutableDictionary **)_additionalInfo { return [self checkLogin: _login password: _pwd @@ -118,6 +120,7 @@ perr: _perr expire: _expire grace: _grace + additionalInfo: _additionalInfo useCache: YES]; } @@ -127,6 +130,7 @@ perr: (SOGoPasswordPolicyError *) _perr expire: (int *) _expire grace: (int *) _grace + additionalInfo: (NSMutableDictionary **)_additionalInfo useCache: (BOOL) _useCache { SOGoCASSession *session; @@ -164,6 +168,7 @@ perr: _perr expire: _expire grace: _grace + additionalInfo: _additionalInfo useCache: _useCache]; //[self logWithFormat: @"Checked login with ppolicy enabled: %d %d %d", *_perr, *_expire, *_grace]; @@ -250,7 +255,8 @@ domain: &domain perr: &perr expire: &expire - grace: &grace]) + grace: &grace + additionalInfo: nil]) return nil; if (domain && [login rangeOfString: @"@"].location == NSNotFound) diff --git a/SoObjects/SOGo/SQLSource.m b/SoObjects/SOGo/SQLSource.m index 9bda6788f..93e8a815c 100644 --- a/SoObjects/SOGo/SQLSource.m +++ b/SoObjects/SOGo/SQLSource.m @@ -245,6 +245,20 @@ return result; } +- (BOOL) checkLogin: (NSString *) _login + password: (NSString *) _pwd + perr: (SOGoPasswordPolicyError *) _perr + expire: (int *) _expire + grace: (int *) _grace +{ + return [self checkLogin: _login + password: _pwd + perr: _perr + expire: _perr + grace: _grace + disablepasswordPolicyCheck: NO]; +} + // // SQL sources don't support right now all the password policy // stuff supported by OpenLDAP (and others). If we want to support @@ -256,6 +270,7 @@ perr: (SOGoPasswordPolicyError *) _perr expire: (int *) _expire grace: (int *) _grace + disablepasswordPolicyCheck: (BOOL) _disablepasswordPolicyCheck { EOAdaptorChannel *channel; EOQualifier *qualifier; @@ -335,10 +350,69 @@ else [self errorWithFormat:@"failed to acquire channel for URL: %@", [_viewURL absoluteString]]; + + if (YES == rc && !_disablepasswordPolicyCheck) { + [self checkPasswordPolicyWithPassword:_pwd perr: _perr]; + } return rc; } +/** + * Validates a given password against the configured user password policies. + * + * This method checks if the provided password complies with all defined + * password policies for the user. Each policy is expected to include + * a regular expression (`regex`) that the password must match. + * + * If the password violates any policy, the method sets an appropriate error + * in the `perr` parameter and stops further validation. + * + * @param password the password to validate. + * @param perr will be set to indicate a policy violation, if the password + * does not meet the required standards. Possible values include: + * `PolicyInsufficientPasswordQuality` for insufficient complexity. + * @return YES if the password satisfies all policies, NO otherwise. + * + * @note This method assumes that `_userPasswordPolicy` is an array of dictionaries, + * where each dictionary represents a password policy with at least a "regex" key. + * @warning If a policy does not include a "regex" key, an error will be logged, and + * the method will continue to the next policy. + */ +- (BOOL) checkPasswordPolicyWithPassword: (NSString *)password perr: (SOGoPasswordPolicyError *)perr +{ + BOOL isPolicyOk; + NSDictionary *policy; + NSEnumerator *policies; + NSRange match; + NSString *regex; + + isPolicyOk = YES; + + if ([_userPasswordPolicy count]) + { + policies = [_userPasswordPolicy objectEnumerator]; + while (isPolicyOk && (policy = [policies nextObject])) + { + regex = [policy objectForKey: @"regex"]; + if (regex) + { + match = [password rangeOfString: regex options: NSRegularExpressionSearch]; + isPolicyOk = isPolicyOk && match.length > 0; + if (match.length == 0) + { + // [self errorWithFormat: @"Password not conform to policy %@ (%@)", regex, [policy objectForKey: @"label"]]; + *perr = PolicyInsufficientPasswordQuality; + } + } + else + [self errorWithFormat: @"Invalid password policy (missing regex): %@", policy]; + } + } + + return isPolicyOk; +} + /** * Change a user's password. * @param login the user's login name. @@ -357,11 +431,8 @@ BOOL didChange, isOldPwdOk, isPolicyOk; EOAdaptorChannel *channel; GCSChannelManager *cm; - NSDictionary *policy; - NSEnumerator *policies; NSException *ex; - NSRange match; - NSString *sqlstr, *regex; + NSString *sqlstr; *perr = -1; isOldPwdOk = NO; @@ -369,30 +440,11 @@ didChange = NO; // Verify current password - isOldPwdOk = [self checkLogin:login password:oldPassword perr:perr expire:0 grace:0]; + isOldPwdOk = [self checkLogin:login password:oldPassword perr:perr expire:0 grace:0 disablepasswordPolicyCheck: YES]; if (isOldPwdOk || passwordRecovery) { - if ([_userPasswordPolicy count]) - { - policies = [_userPasswordPolicy objectEnumerator]; - while (isPolicyOk && (policy = [policies nextObject])) - { - regex = [policy objectForKey: @"regex"]; - if (regex) - { - match = [newPassword rangeOfString: regex options: NSRegularExpressionSearch]; - isPolicyOk = isPolicyOk && match.length > 0; - if (match.length == 0) - { - // [self errorWithFormat: @"Password not conform to policy %@ (%@)", regex, [policy objectForKey: @"label"]]; - *perr = PolicyInsufficientPasswordQuality; - } - } - else - [self errorWithFormat: @"Invalid password policy (missing regex): %@", policy]; - } - } + isPolicyOk = [self checkPasswordPolicyWithPassword:newPassword perr: perr]; } if ((isOldPwdOk || passwordRecovery) && isPolicyOk) diff --git a/UI/MainUI/English.lproj/Localizable.strings b/UI/MainUI/English.lproj/Localizable.strings index 35e15939f..a567f4f70 100644 --- a/UI/MainUI/English.lproj/Localizable.strings +++ b/UI/MainUI/English.lproj/Localizable.strings @@ -88,6 +88,7 @@ See this page for v "Change your Password" = "Change your Password"; "The password was changed successfully." = "The password was changed successfully."; "Your password has expired, please enter a new one below" = "Your password has expired, please enter a new one below"; +"Your password is too weak. Please choose a stronger password to enhance your security" = "Your password is too weak. Please choose a stronger password to enhance your security"; "Password must not be empty." = "Password must not be empty."; "The passwords do not match. Please try again." = "The passwords do not match. Please try again."; "Password Grace Period" = "Password Grace Period"; @@ -130,4 +131,11 @@ See this page for v "Invalid configuration for email password recovery" = "Invalid configuration for email password recovery"; "Password recovery email in error" = "Password recovery email in error"; "Password reset" = "Password reset"; -"Hi %{0},\nThere was a request to change your password!\n\nIf you did not make this request then please ignore this email.\n\nOtherwise, please click this link to change your password: %{1}" = "Hi %{0},\nThere was a request to change your password!\n\nIf you did not make this request then please ignore this email.\n\nOtherwise, please click this link to change your password: %{1}"; \ No newline at end of file +"Hi %{0},\nThere was a request to change your password!\n\nIf you did not make this request then please ignore this email.\n\nOtherwise, please click this link to change your password: %{1}" = "Hi %{0},\nThere was a request to change your password!\n\nIf you did not make this request then please ignore this email.\n\nOtherwise, please click this link to change your password: %{1}"; + +/* Password */ +"POLICY_MIN_LOWERCASE_LETTER" = "Minimum of %{0} lowercase letter"; +"POLICY_MIN_UPPERCASE_LETTER" = "Minimum of %{0} uppercase letter"; +"POLICY_MIN_DIGIT" = "Minimum of %{0} digit"; +"POLICY_MIN_SPECIAL_SYMBOLS" = "Minimum of %{0} special symbols"; +"POLICY_MIN_LENGTH" = "Minimum length of %{0} characters"; \ No newline at end of file diff --git a/UI/MainUI/French.lproj/Localizable.strings b/UI/MainUI/French.lproj/Localizable.strings index 7b6f0b535..43a945586 100644 --- a/UI/MainUI/French.lproj/Localizable.strings +++ b/UI/MainUI/French.lproj/Localizable.strings @@ -85,6 +85,7 @@ "Change your Password" = "Changez votre mot de passe"; "The password was changed successfully." = "Votre mot de passe a bien été changé."; "Your password has expired, please enter a new one below" = "Votre mot de passe est expiré, veuillez entrer un nouveau mot de passe"; +"Your password is too weak. Please choose a stronger password to enhance your security" = "Votre mot de passe est trop faible. Veuillez choisir un mot de passe plus sécurisé pour renforcer votre protection"; "Password must not be empty." = "Le mot de passe ne doit pas être vide."; "The passwords do not match. Please try again." = "Les mots de passe ne sont pas identiques. Essayez de nouveau."; "Password Grace Period" = "Période de grâce pour le mot de passe"; @@ -127,4 +128,11 @@ "Invalid configuration for email password recovery" = "Configuration invalide pour la récupération de mot de passe par e-mail"; "Password recovery email in error" = "Erreur lors de l'envoi de l'email de récupération"; "Password reset" = "Réinitialisation de mot de passe"; -"Hi %{0},\nThere was a request to change your password!\n\nIf you did not make this request then please ignore this email.\n\nOtherwise, please click this link to change your password: %{1}" = "Bonjour %{0},\nUne demande de changement de mot de passe a été initiée.\n\nSi vous n'êtes pas à l'origine de cet e-mail, n'en tenez pas compte.\n\nSi vous en êtes bien à l'origine, veuillez cliquer sur le lien ci-dessous pour modifier votre mot de passe: %{1}"; \ No newline at end of file +"Hi %{0},\nThere was a request to change your password!\n\nIf you did not make this request then please ignore this email.\n\nOtherwise, please click this link to change your password: %{1}" = "Bonjour %{0},\nUne demande de changement de mot de passe a été initiée.\n\nSi vous n'êtes pas à l'origine de cet e-mail, n'en tenez pas compte.\n\nSi vous en êtes bien à l'origine, veuillez cliquer sur le lien ci-dessous pour modifier votre mot de passe: %{1}"; + +/* Password */ +"POLICY_MIN_LOWERCASE_LETTER" = "Au moins %{0} lettre(s) minuscule(s)"; +"POLICY_MIN_UPPERCASE_LETTER" = "Au moins %{0} lettre(s) majuscule(s)"; +"POLICY_MIN_DIGIT" = "Au moins %{0} chiffre(s)"; +"POLICY_MIN_SPECIAL_SYMBOLS" = "Au moins %{0} caractère(s) special(aux)"; +"POLICY_MIN_LENGTH" = "Longueur d'au moins %{0} caractère(s)"; \ No newline at end of file diff --git a/UI/MainUI/SOGoRootPage.m b/UI/MainUI/SOGoRootPage.m index a0206e47b..ceaf19f2b 100644 --- a/UI/MainUI/SOGoRootPage.m +++ b/UI/MainUI/SOGoRootPage.m @@ -52,6 +52,7 @@ #import #import #import +#import #if defined(MFA_CONFIG) #include @@ -167,14 +168,23 @@ static const NSString *kJwtKey = @"jwt"; // // // -- (WOResponse *) _responseWithLDAPPolicyError: (int) error +- (WOResponse *) _responseWithLDAPPolicyError: (int) error additionalInfos: (NSDictionary *) additionalInfos { NSDictionary *jsonError; - jsonError = [NSDictionary dictionaryWithObject: [NSNumber numberWithInt: error] - forKey: @"LDAPPasswordPolicyError"]; - return [self responseWithStatus: 403 - andJSONRepresentation: jsonError]; + if (additionalInfos) { + jsonError = [NSDictionary dictionaryWithObjectsAndKeys: + [NSNumber numberWithInt:error], @"LDAPPasswordPolicyError", + additionalInfos, @"additionalInfos", + nil]; + } else { + jsonError = [NSDictionary dictionaryWithObjectsAndKeys: + [NSNumber numberWithInt:error], @"LDAPPasswordPolicyError", + nil]; + } + + return [self responseWithStatus:403 + andJSONRepresentation:jsonError]; } - (void) _checkAutoReloadWebCalendars: (SOGoUser *) loggedInUser @@ -197,6 +207,27 @@ static const NSString *kJwtKey = @"jwt"; } } +// +// +// +- (void)translateAdditionalLoginInformations:(NSMutableDictionary **)additionalLoginInformations +{ + NSDictionary *policy; + NSMutableDictionary *translations; + + if (additionalLoginInformations && *additionalLoginInformations) { + if ([*additionalLoginInformations objectForKey:@"userPolicies"]) { + translations = [[NSMutableDictionary alloc] init]; + for (policy in [*additionalLoginInformations objectForKey:@"userPolicies"]) { + [translations setObject:[self labelForKey: [policy objectForKey:@"label"]] forKey: [policy objectForKey:@"label"]]; + } + [*additionalLoginInformations setObject:[SOGoPasswordPolicy createPasswordPolicyLabels: [*additionalLoginInformations objectForKey:@"userPolicies"] withTranslations: translations] + forKey:@"userPolicies"]; + [translations release]; + } + } +} + // // // @@ -210,6 +241,7 @@ static const NSString *kJwtKey = @"jwt"; SOGoUserSettings *us; SOGoUser *loggedInUser; NSDictionary *params; + NSMutableDictionary *additionalLoginInformations; NSString *username, *password, *language, *domain, *remoteHost; NSArray *supportedLanguages, *creds; @@ -223,6 +255,7 @@ static const NSString *kJwtKey = @"jwt"; auth = [[WOApplication application] authenticatorInContext: context]; request = [context request]; params = [[request contentAsString] objectFromJSONString]; + additionalLoginInformations = [[NSMutableDictionary alloc] init]; username = [params objectForKey: @"userName"]; password = [params objectForKey: @"password"]; @@ -232,9 +265,11 @@ static const NSString *kJwtKey = @"jwt"; /* this will always be set to something more or less useful by * [WOHttpTransaction applyAdaptorHeadersWithHttpRequest] */ remoteHost = [request headerForKey:@"x-webobjects-remote-host"]; + b = [auth checkLogin: username password: password domain: &domain + perr: &err expire: &expire grace: &grace additionalInfo: &additionalLoginInformations useCache: NO]; + [self translateAdditionalLoginInformations: &additionalLoginInformations]; - if ((b = [auth checkLogin: username password: password domain: &domain - perr: &err expire: &expire grace: &grace useCache: NO]) + if (b && (err == PolicyNoError) // no password policy && ((expire < 0 && grace < 0) // no password policy or everything is alright @@ -334,7 +369,7 @@ static const NSString *kJwtKey = @"jwt"; #endif if ([us objectForKey: @"ForceResetPassword"]) { - response = [self _responseWithLDAPPolicyError: PolicyPasswordExpired]; + response = [self _responseWithLDAPPolicyError: PolicyPasswordExpired additionalInfos: additionalLoginInformations]; } else { [self _checkAutoReloadWebCalendars: loggedInUser]; @@ -377,7 +412,7 @@ static const NSString *kJwtKey = @"jwt"; [self logWithFormat: @"Login from '%@' for user '%@' might not have worked - password policy: %d grace: %d expire: %d bound: %d", remoteHost, username, err, grace, expire, b]; - response = [self _responseWithLDAPPolicyError: err]; + response = [self _responseWithLDAPPolicyError: err additionalInfos: additionalLoginInformations]; } if (rememberLogin) @@ -385,6 +420,8 @@ static const NSString *kJwtKey = @"jwt"; else [response addCookie: [self _cookieWithUsername: nil]]; + [additionalLoginInformations release]; + return response; } @@ -808,7 +845,7 @@ static const NSString *kJwtKey = @"jwt"; } } else - response = [self _responseWithLDAPPolicyError: error]; + response = [self _responseWithLDAPPolicyError: error additionalInfos: nil]; } return response; @@ -1072,4 +1109,5 @@ static const NSString *kJwtKey = @"jwt"; urlCreateAccount]; } + @end /* SOGoRootPage */ diff --git a/UI/Templates/MainUI/SOGoRootPage.wox b/UI/Templates/MainUI/SOGoRootPage.wox index 5a0c1adb0..eec328148 100644 --- a/UI/Templates/MainUI/SOGoRootPage.wox +++ b/UI/Templates/MainUI/SOGoRootPage.wox @@ -213,17 +213,18 @@
- watch_later - vpn_key + watch_later + vpn_key
- + +
- + @@ -232,6 +233,13 @@ +
+
    +
  • + {{ item.label }} +
  • +
+