Skip to content

Commit f0c3e15

Browse files
authored
Merge 920688b into cb1675a
2 parents cb1675a + 920688b commit f0c3e15

File tree

13 files changed

+668
-0
lines changed

13 files changed

+668
-0
lines changed

ydb/core/protos/auth.proto

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ message TAuthConfig {
5656
optional string CertificateAuthenticationDomain = 80 [default = "cert"];
5757
optional bool EnableLoginAuthentication = 81 [default = true];
5858
optional string NodeRegistrationToken = 82 [default = "root@builtin", (Ydb.sensitive) = true];
59+
optional TPasswordComplexitySettings PasswordComplexitySettings = 83;
5960
}
6061

6162
message TUserRegistryConfig {
@@ -122,3 +123,13 @@ message TLdapAuthentication {
122123
optional string Scheme = 11 [default = "ldap"];
123124
optional TExtendedSettings ExtendedSettings = 12;
124125
}
126+
127+
message TPasswordComplexitySettings {
128+
optional uint32 MinLength = 1;
129+
optional uint32 MinLowerCaseCount = 2;
130+
optional uint32 MinUpperCaseCount = 3;
131+
optional uint32 MinNumbersCount = 4;
132+
optional uint32 MinSpecialCharsCount = 5;
133+
optional string SpecialChars = 6 [default = "!@#$%^&*()_+{}|<>?="];
134+
optional bool ContainUsername = 7;
135+
}

ydb/core/tx/schemeshard/schemeshard_impl.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
#include <ydb/core/base/appdata.h>
1212
#include <ydb/core/base/tx_processing.h>
1313
#include <ydb/core/protos/feature_flags.pb.h>
14+
#include <ydb/core/protos/auth.pb.h>
1415
#include <ydb/core/engine/mkql_proto.h>
1516
#include <ydb/core/sys_view/partition_stats/partition_stats.h>
1617
#include <ydb/core/statistics/events.h>
1718
#include <ydb/core/statistics/service/service.h>
1819
#include <ydb/core/scheme/scheme_types_proto.h>
1920
#include <ydb/core/tx/columnshard/bg_tasks/events/events.h>
2021
#include <ydb/core/tx/scheme_board/events_schemeshard.h>
22+
#include <ydb/library/login/password_checker/password_checker.h>
2123
#include <yql/essentials/minikql/mkql_type_ops.h>
2224
#include <yql/essentials/providers/common/proto/gateways_config.pb.h>
2325
#include <util/random/random.h>
@@ -4434,6 +4436,16 @@ TSchemeShard::TSchemeShard(const TActorId &tablet, TTabletStorageInfo *info)
44344436
COUNTER_PQ_STATS_WRITTEN,
44354437
COUNTER_PQ_STATS_BATCH_LATENCY)
44364438
, AllowDataColumnForIndexTable(0, 0, 1)
4439+
, PasswordCheckParameters({
4440+
.MinPasswordLength = AppData()->AuthConfig.GetPasswordCheckerParameters().GetMinimumLength(),
4441+
.MaxPasswordLength = AppData()->AuthConfig.GetPasswordCheckerParameters().GetMaximumLength(),
4442+
.NeedLowerCase = AppData()->AuthConfig.GetPasswordCheckerParameters().GetRestrictLower(),
4443+
.NeedUpperCase = AppData()->AuthConfig.GetPasswordCheckerParameters().GetRestrictUpper(),
4444+
.NeedNumbers = AppData()->AuthConfig.GetPasswordCheckerParameters().GetRestrictNumbers(),
4445+
.NeedSpecialSymbols = AppData()->AuthConfig.GetPasswordCheckerParameters().GetRestrictSpecial(),
4446+
.SpecialSymbols = AppData()->AuthConfig.GetPasswordCheckerParameters().GetSpecialChars()
4447+
})
4448+
, LoginProvider(PasswordCheckParameters)
44374449
{
44384450
TabletCountersPtr.Reset(new TProtobufTabletCounters<
44394451
ESimpleCounters_descriptor,
@@ -7119,6 +7131,10 @@ void TSchemeShard::ApplyConsoleConfigs(const NKikimrConfig::TAppConfig& appConfi
71197131
);
71207132
}
71217133

7134+
if (appConfig.HasAuthConfig()) {
7135+
ConfigureLoginProvider(appConfig.GetAuthConfig(), ctx);
7136+
}
7137+
71227138
if (IsSchemeShardConfigured()) {
71237139
StartStopCompactionQueues();
71247140
if (BackgroundCleaningQueue) {
@@ -7312,6 +7328,35 @@ void TSchemeShard::ConfigureBackgroundCleaningQueue(
73127328
<< ", InflightLimit# " << cleaningConfig.InflightLimit);
73137329
}
73147330

7331+
void TSchemeShard::ConfigureLoginProvider(
7332+
const ::NKikimrProto::TAuthConfig& config,
7333+
const TActorContext &ctx)
7334+
{
7335+
const auto& passwordCheckParameters = config.GetPasswordCheckerParameters();
7336+
PasswordCheckParameters.SetMinPasswordLength(passwordCheckParameters.GetMinimumLength());
7337+
PasswordCheckParameters.SetMaxPasswordLength(passwordCheckParameters.GetMaximumLength());
7338+
PasswordCheckParameters.SetLowerCaseUse(passwordCheckParameters.GetRestrictLower());
7339+
PasswordCheckParameters.SetUpperCaseUse(passwordCheckParameters.GetRestrictUpper());
7340+
PasswordCheckParameters.SetNumbersUse(passwordCheckParameters.GetRestrictNumbers());
7341+
PasswordCheckParameters.SetSpecialSymbolsUse(passwordCheckParameters.GetRestrictSpecial());
7342+
PasswordCheckParameters.SetSpecialSymbols(passwordCheckParameters.GetSpecialChars());
7343+
7344+
LoginProvider.UpdatePasswordCheckParameters(PasswordCheckParameters);
7345+
7346+
auto printBool = [] (bool flag) -> TString {
7347+
return (flag ? "true" : "false");
7348+
};
7349+
7350+
LOG_NOTICE_S(ctx, NKikimrServices::FLAT_TX_SCHEMESHARD,
7351+
"LoginProvider configured: MinPasswordLength# " << PasswordCheckParameters.GetMinPasswordLength()
7352+
<< ", MaxPasswordLength# " << PasswordCheckParameters.GetMaxPasswordLength()
7353+
<< ", NeedLowerCase# " << printBool(PasswordCheckParameters.NeedLowerCaseUse())
7354+
<< ", NeedUpperCase# " << printBool(PasswordCheckParameters.NeedUpperCaseUse())
7355+
<< ", NeedNumbers# " << printBool(PasswordCheckParameters.NeedNumbersUse())
7356+
<< ", NeedSpecialSymbols# " << printBool(PasswordCheckParameters.NeedSpecialSymbolsUse())
7357+
<< ", SpecialSymbols# " << passwordCheckParameters.GetSpecialChars());
7358+
}
7359+
73157360
void TSchemeShard::StartStopCompactionQueues() {
73167361
// note, that we don't need to check current state of compaction queue
73177362
if (IsServerlessDomain(TPath::Init(RootPathId(), this))) {

ydb/core/tx/schemeshard/schemeshard_impl.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
#include <ydb/core/blob_depot/events.h>
6565

6666
#include <ydb/library/login/login.h>
67+
#include <ydb/library/login/password_checker/password_checker.h>
6768

6869
#include <util/generic/ptr.h>
6970

@@ -496,6 +497,10 @@ class TSchemeShard
496497
const NKikimrConfig::TBackgroundCleaningConfig& config,
497498
const TActorContext &ctx);
498499

500+
void ConfigureLoginProvider(
501+
const ::NKikimrProto::TAuthConfig& config,
502+
const TActorContext &ctx);
503+
499504
void StartStopCompactionQueues();
500505

501506
void WaitForTableProfiles(ui64 importId, ui32 itemIdx);
@@ -1455,6 +1460,7 @@ class TSchemeShard
14551460
void ChangeDiskSpaceSoftQuotaBytes(i64 delta) override;
14561461
void AddDiskSpaceSoftQuotaBytes(EUserFacingStorageType storageType, ui64 addend) override;
14571462

1463+
NLogin::TPasswordCheckParameters PasswordCheckParameters;
14581464
NLogin::TLoginProvider LoginProvider;
14591465

14601466
private:

ydb/core/tx/schemeshard/ut_login/ut_login.cpp

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include <util/string/join.h>
22

33
#include <ydb/library/login/login.h>
4+
#include <ydb/library/login/password_checker/password_checker.h>
45
#include <ydb/library/actors/http/http_proxy.h>
56
#include <ydb/library/testlib/service_mocks/ldap_mock/ldap_simple_server.h>
67
#include <ydb/core/tx/schemeshard/ut_helpers/helpers.h>
@@ -27,6 +28,23 @@ void TestCreateAlterLoginCreateUser(TTestActorRuntime& runtime, ui64 txId, const
2728
TestModificationResults(runtime, txId, expectedResults);
2829
}
2930

31+
void SetPasswordCheckerParameters(TTestActorRuntime &runtime, ui64 schemeShard, const NLogin::TPasswordCheckParameters::TInitializer& parameters) {
32+
auto request = MakeHolder<NConsole::TEvConsole::TEvConfigNotificationRequest>();
33+
34+
::NKikimrProto::TPasswordCheckerParameters passwordCheckParameters;
35+
passwordCheckParameters.SetMinimumLength(parameters.MinPasswordLength);
36+
passwordCheckParameters.SetMaximumLength(parameters.MaxPasswordLength);
37+
passwordCheckParameters.SetRestrictLower(parameters.NeedLowerCase);
38+
passwordCheckParameters.SetRestrictUpper(parameters.NeedUpperCase);
39+
passwordCheckParameters.SetRestrictNumbers(parameters.NeedNumbers);
40+
passwordCheckParameters.SetRestrictSpecial(parameters.NeedSpecialSymbols);
41+
passwordCheckParameters.SetSpecialChars(parameters.SpecialSymbols);
42+
*request->Record.MutableConfig()->MutableAuthConfig()->MutablePasswordCheckerParameters() = passwordCheckParameters;
43+
SetConfig(runtime, schemeShard, std::move(request));
44+
}
45+
46+
const TString VALID_SPECIAL_SYMBOLS = "!@#$%^&*()_+{}|<>?=";
47+
3048
} // namespace NSchemeShardUT_Private
3149

3250
Y_UNIT_TEST_SUITE(TSchemeShardLoginTest) {
@@ -66,6 +84,128 @@ Y_UNIT_TEST_SUITE(TSchemeShardLoginTest) {
6684
UNIT_ASSERT(describe.GetPathDescription().GetDomainDescription().HasSecurityState());
6785
UNIT_ASSERT(describe.GetPathDescription().GetDomainDescription().GetSecurityState().PublicKeysSize() > 0);
6886
}
87+
88+
Y_UNIT_TEST(ChangeAcceptablePasswordParameters) {
89+
TTestBasicRuntime runtime;
90+
TTestEnv env(runtime);
91+
ui64 txId = 100;
92+
// Password parameters:
93+
// length 0 - 4294967295
94+
// optional: lower case, upper case, numbers, special symbols from list !@#$%^&*()_+{}|<>?=
95+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user1", "password1", {{NKikimrScheme::StatusSuccess}});
96+
auto resultLogin = Login(runtime, "user1", "password1");
97+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
98+
auto describe = DescribePath(runtime, TTestTxConfig::SchemeShard, "/MyRoot");
99+
UNIT_ASSERT(describe.HasPathDescription());
100+
UNIT_ASSERT(describe.GetPathDescription().HasDomainDescription());
101+
UNIT_ASSERT(describe.GetPathDescription().GetDomainDescription().HasSecurityState());
102+
UNIT_ASSERT(describe.GetPathDescription().GetDomainDescription().GetSecurityState().PublicKeysSize() > 0);
103+
104+
// Accept password without lower case symbols
105+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user2", "PASSWORDU2", {{NKikimrScheme::StatusSuccess}});
106+
resultLogin = Login(runtime, "user2", "PASSWORDU2");
107+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
108+
// Password parameters:
109+
// length 0 - 4294967295
110+
// optional: upper case, numbers, special symbols from list !@#$%^&*()_+{}|<>?=
111+
// required: lower case
112+
SetPasswordCheckerParameters(runtime, TTestTxConfig::SchemeShard, {.NeedLowerCase = true, .SpecialSymbols = VALID_SPECIAL_SYMBOLS});
113+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user3", "PASSWORDU3", {{NKikimrScheme::StatusPreconditionFailed}});
114+
// Add lower case symbols to password
115+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user3", "PASswORDu3", {{NKikimrScheme::StatusSuccess}});
116+
resultLogin = Login(runtime, "user3", "PASswORDu3");
117+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
118+
119+
// Accept password without upper case symbols
120+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user4", "passwordu4", {{NKikimrScheme::StatusSuccess}});
121+
resultLogin = Login(runtime, "user4", "passwordu4");
122+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
123+
// Password parameters:
124+
// length 0 - 4294967295
125+
// optional: numbers, special symbols from list !@#$%^&*()_+{}|<>?=
126+
// required: lower case, upper case
127+
SetPasswordCheckerParameters(runtime, TTestTxConfig::SchemeShard, {.NeedLowerCase = true, .NeedUpperCase = true, .SpecialSymbols = VALID_SPECIAL_SYMBOLS});
128+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user5", "passwordu5", {{NKikimrScheme::StatusPreconditionFailed}});
129+
// Add upper case symbols to password
130+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user5", "PASswORDu5", {{NKikimrScheme::StatusSuccess}});
131+
resultLogin = Login(runtime, "user5", "PASswORDu5");
132+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
133+
134+
// Accept short and long passwords
135+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user6", "pasSWu6", {{NKikimrScheme::StatusSuccess}});
136+
resultLogin = Login(runtime, "user6", "pasSWu6");
137+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
138+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user7", "pasSW12345Word!*&u7", {{NKikimrScheme::StatusSuccess}});
139+
resultLogin = Login(runtime, "user7", "pasSW12345Word!*&u7");
140+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
141+
// Password parameters:
142+
// length 8 - 15
143+
// optional: numbers, special symbols from list !@#$%^&*()_+{}|<>?=
144+
// required: lower case, upper case
145+
SetPasswordCheckerParameters(runtime, TTestTxConfig::SchemeShard, {.MinPasswordLength = 8, .MaxPasswordLength = 15, .NeedLowerCase = true, .NeedUpperCase = true, .SpecialSymbols = VALID_SPECIAL_SYMBOLS});
146+
// Too short password
147+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user8", "pasSWu8", {{NKikimrScheme::StatusPreconditionFailed}});
148+
// Too long password
149+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user8", "pasSW12345Word!*&u8", {{NKikimrScheme::StatusPreconditionFailed}});
150+
// Password has correct length
151+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user8", "PASswORDu8", {{NKikimrScheme::StatusSuccess}});
152+
resultLogin = Login(runtime, "user8", "PASswORDu8");
153+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
154+
155+
// Accept password without numbers
156+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user9", "passWorDunine", {{NKikimrScheme::StatusSuccess}});
157+
resultLogin = Login(runtime, "user9", "passWorDunine");
158+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
159+
// Password parameters:
160+
// length 8 - 15
161+
// optional: special symbols from list !@#$%^&*()_+{}|<>?=
162+
// required: lower case, upper case, numbers
163+
SetPasswordCheckerParameters(runtime, TTestTxConfig::SchemeShard, {.MinPasswordLength = 8,
164+
.MaxPasswordLength = 15,
165+
.NeedLowerCase = true,
166+
.NeedUpperCase = true,
167+
.NeedNumbers = true,
168+
.SpecialSymbols = VALID_SPECIAL_SYMBOLS});
169+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user10", "passWorDuten", {{NKikimrScheme::StatusPreconditionFailed}});
170+
// Password with numbers
171+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user10", "PASswORDu10", {{NKikimrScheme::StatusSuccess}});
172+
resultLogin = Login(runtime, "user10", "PASswORDu10");
173+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
174+
175+
// Accept password without special symbols
176+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user11", "passWorDu11", {{NKikimrScheme::StatusSuccess}});
177+
resultLogin = Login(runtime, "user11", "passWorDu11");
178+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
179+
// Password parameters:
180+
// length 8 - 15
181+
// required: lower case, upper case, numbers, special symbols from list !@#$%^&*()_+{}|<>?=
182+
SetPasswordCheckerParameters(runtime, TTestTxConfig::SchemeShard, {.MinPasswordLength = 8,
183+
.MaxPasswordLength = 15,
184+
.NeedLowerCase = true,
185+
.NeedUpperCase = true,
186+
.NeedNumbers = true,
187+
.NeedSpecialSymbols = true,
188+
.SpecialSymbols = VALID_SPECIAL_SYMBOLS});
189+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user12", "passWorDu12", {{NKikimrScheme::StatusPreconditionFailed}});
190+
// Password with special symbols
191+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user12", "PASswORDu12*&%#", {{NKikimrScheme::StatusSuccess}});
192+
resultLogin = Login(runtime, "user12", "PASswORDu12*&%#");
193+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
194+
// Password parameters:
195+
// length 8 - 15
196+
// required: lower case, upper case, numbers, special symbols from list *#
197+
SetPasswordCheckerParameters(runtime, TTestTxConfig::SchemeShard, {.MinPasswordLength = 8,
198+
.MaxPasswordLength = 15,
199+
.NeedLowerCase = true,
200+
.NeedUpperCase = true,
201+
.NeedNumbers = true,
202+
.NeedSpecialSymbols = true,
203+
.SpecialSymbols = "*#"}); // Only 2 special symbols are valid
204+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user13", "PASswORDu13*&%#", {{NKikimrScheme::StatusPreconditionFailed}});
205+
TestCreateAlterLoginCreateUser(runtime, ++txId, "/MyRoot", "user13", "PASswORDu12*#", {{NKikimrScheme::StatusSuccess}});
206+
resultLogin = Login(runtime, "user13", "PASswORDu12*#");
207+
UNIT_ASSERT_VALUES_EQUAL(resultLogin.error(), "");
208+
}
69209
}
70210

71211
namespace NSchemeShardUT_Private {

ydb/library/login/login.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
#include <deque>
1414

15+
#include <ydb/library/login/password_checker/password_checker.h>
16+
1517
#include "login.h"
1618

1719
namespace NLogin {
@@ -35,6 +37,12 @@ struct TLoginProvider::TImpl {
3537

3638
TLoginProvider::TLoginProvider()
3739
: Impl(new TImpl())
40+
, PasswordChecker(TPasswordCheckParameters())
41+
{}
42+
43+
TLoginProvider::TLoginProvider(const TPasswordCheckParameters& passwordCheckParameters)
44+
: Impl(new TImpl())
45+
, PasswordChecker(passwordCheckParameters)
3846
{}
3947

4048
TLoginProvider::~TLoginProvider()
@@ -51,6 +59,13 @@ TLoginProvider::TBasicResponse TLoginProvider::CreateUser(const TCreateUserReque
5159
response.Error = "Name is not allowed";
5260
return response;
5361
}
62+
63+
TPasswordChecker::TResult passwordCheckResult = PasswordChecker.Check(request.User, request.Password);
64+
if (!passwordCheckResult.Success) {
65+
response.Error = passwordCheckResult.Error;
66+
return response;
67+
}
68+
5469
auto itUserCreate = Sids.emplace(request.User, TSidRecord{.Type = NLoginProto::ESidType::USER});
5570
if (!itUserCreate.second) {
5671
if (itUserCreate.first->second.Type == ESidType::USER) {
@@ -86,6 +101,12 @@ TLoginProvider::TBasicResponse TLoginProvider::ModifyUser(const TModifyUserReque
86101
return response;
87102
}
88103

104+
TPasswordChecker::TResult passwordCheckResult = PasswordChecker.Check(request.User, request.Password);
105+
if (!passwordCheckResult.Success) {
106+
response.Error = passwordCheckResult.Error;
107+
return response;
108+
}
109+
89110
TSidRecord& user = itUserModify->second;
90111
user.Hash = Impl->GenerateHash(request.Password);
91112

@@ -650,4 +671,8 @@ void TLoginProvider::UpdateSecurityState(const NLoginProto::TSecurityState& stat
650671
}
651672
}
652673

674+
void TLoginProvider::UpdatePasswordCheckParameters(const TPasswordCheckParameters& passwordCheckParameters) {
675+
PasswordChecker.Update(passwordCheckParameters);
676+
}
677+
653678
}

ydb/library/login/login.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <deque>
88
#include <util/generic/string.h>
99
#include <ydb/library/login/protos/login.pb.h>
10+
#include <ydb/library/login/password_checker/password_checker.h>
1011

1112
namespace NLogin {
1213

@@ -172,7 +173,10 @@ class TLoginProvider {
172173
TRenameGroupResponse RenameGroup(const TRenameGroupRequest& request);
173174
TRemoveGroupResponse RemoveGroup(const TRemoveGroupRequest& request);
174175

176+
void UpdatePasswordCheckParameters(const TPasswordCheckParameters& passwordCheckParameters);
177+
175178
TLoginProvider();
179+
TLoginProvider(const TPasswordCheckParameters& passwordCheckParameters);
176180
~TLoginProvider();
177181

178182
std::vector<TString> GetGroupsMembership(const TString& member);
@@ -186,6 +190,8 @@ class TLoginProvider {
186190

187191
struct TImpl;
188192
THolder<TImpl> Impl;
193+
194+
TPasswordChecker PasswordChecker;
189195
};
190196

191197
}

0 commit comments

Comments
 (0)