From b453703aa83f9e3b1605190aed3356fec9d46155 Mon Sep 17 00:00:00 2001 From: Rodrigo Andrade Date: Mon, 15 Aug 2016 18:20:28 -0300 Subject: [PATCH 01/66] removing duplicated code for cookie genaration and adding random bytes to generate user cookies --- src/main/java/com/gitblit/ConfigUserService.java | 2 +- src/main/java/com/gitblit/auth/AuthenticationProvider.java | 2 +- src/main/java/com/gitblit/client/EditUserDialog.java | 2 +- src/main/java/com/gitblit/models/UserModel.java | 4 ++++ src/main/java/com/gitblit/wicket/pages/EditUserPage.java | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gitblit/ConfigUserService.java b/src/main/java/com/gitblit/ConfigUserService.java index 6d7230f71..025b1d8c0 100644 --- a/src/main/java/com/gitblit/ConfigUserService.java +++ b/src/main/java/com/gitblit/ConfigUserService.java @@ -898,7 +898,7 @@ protected synchronized void read() { user.countryCode = config.getString(USER, username, COUNTRYCODE); user.cookie = config.getString(USER, username, COOKIE); if (StringUtils.isEmpty(user.cookie) && !StringUtils.isEmpty(user.password)) { - user.cookie = StringUtils.getSHA1(user.username + user.password); + user.cookie = user.createCookie(); } // preferences diff --git a/src/main/java/com/gitblit/auth/AuthenticationProvider.java b/src/main/java/com/gitblit/auth/AuthenticationProvider.java index 0bfe23515..6c0988594 100644 --- a/src/main/java/com/gitblit/auth/AuthenticationProvider.java +++ b/src/main/java/com/gitblit/auth/AuthenticationProvider.java @@ -81,7 +81,7 @@ public String getServiceName() { protected void setCookie(UserModel user, char [] password) { // create a user cookie if (StringUtils.isEmpty(user.cookie) && !ArrayUtils.isEmpty(password)) { - user.cookie = StringUtils.getSHA1(user.username + new String(password)); + user.cookie = user.createCookie(); } } diff --git a/src/main/java/com/gitblit/client/EditUserDialog.java b/src/main/java/com/gitblit/client/EditUserDialog.java index 676916b23..4b01ff046 100644 --- a/src/main/java/com/gitblit/client/EditUserDialog.java +++ b/src/main/java/com/gitblit/client/EditUserDialog.java @@ -330,7 +330,7 @@ private boolean validateFields() { } // change the cookie - user.cookie = StringUtils.getSHA1(user.username + password); + user.cookie = user.createCookie(); String type = settings.get(Keys.realm.passwordStorage).getString("md5"); if (type.equalsIgnoreCase("md5")) { diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java index e15227482..d411e5040 100644 --- a/src/main/java/com/gitblit/models/UserModel.java +++ b/src/main/java/com/gitblit/models/UserModel.java @@ -660,4 +660,8 @@ public boolean isMyPersonalRepository(String repository) { String projectPath = StringUtils.getFirstPathElement(repository); return !StringUtils.isEmpty(projectPath) && projectPath.equalsIgnoreCase(getPersonalPath()); } + + public String createCookie() { + return StringUtils.getSHA1(String.valueOf(Math.random())); + } } diff --git a/src/main/java/com/gitblit/wicket/pages/EditUserPage.java b/src/main/java/com/gitblit/wicket/pages/EditUserPage.java index 220bee3f6..72dee6b6d 100644 --- a/src/main/java/com/gitblit/wicket/pages/EditUserPage.java +++ b/src/main/java/com/gitblit/wicket/pages/EditUserPage.java @@ -156,7 +156,7 @@ protected void onSubmit() { } // change the cookie - userModel.cookie = StringUtils.getSHA1(userModel.username + password); + userModel.cookie = userModel.createCookie(); // Optionally store the password MD5 digest. String type = app().settings().getString(Keys.realm.passwordStorage, "md5"); From 4365c8f0b0410f540118868bbfc30f6974db3008 Mon Sep 17 00:00:00 2001 From: Rodrigo Andrade Date: Mon, 15 Aug 2016 18:24:24 -0300 Subject: [PATCH 02/66] removing unecessary user cookie code --- src/main/java/com/gitblit/auth/AuthenticationProvider.java | 4 ++-- src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java | 2 +- src/main/java/com/gitblit/auth/LdapAuthProvider.java | 2 +- src/main/java/com/gitblit/auth/PAMAuthProvider.java | 2 +- src/main/java/com/gitblit/auth/RedmineAuthProvider.java | 2 +- src/main/java/com/gitblit/auth/SalesforceAuthProvider.java | 2 +- src/main/java/com/gitblit/auth/WindowsAuthProvider.java | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/gitblit/auth/AuthenticationProvider.java b/src/main/java/com/gitblit/auth/AuthenticationProvider.java index 6c0988594..e359fd7e4 100644 --- a/src/main/java/com/gitblit/auth/AuthenticationProvider.java +++ b/src/main/java/com/gitblit/auth/AuthenticationProvider.java @@ -78,9 +78,9 @@ public String getServiceName() { public abstract AuthenticationType getAuthenticationType(); - protected void setCookie(UserModel user, char [] password) { + protected void setCookie(UserModel user) { // create a user cookie - if (StringUtils.isEmpty(user.cookie) && !ArrayUtils.isEmpty(password)) { + if (StringUtils.isEmpty(user.cookie)) { user.cookie = user.createCookie(); } } diff --git a/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java b/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java index 2cdabf6f8..3a6cb8ec1 100644 --- a/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java +++ b/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java @@ -196,7 +196,7 @@ else if (supportPlaintextPwd() && storedPwd.equals(passwd)){ } // create a user cookie - setCookie(user, password); + setCookie(user); // Set user attributes, hide password from backing user service. user.password = Constants.EXTERNAL_ACCOUNT; diff --git a/src/main/java/com/gitblit/auth/LdapAuthProvider.java b/src/main/java/com/gitblit/auth/LdapAuthProvider.java index cc772e7b4..b7efd4a04 100644 --- a/src/main/java/com/gitblit/auth/LdapAuthProvider.java +++ b/src/main/java/com/gitblit/auth/LdapAuthProvider.java @@ -360,7 +360,7 @@ public UserModel authenticate(String username, char[] password) { } // create a user cookie - setCookie(user, password); + setCookie(user); if (!supportsTeamMembershipChanges()) { getTeamsFromLdap(ldapConnection, simpleUsername, loggingInUser, user); diff --git a/src/main/java/com/gitblit/auth/PAMAuthProvider.java b/src/main/java/com/gitblit/auth/PAMAuthProvider.java index 46f4dd6a6..b38d49df9 100644 --- a/src/main/java/com/gitblit/auth/PAMAuthProvider.java +++ b/src/main/java/com/gitblit/auth/PAMAuthProvider.java @@ -122,7 +122,7 @@ public UserModel authenticate(String username, char[] password) { } // create a user cookie - setCookie(user, password); + setCookie(user); // update user attributes from UnixUser user.accountType = getAccountType(); diff --git a/src/main/java/com/gitblit/auth/RedmineAuthProvider.java b/src/main/java/com/gitblit/auth/RedmineAuthProvider.java index 27cece299..364aff042 100644 --- a/src/main/java/com/gitblit/auth/RedmineAuthProvider.java +++ b/src/main/java/com/gitblit/auth/RedmineAuthProvider.java @@ -139,7 +139,7 @@ public UserModel authenticate(String username, char[] password) { } // create a user cookie - setCookie(user, password); + setCookie(user); // update user attributes from Redmine user.accountType = getAccountType(); diff --git a/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java b/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java index df033c27a..79c3a0c47 100644 --- a/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java +++ b/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java @@ -66,7 +66,7 @@ public UserModel authenticate(String username, char[] password) { user = new UserModel(simpleUsername); } - setCookie(user, password); + setCookie(user); setUserAttributes(user, info); updateUser(user); diff --git a/src/main/java/com/gitblit/auth/WindowsAuthProvider.java b/src/main/java/com/gitblit/auth/WindowsAuthProvider.java index aee51008a..4c31fb15b 100644 --- a/src/main/java/com/gitblit/auth/WindowsAuthProvider.java +++ b/src/main/java/com/gitblit/auth/WindowsAuthProvider.java @@ -153,7 +153,7 @@ public UserModel authenticate(String username, char[] password) { } // create a user cookie - setCookie(user, password); + setCookie(user); // update user attributes from Windows identity user.accountType = getAccountType(); From 3b02737103c9d47f065f5026efad26c818cbe40a Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Fri, 18 Nov 2016 20:26:06 +0100 Subject: [PATCH 03/66] Set "can admin" permission on LDAP users and teams correctly The canAdmin permission is set on a LDAP user, when the user is listed in `realm.ldap.admins` or is a member of a team listed in `realm.ldap.admins`. This leads to inconsistent and surprising behaviour on the EditUser page when clicking the "can admin" checkbox. Also, the "can admin" checkbox is disabled, but not checked, for teams that are listed as admin teams. The new behaviour implemented in this patch makes users and teams from LDAP match local ones. That means: * LDAP teams that are listed in `realm.ldap.admins` get the canAdmin property set if teams are maintained in LDAP. * LDAP users that are listed in `realm.ldap.admins` get the canAdmin property set if teams are maintained in LDAP. * LDAP users do not get the canAdmin property set, if they are only a member of a team listed in `realm.ldap.admins`. * The `supportsRoleChanges` method for users and teams of the `LdapAuthProvider` unconditially returns false if teams are maintained in LDAP, not only for users and teams listed in `realm.ldap.admins`. * Therefore, for all LDAP users and teams the "can admin" checkbox is always disabled if teams are maintained in LDAP. --- .../com/gitblit/auth/LdapAuthProvider.java | 44 ++- .../gitblit/tests/LdapAuthenticationTest.java | 265 +++++++++++++++++- src/test/resources/ldap/users.conf | 6 +- 3 files changed, 296 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/gitblit/auth/LdapAuthProvider.java b/src/main/java/com/gitblit/auth/LdapAuthProvider.java index e1dec48fb..19fd46325 100644 --- a/src/main/java/com/gitblit/auth/LdapAuthProvider.java +++ b/src/main/java/com/gitblit/auth/LdapAuthProvider.java @@ -171,6 +171,8 @@ public synchronized void sync() { final Map userTeams = new HashMap(); for (UserModel user : ldapUsers.values()) { for (TeamModel userTeam : user.teams) { + // Is this an administrative team? + setAdminAttribute(userTeam); userTeams.put(userTeam.name, userTeam); } } @@ -238,10 +240,7 @@ public boolean supportsTeamMembershipChanges() { public boolean supportsRoleChanges(UserModel user, Role role) { if (Role.ADMIN == role) { if (!supportsTeamMembershipChanges()) { - List admins = settings.getStrings(Keys.realm.ldap.admins); - if (admins.contains(user.username)) { - return false; - } + return false; } } return true; @@ -251,10 +250,7 @@ public boolean supportsRoleChanges(UserModel user, Role role) { public boolean supportsRoleChanges(TeamModel team, Role role) { if (Role.ADMIN == role) { if (!supportsTeamMembershipChanges()) { - List admins = settings.getStrings(Keys.realm.ldap.admins); - if (admins.contains("@" + team.name)) { - return false; - } + return false; } } return true; @@ -325,6 +321,8 @@ public UserModel authenticate(String username, char[] password) { if (!supportsTeamMembershipChanges()) { for (TeamModel userTeam : user.teams) { + // Is this an administrative team? + setAdminAttribute(userTeam); updateTeam(userTeam); } } @@ -355,10 +353,7 @@ private void setAdminAttribute(UserModel user) { if (!ArrayUtils.isEmpty(admins)) { user.canAdmin = false; for (String admin : admins) { - if (admin.startsWith("@") && user.isTeamMember(admin.substring(1))) { - // admin team - user.canAdmin = true; - } else if (user.getName().equalsIgnoreCase(admin)) { + if (user.getName().equalsIgnoreCase(admin)) { // admin user user.canAdmin = true; } @@ -367,6 +362,30 @@ private void setAdminAttribute(UserModel user) { } } + /** + * Set the canAdmin attribute for team retrieved from LDAP. + * If we are not storing teams in LDAP and/or we have not defined any + * administrator teams, then do not change the admin flag. + * + * @param team + */ + private void setAdminAttribute(TeamModel team) { + if (!supportsTeamMembershipChanges()) { + List admins = settings.getStrings(Keys.realm.ldap.admins); + // if we have defined administrative teams, then set admin flag + // otherwise leave admin flag unchanged + if (!ArrayUtils.isEmpty(admins)) { + team.canAdmin = false; + for (String admin : admins) { + if (admin.startsWith("@") && team.name.equalsIgnoreCase(admin.substring(1))) { + // admin team + team.canAdmin = true; + } + } + } + } + } + private void setUserAttributes(UserModel user, SearchResultEntry userEntry) { // Is this user an admin? setAdminAttribute(user); @@ -462,6 +481,7 @@ private void getEmptyTeamsFromLdap(LdapConnection ldapConnection) { TeamModel teamModel = userManager.getTeamModel(teamName); if (teamModel == null) { teamModel = createTeamFromLdap(teamEntry); + setAdminAttribute(teamModel); userManager.updateTeamModel(teamModel); } } diff --git a/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java b/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java index 2ade68192..b7a77fc24 100644 --- a/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java +++ b/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java @@ -296,7 +296,6 @@ public void testAuthenticate() { assertNotNull(userOneModel); assertNotNull(userOneModel.getTeam("git_admins")); assertNotNull(userOneModel.getTeam("git_users")); - assertTrue(userOneModel.canAdmin); UserModel userOneModelFailedAuth = ldap.authenticate("UserOne", "userTwoPassword".toCharArray()); assertNull(userOneModelFailedAuth); @@ -306,13 +305,49 @@ public void testAuthenticate() { assertNotNull(userTwoModel.getTeam("git_users")); assertNull(userTwoModel.getTeam("git_admins")); assertNotNull(userTwoModel.getTeam("git admins")); - assertTrue(userTwoModel.canAdmin); UserModel userThreeModel = ldap.authenticate("UserThree", "userThreePassword".toCharArray()); assertNotNull(userThreeModel); assertNotNull(userThreeModel.getTeam("git_users")); assertNull(userThreeModel.getTeam("git_admins")); + + UserModel userFourModel = ldap.authenticate("UserFour", "userFourPassword".toCharArray()); + assertNotNull(userFourModel); + assertNotNull(userFourModel.getTeam("git_users")); + assertNull(userFourModel.getTeam("git_admins")); + assertNull(userFourModel.getTeam("git admins")); + } + + @Test + public void testAdminPropertyTeamsInLdap() { + UserModel userOneModel = ldap.authenticate("UserOne", "userOnePassword".toCharArray()); + assertNotNull(userOneModel); + assertNotNull(userOneModel.getTeam("git_admins")); + assertNull(userOneModel.getTeam("git admins")); + assertNotNull(userOneModel.getTeam("git_users")); + assertFalse(userOneModel.canAdmin); + assertTrue(userOneModel.canAdmin()); + assertTrue(userOneModel.getTeam("git_admins").canAdmin); + assertFalse(userOneModel.getTeam("git_users").canAdmin); + + UserModel userTwoModel = ldap.authenticate("UserTwo", "userTwoPassword".toCharArray()); + assertNotNull(userTwoModel); + assertNotNull(userTwoModel.getTeam("git_users")); + assertNull(userTwoModel.getTeam("git_admins")); + assertNotNull(userTwoModel.getTeam("git admins")); + assertFalse(userTwoModel.canAdmin); + assertTrue(userTwoModel.canAdmin()); + assertTrue(userTwoModel.getTeam("git admins").canAdmin); + assertFalse(userTwoModel.getTeam("git_users").canAdmin); + + UserModel userThreeModel = ldap.authenticate("UserThree", "userThreePassword".toCharArray()); + assertNotNull(userThreeModel); + assertNotNull(userThreeModel.getTeam("git_users")); + assertNull(userThreeModel.getTeam("git_admins")); + assertNull(userThreeModel.getTeam("git admins")); assertTrue(userThreeModel.canAdmin); + assertTrue(userThreeModel.canAdmin()); + assertFalse(userThreeModel.getTeam("git_users").canAdmin); UserModel userFourModel = ldap.authenticate("UserFour", "userFourPassword".toCharArray()); assertNotNull(userFourModel); @@ -320,6 +355,51 @@ public void testAuthenticate() { assertNull(userFourModel.getTeam("git_admins")); assertNull(userFourModel.getTeam("git admins")); assertFalse(userFourModel.canAdmin); + assertFalse(userFourModel.canAdmin()); + assertFalse(userFourModel.getTeam("git_users").canAdmin); + } + + @Test + public void testAdminPropertyTeamsNotInLdap() { + settings.put(Keys.realm.ldap.maintainTeams, "false"); + + UserModel userOneModel = ldap.authenticate("UserOne", "userOnePassword".toCharArray()); + assertNotNull(userOneModel); + assertNotNull(userOneModel.getTeam("git_admins")); + assertNull(userOneModel.getTeam("git admins")); + assertNotNull(userOneModel.getTeam("git_users")); + assertTrue(userOneModel.canAdmin); + assertTrue(userOneModel.canAdmin()); + assertFalse(userOneModel.getTeam("git_admins").canAdmin); + assertFalse(userOneModel.getTeam("git_users").canAdmin); + + UserModel userTwoModel = ldap.authenticate("UserTwo", "userTwoPassword".toCharArray()); + assertNotNull(userTwoModel); + assertNotNull(userTwoModel.getTeam("git_users")); + assertNull(userTwoModel.getTeam("git_admins")); + assertNotNull(userTwoModel.getTeam("git admins")); + assertFalse(userTwoModel.canAdmin); + assertTrue(userTwoModel.canAdmin()); + assertTrue(userTwoModel.getTeam("git admins").canAdmin); + assertFalse(userTwoModel.getTeam("git_users").canAdmin); + + UserModel userThreeModel = ldap.authenticate("UserThree", "userThreePassword".toCharArray()); + assertNotNull(userThreeModel); + assertNotNull(userThreeModel.getTeam("git_users")); + assertNull(userThreeModel.getTeam("git_admins")); + assertNull(userThreeModel.getTeam("git admins")); + assertFalse(userThreeModel.canAdmin); + assertFalse(userThreeModel.canAdmin()); + assertFalse(userThreeModel.getTeam("git_users").canAdmin); + + UserModel userFourModel = ldap.authenticate("UserFour", "userFourPassword".toCharArray()); + assertNotNull(userFourModel); + assertNotNull(userFourModel.getTeam("git_users")); + assertNull(userFourModel.getTeam("git_admins")); + assertNull(userFourModel.getTeam("git admins")); + assertFalse(userFourModel.canAdmin); + assertFalse(userFourModel.canAdmin()); + assertFalse(userFourModel.getTeam("git_users").canAdmin); } @Test @@ -391,6 +471,17 @@ public void addingGroupsInLdapShouldNotUpdateGitBlitUsersAndGroups() throws Exce assertEquals("Number of ldap groups in gitblit team model", 0, countLdapTeamsInUserManager()); } + @Test + public void addingGroupsInLdapShouldUpdateGitBlitUsersNotGroups2() throws Exception { + settings.put(Keys.realm.ldap.synchronize, "true"); + settings.put(Keys.realm.ldap.maintainTeams, "false"); + getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "adduser.ldif")); + getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "addgroup.ldif")); + ldap.sync(); + assertEquals("Number of ldap users in gitblit user model", 6, countLdapUsersInUserManager()); + assertEquals("Number of ldap groups in gitblit team model", 0, countLdapTeamsInUserManager()); + } + @Test public void addingGroupsInLdapShouldUpdateGitBlitUsersAndGroups() throws Exception { // This test only makes sense if the authentication mode allows for synchronization. @@ -402,13 +493,92 @@ public void addingGroupsInLdapShouldUpdateGitBlitUsersAndGroups() throws Excepti assertEquals("Number of ldap groups in gitblit team model", 1, countLdapTeamsInUserManager()); } + @Test + public void syncUpdateUsersAndGroupsAdminProperty() throws Exception { + // This test only makes sense if the authentication mode allows for synchronization. + assumeTrue(authMode == AuthMode.ANONYMOUS || authMode == AuthMode.DS_MANAGER); + + settings.put(Keys.realm.ldap.synchronize, "true"); + ldap.sync(); + + UserModel user = userManager.getUserModel("UserOne"); + assertNotNull(user); + assertFalse(user.canAdmin); + assertTrue(user.canAdmin()); + + user = userManager.getUserModel("UserTwo"); + assertNotNull(user); + assertFalse(user.canAdmin); + assertTrue(user.canAdmin()); + + user = userManager.getUserModel("UserThree"); + assertNotNull(user); + assertTrue(user.canAdmin); + assertTrue(user.canAdmin()); + + user = userManager.getUserModel("UserFour"); + assertNotNull(user); + assertFalse(user.canAdmin); + assertFalse(user.canAdmin()); + + TeamModel team = userManager.getTeamModel("Git_Admins"); + assertNotNull(team); + assertTrue(team.canAdmin); + + team = userManager.getTeamModel("Git Admins"); + assertNotNull(team); + assertTrue(team.canAdmin); + + team = userManager.getTeamModel("Git_Users"); + assertNotNull(team); + assertFalse(team.canAdmin); + } + + @Test + public void syncNotUpdateUsersAndGroupsAdminProperty() throws Exception { + settings.put(Keys.realm.ldap.synchronize, "true"); + settings.put(Keys.realm.ldap.maintainTeams, "false"); + ldap.sync(); + + UserModel user = userManager.getUserModel("UserOne"); + assertNotNull(user); + assertTrue(user.canAdmin); + assertTrue(user.canAdmin()); + + user = userManager.getUserModel("UserTwo"); + assertNotNull(user); + assertFalse(user.canAdmin); + assertTrue(user.canAdmin()); + + user = userManager.getUserModel("UserThree"); + assertNotNull(user); + assertFalse(user.canAdmin); + assertFalse(user.canAdmin()); + + user = userManager.getUserModel("UserFour"); + assertNotNull(user); + assertFalse(user.canAdmin); + assertFalse(user.canAdmin()); + + TeamModel team = userManager.getTeamModel("Git_Admins"); + assertNotNull(team); + assertFalse(team.canAdmin); + + team = userManager.getTeamModel("Git Admins"); + assertNotNull(team); + assertTrue(team.canAdmin); + + team = userManager.getTeamModel("Git_Users"); + assertNotNull(team); + assertFalse(team.canAdmin); + } + @Test public void testAuthenticationManager() { UserModel userOneModel = auth.authenticate("UserOne", "userOnePassword".toCharArray(), null); assertNotNull(userOneModel); assertNotNull(userOneModel.getTeam("git_admins")); assertNotNull(userOneModel.getTeam("git_users")); - assertTrue(userOneModel.canAdmin); UserModel userOneModelFailedAuth = auth.authenticate("UserOne", "userTwoPassword".toCharArray(), null); assertNull(userOneModelFailedAuth); @@ -418,13 +588,98 @@ public void testAuthenticationManager() { assertNotNull(userTwoModel.getTeam("git_users")); assertNull(userTwoModel.getTeam("git_admins")); assertNotNull(userTwoModel.getTeam("git admins")); - assertTrue(userTwoModel.canAdmin); UserModel userThreeModel = auth.authenticate("UserThree", "userThreePassword".toCharArray(), null); assertNotNull(userThreeModel); assertNotNull(userThreeModel.getTeam("git_users")); assertNull(userThreeModel.getTeam("git_admins")); + + UserModel userFourModel = auth.authenticate("UserFour", "userFourPassword".toCharArray(), null); + assertNotNull(userFourModel); + assertNotNull(userFourModel.getTeam("git_users")); + assertNull(userFourModel.getTeam("git_admins")); + assertNull(userFourModel.getTeam("git admins")); + } + + @Test + public void testAuthenticationManagerAdminPropertyTeamsInLdap() { + UserModel userOneModel = auth.authenticate("UserOne", "userOnePassword".toCharArray(), null); + assertNotNull(userOneModel); + assertNotNull(userOneModel.getTeam("git_admins")); + assertNull(userOneModel.getTeam("git admins")); + assertNotNull(userOneModel.getTeam("git_users")); + assertFalse(userOneModel.canAdmin); + assertTrue(userOneModel.canAdmin()); + assertTrue(userOneModel.getTeam("git_admins").canAdmin); + assertFalse(userOneModel.getTeam("git_users").canAdmin); + + UserModel userOneModelFailedAuth = auth.authenticate("UserOne", "userTwoPassword".toCharArray(), null); + assertNull(userOneModelFailedAuth); + + UserModel userTwoModel = auth.authenticate("UserTwo", "userTwoPassword".toCharArray(), null); + assertNotNull(userTwoModel); + assertNotNull(userTwoModel.getTeam("git_users")); + assertNull(userTwoModel.getTeam("git_admins")); + assertNotNull(userTwoModel.getTeam("git admins")); + assertFalse(userTwoModel.canAdmin); + assertTrue(userTwoModel.canAdmin()); + assertTrue(userTwoModel.getTeam("git admins").canAdmin); + assertFalse(userTwoModel.getTeam("git_users").canAdmin); + + UserModel userThreeModel = auth.authenticate("UserThree", "userThreePassword".toCharArray(), null); + assertNotNull(userThreeModel); + assertNotNull(userThreeModel.getTeam("git_users")); + assertNull(userThreeModel.getTeam("git_admins")); + assertNull(userThreeModel.getTeam("git admins")); assertTrue(userThreeModel.canAdmin); + assertTrue(userThreeModel.canAdmin()); + assertFalse(userThreeModel.getTeam("git_users").canAdmin); + + UserModel userFourModel = auth.authenticate("UserFour", "userFourPassword".toCharArray(), null); + assertNotNull(userFourModel); + assertNotNull(userFourModel.getTeam("git_users")); + assertNull(userFourModel.getTeam("git_admins")); + assertNull(userFourModel.getTeam("git admins")); + assertFalse(userFourModel.canAdmin); + assertFalse(userFourModel.canAdmin()); + assertFalse(userFourModel.getTeam("git_users").canAdmin); + } + + @Test + public void testAuthenticationManagerAdminPropertyTeamsNotInLdap() { + settings.put(Keys.realm.ldap.maintainTeams, "false"); + + UserModel userOneModel = auth.authenticate("UserOne", "userOnePassword".toCharArray(), null); + assertNotNull(userOneModel); + assertNotNull(userOneModel.getTeam("git_admins")); + assertNull(userOneModel.getTeam("git admins")); + assertNotNull(userOneModel.getTeam("git_users")); + assertTrue(userOneModel.canAdmin); + assertTrue(userOneModel.canAdmin()); + assertFalse(userOneModel.getTeam("git_admins").canAdmin); + assertFalse(userOneModel.getTeam("git_users").canAdmin); + + UserModel userOneModelFailedAuth = auth.authenticate("UserOne", "userTwoPassword".toCharArray(), null); + assertNull(userOneModelFailedAuth); + + UserModel userTwoModel = auth.authenticate("UserTwo", "userTwoPassword".toCharArray(), null); + assertNotNull(userTwoModel); + assertNotNull(userTwoModel.getTeam("git_users")); + assertNull(userTwoModel.getTeam("git_admins")); + assertNotNull(userTwoModel.getTeam("git admins")); + assertFalse(userTwoModel.canAdmin); + assertTrue(userTwoModel.canAdmin()); + assertTrue(userTwoModel.getTeam("git admins").canAdmin); + assertFalse(userTwoModel.getTeam("git_users").canAdmin); + + UserModel userThreeModel = auth.authenticate("UserThree", "userThreePassword".toCharArray(), null); + assertNotNull(userThreeModel); + assertNotNull(userThreeModel.getTeam("git_users")); + assertNull(userThreeModel.getTeam("git_admins")); + assertNull(userThreeModel.getTeam("git admins")); + assertFalse(userThreeModel.canAdmin); + assertFalse(userThreeModel.canAdmin()); + assertFalse(userThreeModel.getTeam("git_users").canAdmin); UserModel userFourModel = auth.authenticate("UserFour", "userFourPassword".toCharArray(), null); assertNotNull(userFourModel); @@ -432,6 +687,8 @@ public void testAuthenticationManager() { assertNull(userFourModel.getTeam("git_admins")); assertNull(userFourModel.getTeam("git admins")); assertFalse(userFourModel.canAdmin); + assertFalse(userFourModel.canAdmin()); + assertFalse(userFourModel.getTeam("git_users").canAdmin); } @Test diff --git a/src/test/resources/ldap/users.conf b/src/test/resources/ldap/users.conf index 7d1e31979..a2390fa96 100644 --- a/src/test/resources/ldap/users.conf +++ b/src/test/resources/ldap/users.conf @@ -10,7 +10,7 @@ displayName = Mrs. User Three emailAddress = userthree@gitblit.com accountType = LDAP - role = "#admin" + role = "#none" [user "userfive"] password = "#externalAccount" cookie = 220bafef069b8b399b2597644015b6b0f4667982 @@ -31,7 +31,7 @@ displayName = Mr. User Two emailAddress = usertwo@gitblit.com accountType = LDAP - role = "#admin" + role = "#none" [user "basic"] password = MD5:f17aaabc20bfe045075927934fed52d2 cookie = dd94709528bb1c83d08f3088d4043f4742891f4f @@ -63,6 +63,6 @@ user = userthree user = userfour [team "Git Admins"] - role = "#none" + role = "#admin" accountType = LOCAL user = usertwo From 8d27912b0f7f0a67a929671a9c6ff3c8052e3497 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Wed, 23 Nov 2016 02:48:38 +0100 Subject: [PATCH 04/66] Create base unit test class for LDAP tests. Extract the creation of the in-memory servers and the interceptor code to a base class that LDAP related unit tests can extend to have the servers available. --- .../gitblit/tests/LdapAuthenticationTest.java | 339 +-------------- .../com/gitblit/tests/LdapBasedUnitTest.java | 409 ++++++++++++++++++ 2 files changed, 416 insertions(+), 332 deletions(-) create mode 100644 src/test/java/com/gitblit/tests/LdapBasedUnitTest.java diff --git a/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java b/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java index b7a77fc24..4f79edfb8 100644 --- a/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java +++ b/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java @@ -18,25 +18,10 @@ import static org.junit.Assume.*; -import java.io.File; -import java.util.Arrays; -import java.util.Collection; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.Map; - -import org.apache.commons.io.FileUtils; -import org.junit.AfterClass; import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameter; -import org.junit.runners.Parameterized.Parameters; - import com.gitblit.Constants.AccountType; import com.gitblit.IStoredSettings; import com.gitblit.Keys; @@ -50,26 +35,8 @@ import com.gitblit.tests.mock.MemorySettings; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; -import com.unboundid.ldap.listener.InMemoryDirectoryServer; -import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; -import com.unboundid.ldap.listener.InMemoryDirectoryServerSnapshot; -import com.unboundid.ldap.listener.InMemoryListenerConfig; -import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedRequest; -import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedResult; -import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchEntry; -import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchRequest; -import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult; -import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSimpleBindResult; -import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor; -import com.unboundid.ldap.sdk.BindRequest; -import com.unboundid.ldap.sdk.BindResult; -import com.unboundid.ldap.sdk.LDAPException; -import com.unboundid.ldap.sdk.LDAPResult; -import com.unboundid.ldap.sdk.OperationType; -import com.unboundid.ldap.sdk.ResultCode; import com.unboundid.ldap.sdk.SearchResult; import com.unboundid.ldap.sdk.SearchScope; -import com.unboundid.ldap.sdk.SimpleBindRequest; import com.unboundid.ldif.LDIFReader; /** @@ -80,68 +47,7 @@ * */ @RunWith(Parameterized.class) -public class LdapAuthenticationTest extends GitblitUnitTest { - - private static final String RESOURCE_DIR = "src/test/resources/ldap/"; - private static final String DIRECTORY_MANAGER = "cn=Directory Manager"; - private static final String USER_MANAGER = "cn=UserManager"; - private static final String ACCOUNT_BASE = "OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain"; - private static final String GROUP_BASE = "OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain"; - - - /** - * Enumeration of different test modes, representing different use scenarios. - * With ANONYMOUS anonymous binds are used to search LDAP. - * DS_MANAGER will use a DIRECTORY_MANAGER to search LDAP. Normal users are prohibited to search the DS. - * With USR_MANAGER, a USER_MANAGER account is used to search in LDAP. This account can only search users - * but not groups. Normal users can search groups, though. - * - */ - enum AuthMode { - ANONYMOUS(1389), - DS_MANAGER(2389), - USR_MANAGER(3389); - - - private int ldapPort; - private InMemoryDirectoryServer ds; - private InMemoryDirectoryServerSnapshot dsSnapshot; - - AuthMode(int port) { - this.ldapPort = port; - } - - int ldapPort() { - return this.ldapPort; - } - - void setDS(InMemoryDirectoryServer ds) { - if (this.ds == null) { - this.ds = ds; - this.dsSnapshot = ds.createSnapshot(); - }; - } - - InMemoryDirectoryServer getDS() { - return ds; - } - - void restoreSnapshot() { - ds.restoreSnapshot(dsSnapshot); - } - }; - - - - @Parameter - public AuthMode authMode; - - @Rule - public TemporaryFolder folder = new TemporaryFolder(); - - private File usersConf; - - +public class LdapAuthenticationTest extends LdapBasedUnitTest { private LdapAuthProvider ldap; @@ -149,87 +55,9 @@ void restoreSnapshot() { private AuthenticationManager auth; - private MemorySettings settings; - - - /** - * Run the tests with each authentication scenario once. - */ - @Parameters(name = "{0}") - public static Collection data() { - return Arrays.asList(new Object[][] { {AuthMode.ANONYMOUS}, {AuthMode.DS_MANAGER}, {AuthMode.USR_MANAGER} }); - } - - - - /** - * Create three different in memory DS. - * - * Each DS has a different configuration: - * The first allows anonymous binds. - * The second requires authentication for all operations. It will only allow the DIRECTORY_MANAGER account - * to search for users and groups. - * The third one is like the second, but it allows users to search for users and groups, and restricts the - * USER_MANAGER from searching for groups. - */ - @BeforeClass - public static void init() throws Exception { - InMemoryDirectoryServer ds; - InMemoryDirectoryServerConfig config = createInMemoryLdapServerConfig(AuthMode.ANONYMOUS); - config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.ANONYMOUS.ldapPort())); - ds = createInMemoryLdapServer(config); - AuthMode.ANONYMOUS.setDS(ds); - - - config = createInMemoryLdapServerConfig(AuthMode.DS_MANAGER); - config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.DS_MANAGER.ldapPort())); - config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class)); - ds = createInMemoryLdapServer(config); - AuthMode.DS_MANAGER.setDS(ds); - - - config = createInMemoryLdapServerConfig(AuthMode.USR_MANAGER); - config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.USR_MANAGER.ldapPort())); - config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class)); - ds = createInMemoryLdapServer(config); - AuthMode.USR_MANAGER.setDS(ds); - - } - - @AfterClass - public static void destroy() throws Exception { - for (AuthMode am : AuthMode.values()) { - am.getDS().shutDown(true); - } - } - - public static InMemoryDirectoryServer createInMemoryLdapServer(InMemoryDirectoryServerConfig config) throws Exception { - InMemoryDirectoryServer imds = new InMemoryDirectoryServer(config); - imds.importFromLDIF(true, RESOURCE_DIR + "sampledata.ldif"); - imds.startListening(); - return imds; - } - - public static InMemoryDirectoryServerConfig createInMemoryLdapServerConfig(AuthMode authMode) throws Exception { - InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=MyDomain"); - config.addAdditionalBindCredentials(DIRECTORY_MANAGER, "password"); - config.addAdditionalBindCredentials(USER_MANAGER, "passwd"); - config.setSchema(null); - - config.addInMemoryOperationInterceptor(new AccessInterceptor(authMode)); - - return config; - } - - @Before public void setup() throws Exception { - authMode.restoreSnapshot(); - - usersConf = folder.newFile("users.conf"); - FileUtils.copyFile(new File(RESOURCE_DIR + "users.conf"), usersConf); - settings = getSettings(); ldap = newLdapAuthentication(settings); auth = newAuthenticationManager(settings); } @@ -251,45 +79,6 @@ private AuthenticationManager newAuthenticationManager(IStoredSettings settings) return auth; } - private MemorySettings getSettings() { - Map backingMap = new HashMap(); - backingMap.put(Keys.realm.userService, usersConf.getAbsolutePath()); - switch(authMode) { - case ANONYMOUS: - backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); - backingMap.put(Keys.realm.ldap.username, ""); - backingMap.put(Keys.realm.ldap.password, ""); - break; - case DS_MANAGER: - backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); - backingMap.put(Keys.realm.ldap.username, DIRECTORY_MANAGER); - backingMap.put(Keys.realm.ldap.password, "password"); - break; - case USR_MANAGER: - backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); - backingMap.put(Keys.realm.ldap.username, USER_MANAGER); - backingMap.put(Keys.realm.ldap.password, "passwd"); - break; - default: - throw new RuntimeException("Unimplemented AuthMode case!"); - - } - backingMap.put(Keys.realm.ldap.maintainTeams, "true"); - backingMap.put(Keys.realm.ldap.accountBase, ACCOUNT_BASE); - backingMap.put(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))"); - backingMap.put(Keys.realm.ldap.groupBase, GROUP_BASE); - backingMap.put(Keys.realm.ldap.groupMemberPattern, "(&(objectClass=group)(member=${dn}))"); - backingMap.put(Keys.realm.ldap.admins, "UserThree @Git_Admins \"@Git Admins\""); - backingMap.put(Keys.realm.ldap.displayName, "displayName"); - backingMap.put(Keys.realm.ldap.email, "email"); - backingMap.put(Keys.realm.ldap.uid, "sAMAccountName"); - - MemorySettings ms = new MemorySettings(backingMap); - return ms; - } - - - @Test public void testAuthenticate() { UserModel userOneModel = ldap.authenticate("UserOne", "userOnePassword".toCharArray()); @@ -708,10 +497,12 @@ public void testBindWithUser() { } - private InMemoryDirectoryServer getDS() - { - return authMode.getDS(); - } + + + + + + private int countLdapUsersInUserManager() { int ldapAccountCount = 0; @@ -733,120 +524,4 @@ private int countLdapTeamsInUserManager() { return ldapAccountCount; } - - - - /** - * Operation interceptor for the in memory DS. This interceptor - * implements access restrictions for certain user/DN combinations. - * - * The USER_MANAGER is only allowed to search for users, but not for groups. - * This is to test the original behaviour where the teams were searched under - * the user binding. - * When running in a DIRECTORY_MANAGER scenario, only the manager account - * is allowed to search for users and groups, while a normal user may not do so. - * This tests the scenario where a normal user cannot read teams and thus the - * manager account needs to be used for all searches. - * - */ - private static class AccessInterceptor extends InMemoryOperationInterceptor { - AuthMode authMode; - Map lastSuccessfulBindDN = new HashMap<>(); - Map resultProhibited = new HashMap<>(); - - public AccessInterceptor(AuthMode authMode) { - this.authMode = authMode; - } - - - @Override - public void processSimpleBindResult(InMemoryInterceptedSimpleBindResult bind) { - BindResult result = bind.getResult(); - if (result.getResultCode() == ResultCode.SUCCESS) { - BindRequest bindRequest = bind.getRequest(); - lastSuccessfulBindDN.put(bind.getConnectionID(), ((SimpleBindRequest)bindRequest).getBindDN()); - resultProhibited.remove(bind.getConnectionID()); - } - } - - - - @Override - public void processSearchRequest(InMemoryInterceptedSearchRequest request) throws LDAPException { - String bindDN = getLastBindDN(request); - - if (USER_MANAGER.equals(bindDN)) { - if (request.getRequest().getBaseDN().endsWith(GROUP_BASE)) { - throw new LDAPException(ResultCode.NO_SUCH_OBJECT); - } - } - else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) { - throw new LDAPException(ResultCode.NO_SUCH_OBJECT); - } - } - - - @Override - public void processSearchEntry(InMemoryInterceptedSearchEntry entry) { - String bindDN = getLastBindDN(entry); - - boolean prohibited = false; - - if (USER_MANAGER.equals(bindDN)) { - if (entry.getSearchEntry().getDN().endsWith(GROUP_BASE)) { - prohibited = true; - } - } - else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) { - prohibited = true; - } - - if (prohibited) { - // Found entry prohibited for bound user. Setting entry to null. - entry.setSearchEntry(null); - resultProhibited.put(entry.getConnectionID(), Boolean.TRUE); - } - } - - @Override - public void processSearchResult(InMemoryInterceptedSearchResult result) { - String bindDN = getLastBindDN(result); - - boolean prohibited = false; - - Boolean rspb = resultProhibited.get(result.getConnectionID()); - if (USER_MANAGER.equals(bindDN)) { - if (rspb != null && rspb) { - prohibited = true; - } - } - else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) { - if (rspb != null && rspb) { - prohibited = true; - } - } - - if (prohibited) { - // Result prohibited for bound user. Returning error - result.setResult(new LDAPResult(result.getMessageID(), ResultCode.INSUFFICIENT_ACCESS_RIGHTS)); - resultProhibited.remove(result.getConnectionID()); - } - } - - private String getLastBindDN(InMemoryInterceptedResult result) { - String bindDN = lastSuccessfulBindDN.get(result.getConnectionID()); - if (bindDN == null) { - return "UNKNOWN"; - } - return bindDN; - } - private String getLastBindDN(InMemoryInterceptedRequest request) { - String bindDN = lastSuccessfulBindDN.get(request.getConnectionID()); - if (bindDN == null) { - return "UNKNOWN"; - } - return bindDN; - } - } - } diff --git a/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java b/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java new file mode 100644 index 000000000..cf3ab1fa0 --- /dev/null +++ b/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java @@ -0,0 +1,409 @@ +package com.gitblit.tests; + +import java.io.File; +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.io.FileUtils; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import com.gitblit.Keys; +import com.gitblit.tests.mock.MemorySettings; +import com.unboundid.ldap.listener.InMemoryDirectoryServer; +import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; +import com.unboundid.ldap.listener.InMemoryDirectoryServerSnapshot; +import com.unboundid.ldap.listener.InMemoryListenerConfig; +import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedRequest; +import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedResult; +import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchEntry; +import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchRequest; +import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult; +import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSimpleBindResult; +import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor; +import com.unboundid.ldap.sdk.BindRequest; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.LDAPResult; +import com.unboundid.ldap.sdk.OperationType; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SimpleBindRequest; + + + +/** + * Base class for Unit (/Integration) tests that test going against an + * in-memory UnboundID LDAP server. + * + * This base class creates separate in-memory LDAP servers for different scenarios: + * - ANONYMOUS: anonymous bind to LDAP. + * - DS_MANAGER: The DIRECTORY_MANAGER is set as DN to bind as an admin. + * Normal users are prohibited to search the DS, they can only bind. + * - USR_MANAGER: The USER_MANAGER is set as DN to bind as an admin. + * This account can only search users but not groups. Normal users can search groups. + * + * @author Florian Zschocke + * + */ +public abstract class LdapBasedUnitTest extends GitblitUnitTest { + + protected static final String RESOURCE_DIR = "src/test/resources/ldap/"; + private static final String DIRECTORY_MANAGER = "cn=Directory Manager"; + private static final String USER_MANAGER = "cn=UserManager"; + protected static final String ACCOUNT_BASE = "OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain"; + private static final String GROUP_BASE = "OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain"; + protected static final String DN_USER_ONE = "CN=UserOne,OU=US," + ACCOUNT_BASE; + protected static final String DN_USER_TWO = "CN=UserTwo,OU=US," + ACCOUNT_BASE; + protected static final String DN_USER_THREE = "CN=UserThree,OU=Canada," + ACCOUNT_BASE; + + + /** + * Enumeration of different test modes, representing different use scenarios. + * With ANONYMOUS anonymous binds are used to search LDAP. + * DS_MANAGER will use a DIRECTORY_MANAGER to search LDAP. Normal users are prohibited to search the DS. + * With USR_MANAGER, a USER_MANAGER account is used to search in LDAP. This account can only search users + * but not groups. Normal users can search groups, though. + * + */ + protected enum AuthMode { + ANONYMOUS(1389), + DS_MANAGER(2389), + USR_MANAGER(3389); + + + private int ldapPort; + private InMemoryDirectoryServer ds; + private InMemoryDirectoryServerSnapshot dsSnapshot; + private BindTracker bindTracker; + + AuthMode(int port) { + this.ldapPort = port; + } + + int ldapPort() { + return this.ldapPort; + } + + void setDS(InMemoryDirectoryServer ds) { + if (this.ds == null) { + this.ds = ds; + this.dsSnapshot = ds.createSnapshot(); + }; + } + + InMemoryDirectoryServer getDS() { + return ds; + } + + void setBindTracker(BindTracker bindTracker) { + this.bindTracker = bindTracker; + } + + BindTracker getBindTracker() { + return bindTracker; + } + + void restoreSnapshot() { + ds.restoreSnapshot(dsSnapshot); + } + } + + @Parameter + public AuthMode authMode = AuthMode.ANONYMOUS; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + + protected File usersConf; + + protected MemorySettings settings; + + + /** + * Run the tests with each authentication scenario once. + */ + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][] { {AuthMode.ANONYMOUS}, {AuthMode.DS_MANAGER}, {AuthMode.USR_MANAGER} }); + } + + + /** + * Create three different in memory DS. + * + * Each DS has a different configuration: + * The first allows anonymous binds. + * The second requires authentication for all operations. It will only allow the DIRECTORY_MANAGER account + * to search for users and groups. + * The third one is like the second, but it allows users to search for users and groups, and restricts the + * USER_MANAGER from searching for groups. + */ + @BeforeClass + public static void ldapInit() throws Exception { + InMemoryDirectoryServer ds; + InMemoryDirectoryServerConfig config = createInMemoryLdapServerConfig(AuthMode.ANONYMOUS); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.ANONYMOUS.ldapPort())); + ds = createInMemoryLdapServer(config); + AuthMode.ANONYMOUS.setDS(ds); + + + config = createInMemoryLdapServerConfig(AuthMode.DS_MANAGER); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.DS_MANAGER.ldapPort())); + config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class)); + ds = createInMemoryLdapServer(config); + AuthMode.DS_MANAGER.setDS(ds); + + + config = createInMemoryLdapServerConfig(AuthMode.USR_MANAGER); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.USR_MANAGER.ldapPort())); + config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class)); + ds = createInMemoryLdapServer(config); + AuthMode.USR_MANAGER.setDS(ds); + + } + + @AfterClass + public static void destroy() throws Exception { + for (AuthMode am : AuthMode.values()) { + am.getDS().shutDown(true); + } + } + + public static InMemoryDirectoryServer createInMemoryLdapServer(InMemoryDirectoryServerConfig config) throws Exception { + InMemoryDirectoryServer imds = new InMemoryDirectoryServer(config); + imds.importFromLDIF(true, RESOURCE_DIR + "sampledata.ldif"); + imds.startListening(); + return imds; + } + + public static InMemoryDirectoryServerConfig createInMemoryLdapServerConfig(AuthMode authMode) throws Exception { + InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=MyDomain"); + config.addAdditionalBindCredentials(DIRECTORY_MANAGER, "password"); + config.addAdditionalBindCredentials(USER_MANAGER, "passwd"); + config.setSchema(null); + + authMode.setBindTracker(new BindTracker()); + config.addInMemoryOperationInterceptor(authMode.getBindTracker()); + config.addInMemoryOperationInterceptor(new AccessInterceptor(authMode)); + + return config; + } + + + + @Before + public void setupBase() throws Exception { + authMode.restoreSnapshot(); + authMode.getBindTracker().reset(); + + usersConf = folder.newFile("users.conf"); + FileUtils.copyFile(new File(RESOURCE_DIR + "users.conf"), usersConf); + settings = getSettings(); + } + + + protected InMemoryDirectoryServer getDS() { + return authMode.getDS(); + } + + + + protected MemorySettings getSettings() { + Map backingMap = new HashMap(); + backingMap.put(Keys.realm.userService, usersConf.getAbsolutePath()); + switch(authMode) { + case ANONYMOUS: + backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); + backingMap.put(Keys.realm.ldap.username, ""); + backingMap.put(Keys.realm.ldap.password, ""); + break; + case DS_MANAGER: + backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); + backingMap.put(Keys.realm.ldap.username, DIRECTORY_MANAGER); + backingMap.put(Keys.realm.ldap.password, "password"); + break; + case USR_MANAGER: + backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); + backingMap.put(Keys.realm.ldap.username, USER_MANAGER); + backingMap.put(Keys.realm.ldap.password, "passwd"); + break; + default: + throw new RuntimeException("Unimplemented AuthMode case!"); + + } + backingMap.put(Keys.realm.ldap.maintainTeams, "true"); + backingMap.put(Keys.realm.ldap.accountBase, ACCOUNT_BASE); + backingMap.put(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))"); + backingMap.put(Keys.realm.ldap.groupBase, GROUP_BASE); + backingMap.put(Keys.realm.ldap.groupMemberPattern, "(&(objectClass=group)(member=${dn}))"); + backingMap.put(Keys.realm.ldap.admins, "UserThree @Git_Admins \"@Git Admins\""); + backingMap.put(Keys.realm.ldap.displayName, "displayName"); + backingMap.put(Keys.realm.ldap.email, "email"); + backingMap.put(Keys.realm.ldap.uid, "sAMAccountName"); + + MemorySettings ms = new MemorySettings(backingMap); + return ms; + } + + + + + /** + * Operation interceptor for the in memory DS. This interceptor + * tracks bind requests. + * + */ + protected static class BindTracker extends InMemoryOperationInterceptor { + private Map lastSuccessfulBindDNs = new HashMap<>(); + private String lastSuccessfulBindDN; + + + @Override + public void processSimpleBindResult(InMemoryInterceptedSimpleBindResult bind) { + BindResult result = bind.getResult(); + if (result.getResultCode() == ResultCode.SUCCESS) { + BindRequest bindRequest = bind.getRequest(); + lastSuccessfulBindDNs.put(bind.getMessageID(), ((SimpleBindRequest)bindRequest).getBindDN()); + lastSuccessfulBindDN = ((SimpleBindRequest)bindRequest).getBindDN(); + } + } + + String getLastSuccessfulBindDN() { + return lastSuccessfulBindDN; + } + + String getLastSuccessfulBindDN(int messageID) { + return lastSuccessfulBindDNs.get(messageID); + } + + void reset() { + lastSuccessfulBindDNs = new HashMap<>(); + lastSuccessfulBindDN = null; + } + } + + + + /** + * Operation interceptor for the in memory DS. This interceptor + * implements access restrictions for certain user/DN combinations. + * + * The USER_MANAGER is only allowed to search for users, but not for groups. + * This is to test the original behaviour where the teams were searched under + * the user binding. + * When running in a DIRECTORY_MANAGER scenario, only the manager account + * is allowed to search for users and groups, while a normal user may not do so. + * This tests the scenario where a normal user cannot read teams and thus the + * manager account needs to be used for all searches. + * + */ + protected static class AccessInterceptor extends InMemoryOperationInterceptor { + AuthMode authMode; + Map lastSuccessfulBindDN = new HashMap<>(); + Map resultProhibited = new HashMap<>(); + + public AccessInterceptor(AuthMode authMode) { + this.authMode = authMode; + } + + + @Override + public void processSimpleBindResult(InMemoryInterceptedSimpleBindResult bind) { + BindResult result = bind.getResult(); + if (result.getResultCode() == ResultCode.SUCCESS) { + BindRequest bindRequest = bind.getRequest(); + lastSuccessfulBindDN.put(bind.getConnectionID(), ((SimpleBindRequest)bindRequest).getBindDN()); + resultProhibited.remove(bind.getConnectionID()); + } + } + + + + @Override + public void processSearchRequest(InMemoryInterceptedSearchRequest request) throws LDAPException { + String bindDN = getLastBindDN(request); + + if (USER_MANAGER.equals(bindDN)) { + if (request.getRequest().getBaseDN().endsWith(GROUP_BASE)) { + throw new LDAPException(ResultCode.NO_SUCH_OBJECT); + } + } + else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) { + throw new LDAPException(ResultCode.NO_SUCH_OBJECT); + } + } + + + @Override + public void processSearchEntry(InMemoryInterceptedSearchEntry entry) { + String bindDN = getLastBindDN(entry); + + boolean prohibited = false; + + if (USER_MANAGER.equals(bindDN)) { + if (entry.getSearchEntry().getDN().endsWith(GROUP_BASE)) { + prohibited = true; + } + } + else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) { + prohibited = true; + } + + if (prohibited) { + // Found entry prohibited for bound user. Setting entry to null. + entry.setSearchEntry(null); + resultProhibited.put(entry.getConnectionID(), Boolean.TRUE); + } + } + + @Override + public void processSearchResult(InMemoryInterceptedSearchResult result) { + String bindDN = getLastBindDN(result); + + boolean prohibited = false; + + Boolean rspb = resultProhibited.get(result.getConnectionID()); + if (USER_MANAGER.equals(bindDN)) { + if (rspb != null && rspb) { + prohibited = true; + } + } + else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) { + if (rspb != null && rspb) { + prohibited = true; + } + } + + if (prohibited) { + // Result prohibited for bound user. Returning error + result.setResult(new LDAPResult(result.getMessageID(), ResultCode.INSUFFICIENT_ACCESS_RIGHTS)); + resultProhibited.remove(result.getConnectionID()); + } + } + + private String getLastBindDN(InMemoryInterceptedResult result) { + String bindDN = lastSuccessfulBindDN.get(result.getConnectionID()); + if (bindDN == null) { + return "UNKNOWN"; + } + return bindDN; + } + private String getLastBindDN(InMemoryInterceptedRequest request) { + String bindDN = lastSuccessfulBindDN.get(request.getConnectionID()); + if (bindDN == null) { + return "UNKNOWN"; + } + return bindDN; + } + } + +} From 967c2422591b70a82bd8fc991e87088e880f5024 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Wed, 23 Nov 2016 02:59:39 +0100 Subject: [PATCH 05/66] Extract LdapConnection into new class from LdapAuthProvider Extract the inner class `LdapConnection` from the `LdapAuthProvider` into a separate class, so that it can be used from multiple classes that have to connect to an LDAP directory. The new class is placed into the new package `com.gitblit.ldap`, since it isn't specific to authentication. --- .../com/gitblit/auth/LdapAuthProvider.java | 275 +---------------- .../java/com/gitblit/ldap/LdapConnection.java | 288 ++++++++++++++++++ .../com/gitblit/tests/LdapConnectionTest.java | 248 +++++++++++++++ 3 files changed, 543 insertions(+), 268 deletions(-) create mode 100644 src/main/java/com/gitblit/ldap/LdapConnection.java create mode 100644 src/test/java/com/gitblit/tests/LdapConnectionTest.java diff --git a/src/main/java/com/gitblit/auth/LdapAuthProvider.java b/src/main/java/com/gitblit/auth/LdapAuthProvider.java index 19fd46325..8a326cdc4 100644 --- a/src/main/java/com/gitblit/auth/LdapAuthProvider.java +++ b/src/main/java/com/gitblit/auth/LdapAuthProvider.java @@ -16,9 +16,6 @@ */ package com.gitblit.auth; -import java.net.URI; -import java.net.URISyntaxException; -import java.security.GeneralSecurityException; import java.text.MessageFormat; import java.util.Arrays; import java.util.HashMap; @@ -33,28 +30,20 @@ import com.gitblit.Constants.Role; import com.gitblit.Keys; import com.gitblit.auth.AuthenticationProvider.UsernamePasswordAuthenticationProvider; +import com.gitblit.ldap.LdapConnection; import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; import com.gitblit.service.LdapSyncService; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.StringUtils; import com.unboundid.ldap.sdk.Attribute; -import com.unboundid.ldap.sdk.BindRequest; import com.unboundid.ldap.sdk.BindResult; -import com.unboundid.ldap.sdk.DereferencePolicy; -import com.unboundid.ldap.sdk.ExtendedResult; -import com.unboundid.ldap.sdk.LDAPConnection; import com.unboundid.ldap.sdk.LDAPException; -import com.unboundid.ldap.sdk.LDAPSearchException; import com.unboundid.ldap.sdk.ResultCode; import com.unboundid.ldap.sdk.SearchRequest; import com.unboundid.ldap.sdk.SearchResult; import com.unboundid.ldap.sdk.SearchResultEntry; import com.unboundid.ldap.sdk.SearchScope; -import com.unboundid.ldap.sdk.SimpleBindRequest; -import com.unboundid.ldap.sdk.extensions.StartTLSExtendedRequest; -import com.unboundid.util.ssl.SSLUtil; -import com.unboundid.util.ssl.TrustAllTrustManager; /** * Implementation of an LDAP user service. @@ -109,7 +98,7 @@ public synchronized void sync() { if (enabled) { logger.info("Synchronizing with LDAP @ " + settings.getRequiredString(Keys.realm.ldap.server)); final boolean deleteRemovedLdapUsers = settings.getBoolean(Keys.realm.ldap.removeDeletedUsers, true); - LdapConnection ldapConnection = new LdapConnection(); + LdapConnection ldapConnection = new LdapConnection(settings); if (ldapConnection.connect()) { if (ldapConnection.bind() == null) { ldapConnection.close(); @@ -265,7 +254,7 @@ public AccountType getAccountType() { public UserModel authenticate(String username, char[] password) { String simpleUsername = getSimpleUsername(username); - LdapConnection ldapConnection = new LdapConnection(); + LdapConnection ldapConnection = new LdapConnection(settings); if (ldapConnection.connect()) { // Try to bind either to the "manager" account, @@ -288,7 +277,7 @@ public UserModel authenticate(String username, char[] password) { // Find the logging in user's DN String accountBase = settings.getString(Keys.realm.ldap.accountBase, ""); String accountPattern = settings.getString(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))"); - accountPattern = StringUtils.replace(accountPattern, "${username}", escapeLDAPSearchFilter(simpleUsername)); + accountPattern = StringUtils.replace(accountPattern, "${username}", LdapConnection.escapeLDAPSearchFilter(simpleUsername)); SearchResult result = doSearch(ldapConnection, accountBase, accountPattern); if (result != null && result.getEntryCount() == 1) { @@ -441,12 +430,12 @@ private void getTeamsFromLdap(LdapConnection ldapConnection, String simpleUserna String groupBase = settings.getString(Keys.realm.ldap.groupBase, ""); String groupMemberPattern = settings.getString(Keys.realm.ldap.groupMemberPattern, "(&(objectClass=group)(member=${dn}))"); - groupMemberPattern = StringUtils.replace(groupMemberPattern, "${dn}", escapeLDAPSearchFilter(loggingInUserDN)); - groupMemberPattern = StringUtils.replace(groupMemberPattern, "${username}", escapeLDAPSearchFilter(simpleUsername)); + groupMemberPattern = StringUtils.replace(groupMemberPattern, "${dn}", LdapConnection.escapeLDAPSearchFilter(loggingInUserDN)); + groupMemberPattern = StringUtils.replace(groupMemberPattern, "${username}", LdapConnection.escapeLDAPSearchFilter(simpleUsername)); // Fill in attributes into groupMemberPattern for (Attribute userAttribute : loggingInUser.getAttributes()) { - groupMemberPattern = StringUtils.replace(groupMemberPattern, "${" + userAttribute.getName() + "}", escapeLDAPSearchFilter(userAttribute.getValue())); + groupMemberPattern = StringUtils.replace(groupMemberPattern, "${" + userAttribute.getName() + "}", LdapConnection.escapeLDAPSearchFilter(userAttribute.getValue())); } SearchResult teamMembershipResult = searchTeamsInLdap(ldapConnection, groupBase, true, groupMemberPattern, Arrays.asList("cn")); @@ -553,34 +542,6 @@ protected String getSimpleUsername(String username) { return username; } - // From: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java - private static final String escapeLDAPSearchFilter(String filter) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < filter.length(); i++) { - char curChar = filter.charAt(i); - switch (curChar) { - case '\\': - sb.append("\\5c"); - break; - case '*': - sb.append("\\2a"); - break; - case '(': - sb.append("\\28"); - break; - case ')': - sb.append("\\29"); - break; - case '\u0000': - sb.append("\\00"); - break; - default: - sb.append(curChar); - } - } - return sb.toString(); - } - private void configureSyncService() { LdapSyncService ldapSyncService = new LdapSyncService(settings, this); if (ldapSyncService.isReady()) { @@ -593,226 +554,4 @@ private void configureSyncService() { logger.info("Ldap sync service is disabled."); } } - - - - private class LdapConnection { - private LDAPConnection conn; - private SimpleBindRequest currentBindRequest; - private SimpleBindRequest managerBindRequest; - private SimpleBindRequest userBindRequest; - - - public LdapConnection() { - String bindUserName = settings.getString(Keys.realm.ldap.username, ""); - String bindPassword = settings.getString(Keys.realm.ldap.password, ""); - if (StringUtils.isEmpty(bindUserName) && StringUtils.isEmpty(bindPassword)) { - this.managerBindRequest = new SimpleBindRequest(); - } - this.managerBindRequest = new SimpleBindRequest(bindUserName, bindPassword); - } - - - boolean connect() { - try { - URI ldapUrl = new URI(settings.getRequiredString(Keys.realm.ldap.server)); - String ldapHost = ldapUrl.getHost(); - int ldapPort = ldapUrl.getPort(); - - if (ldapUrl.getScheme().equalsIgnoreCase("ldaps")) { - // SSL - SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager()); - conn = new LDAPConnection(sslUtil.createSSLSocketFactory()); - if (ldapPort == -1) { - ldapPort = 636; - } - } else if (ldapUrl.getScheme().equalsIgnoreCase("ldap") || ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) { - // no encryption or StartTLS - conn = new LDAPConnection(); - if (ldapPort == -1) { - ldapPort = 389; - } - } else { - logger.error("Unsupported LDAP URL scheme: " + ldapUrl.getScheme()); - return false; - } - - conn.connect(ldapHost, ldapPort); - - if (ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) { - SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager()); - ExtendedResult extendedResult = conn.processExtendedOperation( - new StartTLSExtendedRequest(sslUtil.createSSLContext())); - if (extendedResult.getResultCode() != ResultCode.SUCCESS) { - throw new LDAPException(extendedResult.getResultCode()); - } - } - - return true; - - } catch (URISyntaxException e) { - logger.error("Bad LDAP URL, should be in the form: ldap(s|+tls)://:", e); - } catch (GeneralSecurityException e) { - logger.error("Unable to create SSL Connection", e); - } catch (LDAPException e) { - logger.error("Error Connecting to LDAP", e); - } - - return false; - } - - - void close() { - if (conn != null) { - conn.close(); - } - } - - - SearchResult search(SearchRequest request) { - try { - return conn.search(request); - } catch (LDAPSearchException e) { - logger.error("Problem Searching LDAP [{}]", e.getResultCode()); - return e.getSearchResult(); - } - } - - - SearchResult search(String base, boolean dereferenceAliases, String filter, List attributes) { - try { - SearchRequest searchRequest = new SearchRequest(base, SearchScope.SUB, filter); - if (dereferenceAliases) { - searchRequest.setDerefPolicy(DereferencePolicy.SEARCHING); - } - if (attributes != null) { - searchRequest.setAttributes(attributes); - } - SearchResult result = search(searchRequest); - return result; - - } catch (LDAPException e) { - logger.error("Problem creating LDAP search", e); - return null; - } - } - - - - /** - * Bind using the manager credentials set in realm.ldap.username and ..password - * @return A bind result, or null if binding failed. - */ - BindResult bind() { - BindResult result = null; - try { - result = conn.bind(managerBindRequest); - currentBindRequest = managerBindRequest; - } catch (LDAPException e) { - logger.error("Error authenticating to LDAP with manager account to search the directory."); - logger.error(" Please check your settings for realm.ldap.username and realm.ldap.password."); - logger.debug(" Received exception when binding to LDAP", e); - return null; - } - return result; - } - - - /** - * Bind using the given credentials, by filling in the username in the given {@code bindPattern} to - * create the DN. - * @return A bind result, or null if binding failed. - */ - BindResult bind(String bindPattern, String simpleUsername, String password) { - BindResult result = null; - try { - String bindUser = StringUtils.replace(bindPattern, "${username}", escapeLDAPSearchFilter(simpleUsername)); - SimpleBindRequest request = new SimpleBindRequest(bindUser, password); - result = conn.bind(request); - userBindRequest = request; - currentBindRequest = userBindRequest; - } catch (LDAPException e) { - logger.error("Error authenticating to LDAP with user account to search the directory."); - logger.error(" Please check your settings for realm.ldap.bindpattern."); - logger.debug(" Received exception when binding to LDAP", e); - return null; - } - return result; - } - - - boolean rebindAsUser() { - if (userBindRequest == null || currentBindRequest == userBindRequest) { - return false; - } - try { - conn.bind(userBindRequest); - currentBindRequest = userBindRequest; - } catch (LDAPException e) { - conn.close(); - logger.error("Error rebinding to LDAP with user account.", e); - return false; - } - return true; - } - - - boolean isAuthenticated(String userDn, String password) { - verifyCurrentBinding(); - - // If the currently bound DN is already the DN of the logging in user, authentication has already happened - // during the previous bind operation. We accept this and return with the current bind left in place. - // This could also be changed to always retry binding as the logging in user, to make sure that the - // connection binding has not been tampered with in between. So far I see no way how this could happen - // and thus skip the repeated binding. - // This check also makes sure that the DN in realm.ldap.bindpattern actually matches the DN that was found - // when searching the user entry. - String boundDN = currentBindRequest.getBindDN(); - if (boundDN != null && boundDN.equals(userDn)) { - return true; - } - - // Bind a the logging in user to check for authentication. - // Afterwards, bind as the original bound DN again, to restore the previous authorization. - boolean isAuthenticated = false; - try { - // Binding will stop any LDAP-Injection Attacks since the searched-for user needs to bind to that DN - SimpleBindRequest ubr = new SimpleBindRequest(userDn, password); - conn.bind(ubr); - isAuthenticated = true; - userBindRequest = ubr; - } catch (LDAPException e) { - logger.error("Error authenticating user ({})", userDn, e); - } - - try { - conn.bind(currentBindRequest); - } catch (LDAPException e) { - logger.error("Error reinstating original LDAP authorization (code {}). Team information may be inaccurate for this log in.", - e.getResultCode(), e); - } - return isAuthenticated; - } - - - - private boolean verifyCurrentBinding() { - BindRequest lastBind = conn.getLastBindRequest(); - if (lastBind == currentBindRequest) { - return true; - } - logger.debug("Unexpected binding in LdapConnection. {} != {}", lastBind, currentBindRequest); - - String lastBoundDN = ((SimpleBindRequest)lastBind).getBindDN(); - String boundDN = currentBindRequest.getBindDN(); - logger.debug("Currently bound as '{}', check authentication for '{}'", lastBoundDN, boundDN); - if (boundDN != null && ! boundDN.equals(lastBoundDN)) { - logger.warn("Unexpected binding DN in LdapConnection. '{}' != '{}'.", lastBoundDN, boundDN); - logger.warn("Updated binding information in LDAP connection."); - currentBindRequest = (SimpleBindRequest)lastBind; - return false; - } - return true; - } - } } diff --git a/src/main/java/com/gitblit/ldap/LdapConnection.java b/src/main/java/com/gitblit/ldap/LdapConnection.java new file mode 100644 index 000000000..b7f07a1e6 --- /dev/null +++ b/src/main/java/com/gitblit/ldap/LdapConnection.java @@ -0,0 +1,288 @@ +package com.gitblit.ldap; + +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.utils.StringUtils; +import com.unboundid.ldap.sdk.BindRequest; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.DereferencePolicy; +import com.unboundid.ldap.sdk.ExtendedResult; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.LDAPSearchException; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchRequest; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchScope; +import com.unboundid.ldap.sdk.SimpleBindRequest; +import com.unboundid.ldap.sdk.extensions.StartTLSExtendedRequest; +import com.unboundid.util.ssl.SSLUtil; +import com.unboundid.util.ssl.TrustAllTrustManager; + +public class LdapConnection implements AutoCloseable { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private IStoredSettings settings; + + private LDAPConnection conn; + private SimpleBindRequest currentBindRequest; + private SimpleBindRequest managerBindRequest; + private SimpleBindRequest userBindRequest; + + + // From: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java + public static final String escapeLDAPSearchFilter(String filter) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < filter.length(); i++) { + char curChar = filter.charAt(i); + switch (curChar) { + case '\\': + sb.append("\\5c"); + break; + case '*': + sb.append("\\2a"); + break; + case '(': + sb.append("\\28"); + break; + case ')': + sb.append("\\29"); + break; + case '\u0000': + sb.append("\\00"); + break; + default: + sb.append(curChar); + } + } + return sb.toString(); + } + + + + public LdapConnection(IStoredSettings settings) { + this.settings = settings; + + String bindUserName = settings.getString(Keys.realm.ldap.username, ""); + String bindPassword = settings.getString(Keys.realm.ldap.password, ""); + if (StringUtils.isEmpty(bindUserName) && StringUtils.isEmpty(bindPassword)) { + this.managerBindRequest = new SimpleBindRequest(); + } + this.managerBindRequest = new SimpleBindRequest(bindUserName, bindPassword); + } + + + + public boolean connect() { + try { + URI ldapUrl = new URI(settings.getRequiredString(Keys.realm.ldap.server)); + String ldapHost = ldapUrl.getHost(); + int ldapPort = ldapUrl.getPort(); + + if (ldapUrl.getScheme().equalsIgnoreCase("ldaps")) { + // SSL + SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager()); + conn = new LDAPConnection(sslUtil.createSSLSocketFactory()); + if (ldapPort == -1) { + ldapPort = 636; + } + } else if (ldapUrl.getScheme().equalsIgnoreCase("ldap") || ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) { + // no encryption or StartTLS + conn = new LDAPConnection(); + if (ldapPort == -1) { + ldapPort = 389; + } + } else { + logger.error("Unsupported LDAP URL scheme: " + ldapUrl.getScheme()); + return false; + } + + conn.connect(ldapHost, ldapPort); + + if (ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) { + SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager()); + ExtendedResult extendedResult = conn.processExtendedOperation( + new StartTLSExtendedRequest(sslUtil.createSSLContext())); + if (extendedResult.getResultCode() != ResultCode.SUCCESS) { + throw new LDAPException(extendedResult.getResultCode()); + } + } + + return true; + + } catch (URISyntaxException e) { + logger.error("Bad LDAP URL, should be in the form: ldap(s|+tls)://:", e); + } catch (GeneralSecurityException e) { + logger.error("Unable to create SSL Connection", e); + } catch (LDAPException e) { + logger.error("Error Connecting to LDAP", e); + } + + return false; + } + + + public void close() { + if (conn != null) { + conn.close(); + } + } + + + + /** + * Bind using the manager credentials set in realm.ldap.username and ..password + * @return A bind result, or null if binding failed. + */ + public BindResult bind() { + BindResult result = null; + try { + result = conn.bind(managerBindRequest); + currentBindRequest = managerBindRequest; + } catch (LDAPException e) { + logger.error("Error authenticating to LDAP with manager account to search the directory."); + logger.error(" Please check your settings for realm.ldap.username and realm.ldap.password."); + logger.debug(" Received exception when binding to LDAP", e); + return null; + } + return result; + } + + + /** + * Bind using the given credentials, by filling in the username in the given {@code bindPattern} to + * create the DN. + * @return A bind result, or null if binding failed. + */ + public BindResult bind(String bindPattern, String simpleUsername, String password) { + BindResult result = null; + try { + String bindUser = StringUtils.replace(bindPattern, "${username}", escapeLDAPSearchFilter(simpleUsername)); + SimpleBindRequest request = new SimpleBindRequest(bindUser, password); + result = conn.bind(request); + userBindRequest = request; + currentBindRequest = userBindRequest; + } catch (LDAPException e) { + logger.error("Error authenticating to LDAP with user account to search the directory."); + logger.error(" Please check your settings for realm.ldap.bindpattern."); + logger.debug(" Received exception when binding to LDAP", e); + return null; + } + return result; + } + + + public boolean rebindAsUser() { + if (userBindRequest == null || currentBindRequest == userBindRequest) { + return false; + } + try { + conn.bind(userBindRequest); + currentBindRequest = userBindRequest; + } catch (LDAPException e) { + conn.close(); + logger.error("Error rebinding to LDAP with user account.", e); + return false; + } + return true; + } + + + + public SearchResult search(SearchRequest request) { + try { + return conn.search(request); + } catch (LDAPSearchException e) { + logger.error("Problem Searching LDAP [{}]", e.getResultCode()); + return e.getSearchResult(); + } + } + + + public SearchResult search(String base, boolean dereferenceAliases, String filter, List attributes) { + try { + SearchRequest searchRequest = new SearchRequest(base, SearchScope.SUB, filter); + if (dereferenceAliases) { + searchRequest.setDerefPolicy(DereferencePolicy.SEARCHING); + } + if (attributes != null) { + searchRequest.setAttributes(attributes); + } + SearchResult result = search(searchRequest); + return result; + + } catch (LDAPException e) { + logger.error("Problem creating LDAP search", e); + return null; + } + } + + + + public boolean isAuthenticated(String userDn, String password) { + verifyCurrentBinding(); + + // If the currently bound DN is already the DN of the logging in user, authentication has already happened + // during the previous bind operation. We accept this and return with the current bind left in place. + // This could also be changed to always retry binding as the logging in user, to make sure that the + // connection binding has not been tampered with in between. So far I see no way how this could happen + // and thus skip the repeated binding. + // This check also makes sure that the DN in realm.ldap.bindpattern actually matches the DN that was found + // when searching the user entry. + String boundDN = currentBindRequest.getBindDN(); + if (boundDN != null && boundDN.equals(userDn)) { + return true; + } + + // Bind a the logging in user to check for authentication. + // Afterwards, bind as the original bound DN again, to restore the previous authorization. + boolean isAuthenticated = false; + try { + // Binding will stop any LDAP-Injection Attacks since the searched-for user needs to bind to that DN + SimpleBindRequest ubr = new SimpleBindRequest(userDn, password); + conn.bind(ubr); + isAuthenticated = true; + userBindRequest = ubr; + } catch (LDAPException e) { + logger.error("Error authenticating user ({})", userDn, e); + } + + try { + conn.bind(currentBindRequest); + } catch (LDAPException e) { + logger.error("Error reinstating original LDAP authorization (code {}). Team information may be inaccurate for this log in.", + e.getResultCode(), e); + } + return isAuthenticated; + } + + + + private boolean verifyCurrentBinding() { + BindRequest lastBind = conn.getLastBindRequest(); + if (lastBind == currentBindRequest) { + return true; + } + logger.debug("Unexpected binding in LdapConnection. {} != {}", lastBind, currentBindRequest); + + String lastBoundDN = ((SimpleBindRequest)lastBind).getBindDN(); + String boundDN = currentBindRequest.getBindDN(); + logger.debug("Currently bound as '{}', check authentication for '{}'", lastBoundDN, boundDN); + if (boundDN != null && ! boundDN.equals(lastBoundDN)) { + logger.warn("Unexpected binding DN in LdapConnection. '{}' != '{}'.", lastBoundDN, boundDN); + logger.warn("Updated binding information in LDAP connection."); + currentBindRequest = (SimpleBindRequest)lastBind; + return false; + } + return true; + } +} diff --git a/src/test/java/com/gitblit/tests/LdapConnectionTest.java b/src/test/java/com/gitblit/tests/LdapConnectionTest.java new file mode 100644 index 000000000..f8d2fed0d --- /dev/null +++ b/src/test/java/com/gitblit/tests/LdapConnectionTest.java @@ -0,0 +1,248 @@ +package com.gitblit.tests; + +import static org.junit.Assume.assumeTrue; + +import java.util.ArrayList; +import java.util.Arrays; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import com.gitblit.Keys; +import com.gitblit.ldap.LdapConnection; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchRequest; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchResultEntry; +import com.unboundid.ldap.sdk.SearchScope; + +/* + * Test for the LdapConnection + * + * @author Florian Zschocke + * + */ +@RunWith(Parameterized.class) +public class LdapConnectionTest extends LdapBasedUnitTest { + + @Test + public void testEscapeLDAPFilterString() { + // This test is independent from authentication mode, so run only once. + assumeTrue(authMode == AuthMode.ANONYMOUS); + + // From: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java + assertEquals("No special characters to escape", "Hi This is a test #§ ", LdapConnection.escapeLDAPSearchFilter("Hi This is a test #§ ")); + assertEquals("LDAP Christams Tree", "Hi \\28This\\29 = is \\2a a \\5c test # §   ´", LdapConnection.escapeLDAPSearchFilter("Hi (This) = is * a \\ test # §   ´")); + + assertEquals("Injection", "\\2a\\29\\28userPassword=secret", LdapConnection.escapeLDAPSearchFilter("*)(userPassword=secret")); + } + + + @Test + public void testConnect() { + // This test is independent from authentication mode, so run only once. + assumeTrue(authMode == AuthMode.ANONYMOUS); + + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + } finally { + conn.close(); + } + } + + + @Test + public void testBindAnonymous() { + // This test tests for anonymous bind, so run only in authentication mode ANONYMOUS. + assumeTrue(authMode == AuthMode.ANONYMOUS); + + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + + BindResult br = conn.bind(); + assertNotNull(br); + assertEquals(ResultCode.SUCCESS, br.getResultCode()); + assertEquals("", authMode.getBindTracker().getLastSuccessfulBindDN(br.getMessageID())); + + } finally { + conn.close(); + } + } + + + @Test + public void testBindAsAdmin() { + // This test tests for anonymous bind, so run only in authentication mode DS_MANAGER. + assumeTrue(authMode == AuthMode.DS_MANAGER); + + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + + BindResult br = conn.bind(); + assertNotNull(br); + assertEquals(ResultCode.SUCCESS, br.getResultCode()); + assertEquals(settings.getString(Keys.realm.ldap.username, "UNSET"), authMode.getBindTracker().getLastSuccessfulBindDN(br.getMessageID())); + + } finally { + conn.close(); + } + } + + + @Test + public void testBindToBindpattern() { + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + + String bindPattern = "CN=${username},OU=Canada," + ACCOUNT_BASE; + + BindResult br = conn.bind(bindPattern, "UserThree", "userThreePassword"); + assertNotNull(br); + assertEquals(ResultCode.SUCCESS, br.getResultCode()); + assertEquals("CN=UserThree,OU=Canada," + ACCOUNT_BASE, authMode.getBindTracker().getLastSuccessfulBindDN(br.getMessageID())); + + br = conn.bind(bindPattern, "UserFour", "userThreePassword"); + assertNull(br); + + br = conn.bind(bindPattern, "UserTwo", "userTwoPassword"); + assertNull(br); + + } finally { + conn.close(); + } + } + + + @Test + public void testRebindAsUser() { + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + + assertFalse(conn.rebindAsUser()); + + BindResult br = conn.bind(); + assertNotNull(br); + assertFalse(conn.rebindAsUser()); + + + String bindPattern = "CN=${username},OU=Canada," + ACCOUNT_BASE; + br = conn.bind(bindPattern, "UserThree", "userThreePassword"); + assertNotNull(br); + assertFalse(conn.rebindAsUser()); + + br = conn.bind(); + assertNotNull(br); + assertTrue(conn.rebindAsUser()); + assertEquals(ResultCode.SUCCESS, br.getResultCode()); + assertEquals("CN=UserThree,OU=Canada," + ACCOUNT_BASE, authMode.getBindTracker().getLastSuccessfulBindDN()); + + } finally { + conn.close(); + } + } + + + + @Test + public void testSearchRequest() throws LDAPException { + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + BindResult br = conn.bind(); + assertNotNull(br); + + SearchRequest req; + SearchResult result; + SearchResultEntry entry; + + req = new SearchRequest(ACCOUNT_BASE, SearchScope.BASE, "(CN=UserOne)"); + result = conn.search(req); + assertNotNull(result); + assertEquals(0, result.getEntryCount()); + + req = new SearchRequest(ACCOUNT_BASE, SearchScope.ONE, "(CN=UserTwo)"); + result = conn.search(req); + assertNotNull(result); + assertEquals(0, result.getEntryCount()); + + req = new SearchRequest(ACCOUNT_BASE, SearchScope.SUB, "(CN=UserThree)"); + result = conn.search(req); + assertNotNull(result); + assertEquals(1, result.getEntryCount()); + entry = result.getSearchEntries().get(0); + assertEquals("CN=UserThree,OU=Canada," + ACCOUNT_BASE, entry.getDN()); + + req = new SearchRequest(ACCOUNT_BASE, SearchScope.SUBORDINATE_SUBTREE, "(CN=UserFour)"); + result = conn.search(req); + assertNotNull(result); + assertEquals(1, result.getEntryCount()); + entry = result.getSearchEntries().get(0); + assertEquals("CN=UserFour,OU=Canada," + ACCOUNT_BASE, entry.getDN()); + + } finally { + conn.close(); + } + } + + + @Test + public void testSearch() throws LDAPException { + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + BindResult br = conn.bind(); + assertNotNull(br); + + SearchResult result; + SearchResultEntry entry; + + result = conn.search(ACCOUNT_BASE, false, "(CN=UserOne)", null); + assertNotNull(result); + assertEquals(1, result.getEntryCount()); + entry = result.getSearchEntries().get(0); + assertEquals("CN=UserOne,OU=US," + ACCOUNT_BASE, entry.getDN()); + + result = conn.search(ACCOUNT_BASE, true, "(&(CN=UserOne)(surname=One))", null); + assertNotNull(result); + assertEquals(1, result.getEntryCount()); + entry = result.getSearchEntries().get(0); + assertEquals("CN=UserOne,OU=US," + ACCOUNT_BASE, entry.getDN()); + + result = conn.search(ACCOUNT_BASE, true, "(&(CN=UserOne)(surname=Two))", null); + assertNotNull(result); + assertEquals(0, result.getEntryCount()); + + result = conn.search(ACCOUNT_BASE, true, "(surname=Two)", Arrays.asList("givenName", "surname")); + assertNotNull(result); + assertEquals(1, result.getEntryCount()); + entry = result.getSearchEntries().get(0); + assertEquals("CN=UserTwo,OU=US," + ACCOUNT_BASE, entry.getDN()); + assertEquals(2, entry.getAttributes().size()); + assertEquals("User", entry.getAttributeValue("givenName")); + assertEquals("Two", entry.getAttributeValue("surname")); + + result = conn.search(ACCOUNT_BASE, true, "(personalTitle=Mr*)", null); + assertNotNull(result); + assertEquals(3, result.getEntryCount()); + ArrayList names = new ArrayList<>(3); + names.add(result.getSearchEntries().get(0).getAttributeValue("surname")); + names.add(result.getSearchEntries().get(1).getAttributeValue("surname")); + names.add(result.getSearchEntries().get(2).getAttributeValue("surname")); + assertTrue(names.contains("One")); + assertTrue(names.contains("Two")); + assertTrue(names.contains("Three")); + + } finally { + conn.close(); + } + } + +} From f639d966cb5e7026cb30e6b25be55fb681feb896 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Fri, 25 Nov 2016 18:21:27 +0100 Subject: [PATCH 06/66] Retrieve public SSH keys from LDAP. Add new class `LdapPublicKeyManager` which retrieves public SSH keys from LDAP. The attribute can be configured with the new configuration option `realm.ldap.sshPublicKey`. The setting can be a simple attribute name, like `sshPublicKey`, or an attribute name and a prefix for the value, like `altSecurityIdentities:SshKey`, in which case attributes are selected that have the name `altSecurityIdentities` and whose values start with `SshKey:`. --- src/main/distrib/data/defaults.properties | 12 + .../com/gitblit/auth/LdapAuthProvider.java | 11 +- .../java/com/gitblit/ldap/LdapConnection.java | 110 ++- .../gitblit/transport/ssh/LdapKeyManager.java | 397 ++++++++++ .../com/gitblit/tests/LdapConnectionTest.java | 32 + .../tests/LdapPublicKeyManagerTest.java | 723 ++++++++++++++++++ 6 files changed, 1248 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java create mode 100644 src/test/java/com/gitblit/tests/LdapPublicKeyManagerTest.java diff --git a/src/main/distrib/data/defaults.properties b/src/main/distrib/data/defaults.properties index 16be84763..1fe5b345e 100644 --- a/src/main/distrib/data/defaults.properties +++ b/src/main/distrib/data/defaults.properties @@ -1935,6 +1935,18 @@ realm.ldap.email = email # SINCE 1.0.0 realm.ldap.uid = uid +# Attribute on the USER record that indicates their public SSH key. +# Leave blank when public SSH keys shall not be retrieved from LDAP. +# +# This may be a simple attribute or an attribute and a value prefix. Examples: +# sshPublicKey - Use the attribute 'sshPublicKey' on the user record. +# altSecurityIdentities:SshKey - Use the attribute 'altSecurityIdentities' +# on the user record, for which the record value +# starts with 'SshKey:', followed by the SSH key entry. +# +# SINCE 1.9.0 +realm.ldap.sshPublicKey = + # Defines whether to synchronize all LDAP users and teams into the user service # This requires either anonymous LDAP access or that a specific account is set # in realm.ldap.username and realm.ldap.password, that has permission to read diff --git a/src/main/java/com/gitblit/auth/LdapAuthProvider.java b/src/main/java/com/gitblit/auth/LdapAuthProvider.java index 8a326cdc4..7ea8f1137 100644 --- a/src/main/java/com/gitblit/auth/LdapAuthProvider.java +++ b/src/main/java/com/gitblit/auth/LdapAuthProvider.java @@ -107,9 +107,9 @@ public synchronized void sync() { } try { - String accountBase = settings.getString(Keys.realm.ldap.accountBase, ""); String uidAttribute = settings.getString(Keys.realm.ldap.uid, "uid"); - String accountPattern = settings.getString(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))"); + String accountBase = ldapConnection.getAccountBase(); + String accountPattern = ldapConnection.getAccountPattern(); accountPattern = StringUtils.replace(accountPattern, "${username}", "*"); SearchResult result = doSearch(ldapConnection, accountBase, accountPattern); @@ -275,11 +275,7 @@ public UserModel authenticate(String username, char[] password) { try { // Find the logging in user's DN - String accountBase = settings.getString(Keys.realm.ldap.accountBase, ""); - String accountPattern = settings.getString(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))"); - accountPattern = StringUtils.replace(accountPattern, "${username}", LdapConnection.escapeLDAPSearchFilter(simpleUsername)); - - SearchResult result = doSearch(ldapConnection, accountBase, accountPattern); + SearchResult result = ldapConnection.searchUser(simpleUsername); if (result != null && result.getEntryCount() == 1) { SearchResultEntry loggingInUser = result.getSearchEntries().get(0); String loggingInUserDN = loggingInUser.getDN(); @@ -527,6 +523,7 @@ private SearchResult doSearch(LdapConnection ldapConnection, String base, String + /** * Returns a simple username without any domain prefixes. * diff --git a/src/main/java/com/gitblit/ldap/LdapConnection.java b/src/main/java/com/gitblit/ldap/LdapConnection.java index b7f07a1e6..14fedf100 100644 --- a/src/main/java/com/gitblit/ldap/LdapConnection.java +++ b/src/main/java/com/gitblit/ldap/LdapConnection.java @@ -1,3 +1,18 @@ +/* + * Copyright 2016 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ package com.gitblit.ldap; import java.net.URI; @@ -69,6 +84,16 @@ public static final String escapeLDAPSearchFilter(String filter) { + public static String getAccountBase(IStoredSettings settings) { + return settings.getString(Keys.realm.ldap.accountBase, ""); + } + + public static String getAccountPattern(IStoredSettings settings) { + return settings.getString(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))"); + } + + + public LdapConnection(IStoredSettings settings) { this.settings = settings; @@ -82,6 +107,16 @@ public LdapConnection(IStoredSettings settings) { + public String getAccountBase() { + return getAccountBase(settings); + } + + public String getAccountPattern() { + return getAccountPattern(settings); + } + + + public boolean connect() { try { URI ldapUrl = new URI(settings.getRequiredString(Keys.realm.ldap.server)); @@ -198,36 +233,6 @@ public boolean rebindAsUser() { - public SearchResult search(SearchRequest request) { - try { - return conn.search(request); - } catch (LDAPSearchException e) { - logger.error("Problem Searching LDAP [{}]", e.getResultCode()); - return e.getSearchResult(); - } - } - - - public SearchResult search(String base, boolean dereferenceAliases, String filter, List attributes) { - try { - SearchRequest searchRequest = new SearchRequest(base, SearchScope.SUB, filter); - if (dereferenceAliases) { - searchRequest.setDerefPolicy(DereferencePolicy.SEARCHING); - } - if (attributes != null) { - searchRequest.setAttributes(attributes); - } - SearchResult result = search(searchRequest); - return result; - - } catch (LDAPException e) { - logger.error("Problem creating LDAP search", e); - return null; - } - } - - - public boolean isAuthenticated(String userDn, String password) { verifyCurrentBinding(); @@ -267,6 +272,51 @@ public boolean isAuthenticated(String userDn, String password) { + + public SearchResult search(SearchRequest request) { + try { + return conn.search(request); + } catch (LDAPSearchException e) { + logger.error("Problem Searching LDAP [{}]", e.getResultCode()); + return e.getSearchResult(); + } + } + + + public SearchResult search(String base, boolean dereferenceAliases, String filter, List attributes) { + try { + SearchRequest searchRequest = new SearchRequest(base, SearchScope.SUB, filter); + if (dereferenceAliases) { + searchRequest.setDerefPolicy(DereferencePolicy.SEARCHING); + } + if (attributes != null) { + searchRequest.setAttributes(attributes); + } + SearchResult result = search(searchRequest); + return result; + + } catch (LDAPException e) { + logger.error("Problem creating LDAP search", e); + return null; + } + } + + + public SearchResult searchUser(String username, List attributes) { + + String accountPattern = getAccountPattern(); + accountPattern = StringUtils.replace(accountPattern, "${username}", escapeLDAPSearchFilter(username)); + + return search(getAccountBase(), false, accountPattern, attributes); + } + + + public SearchResult searchUser(String username) { + return searchUser(username, null); + } + + + private boolean verifyCurrentBinding() { BindRequest lastBind = conn.getLastBindRequest(); if (lastBind == currentBindRequest) { diff --git a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java new file mode 100644 index 000000000..9612a96b9 --- /dev/null +++ b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java @@ -0,0 +1,397 @@ +/* + * Copyright 2016 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.gitblit.transport.ssh; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.server.config.keys.AuthorizedKeyEntry; + +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.Constants.AccessPermission; +import com.gitblit.ldap.LdapConnection; +import com.gitblit.utils.StringUtils; +import com.google.common.base.Joiner; +import com.google.inject.Inject; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchResultEntry; + +/** + * LDAP public key manager + * + * Retrieves public keys from user's LDAP entries. Using this key manager, + * no SSH keys can be edited, i.e. added, removed, permissions changed, etc. + * + * @author Florian Zschocke + * + */ +public class LdapKeyManager extends IPublicKeyManager { + + /** + * Pattern to find prefixes like 'SSHKey:' in key entries. + * These prefixes describe the type of an altSecurityIdentity. + * The pattern accepts anything but quote and colon up to the + * first colon at the start of a string. + */ + private static final Pattern PREFIX_PATTERN = Pattern.compile("^([^\":]+):"); + /** + * Pattern to find the string describing Gitblit permissions for a SSH key. + * The pattern matches on a string starting with 'gbPerm', matched case-insensitive, + * followed by '=' with optional whitespace around it, followed by a string of + * upper and lower case letters and '+' and '-' for the permission, which can optionally + * be enclosed in '"' or '\"' (only the leading quote is matched in the pattern). + * Only the group describing the permission is a capturing group. + */ + private static final Pattern GB_PERM_PATTERN = Pattern.compile("(?i:gbPerm)\\s*=\\s*(?:\\\\\"|\")?\\s*([A-Za-z+-]+)"); + + + private final IStoredSettings settings; + + + + @Inject + public LdapKeyManager(IStoredSettings settings) { + this.settings = settings; + } + + + @Override + public String toString() { + return getClass().getSimpleName(); + } + + @Override + public LdapKeyManager start() { + log.info(toString()); + return this; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public LdapKeyManager stop() { + return this; + } + + @Override + protected boolean isStale(String username) { + // always return true so we gets keys from LDAP every time + return true; + } + + @Override + protected List getKeysImpl(String username) { + try (LdapConnection conn = new LdapConnection(settings)) { + if (conn.connect()) { + log.info("loading ssh key for {} from LDAP directory", username); + + BindResult bindResult = conn.bind(); + if (bindResult == null) { + conn.close(); + return null; + } + + // Search the user entity + + // Support prefixing the key data, e.g. when using altSecurityIdentities in AD. + String pubKeyAttribute = settings.getString(Keys.realm.ldap.sshPublicKey, "sshPublicKey"); + String pkaPrefix = null; + int idx = pubKeyAttribute.indexOf(':'); + if (idx > 0) { + pkaPrefix = pubKeyAttribute.substring(idx +1); + pubKeyAttribute = pubKeyAttribute.substring(0, idx); + } + + SearchResult result = conn.searchUser(getSimpleUsername(username), Arrays.asList(pubKeyAttribute)); + conn.close(); + + if (result != null && result.getResultCode() == ResultCode.SUCCESS) { + if ( result.getEntryCount() > 1) { + log.info("Found more than one entry for user {} in LDAP. Cannot retrieve SSH key.", username); + return null; + } else if ( result.getEntryCount() < 1) { + log.info("Found no entry for user {} in LDAP. Cannot retrieve SSH key.", username); + return null; + } + + // Retrieve the SSH key attributes + SearchResultEntry foundUser = result.getSearchEntries().get(0); + String[] attrs = foundUser.getAttributeValues(pubKeyAttribute); + if (attrs == null ||attrs.length == 0) { + log.info("found no keys for user {} under attribute {} in directory", username, pubKeyAttribute); + return null; + } + + + // Filter resulting list to match with required special prefix in entry + List authorizedKeys = new ArrayList<>(attrs.length); + Matcher m = PREFIX_PATTERN.matcher(""); + for (int i = 0; i < attrs.length; ++i) { + // strip out line breaks + String keyEntry = Joiner.on("").join(attrs[i].replace("\r\n", "\n").split("\n")); + m.reset(keyEntry); + try { + if (m.lookingAt()) { // Key is prefixed in LDAP + if (pkaPrefix == null) { + continue; + } + String prefix = m.group(1).trim(); + if (! pkaPrefix.equalsIgnoreCase(prefix)) { + continue; + } + String s = keyEntry.substring(m.end()); // Strip prefix off + authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s)); + + } else { // Key is not prefixed in LDAP + if (pkaPrefix != null) { + continue; + } + String s = keyEntry; // Strip prefix off + authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s)); + } + } catch (IllegalArgumentException e) { + log.info("Failed to parse key entry={}:", keyEntry, e.getMessage()); + } + } + + List keyList = new ArrayList<>(authorizedKeys.size()); + for (GbAuthorizedKeyEntry keyEntry : authorizedKeys) { + try { + SshKey key = new SshKey(keyEntry.resolvePublicKey()); + key.setComment(keyEntry.getComment()); + setKeyPermissions(key, keyEntry); + keyList.add(key); + } catch (GeneralSecurityException | IOException e) { + log.warn("Error resolving key entry for user {}. Entry={}", username, keyEntry, e); + } + } + return keyList; + } + } + } + + return null; + } + + + @Override + public boolean addKey(String username, SshKey key) { + return false; + } + + @Override + public boolean removeKey(String username, SshKey key) { + return false; + } + + @Override + public boolean removeAllKeys(String username) { + return false; + } + + + + private void setKeyPermissions(SshKey key, GbAuthorizedKeyEntry keyEntry) { + List env = keyEntry.getLoginOptionValues("environment"); + if (env != null && !env.isEmpty()) { + // Walk over all entries and find one that sets 'gbPerm'. The last one wins. + for (String envi : env) { + Matcher m = GB_PERM_PATTERN.matcher(envi); + if (m.find()) { + String perm = m.group(1).trim(); + AccessPermission ap = AccessPermission.fromCode(perm); + if (ap == AccessPermission.NONE) { + ap = AccessPermission.valueOf(perm.toUpperCase()); + } + + if (ap != null && ap != AccessPermission.NONE) { + try { + key.setPermission(ap); + } catch (IllegalArgumentException e) { + log.warn("Incorrect permissions ({}) set for SSH key entry {}.", ap, envi, e); + } + } + } + } + } + } + + + /** + * Returns a simple username without any domain prefixes. + * + * @param username + * @return a simple username + */ + private String getSimpleUsername(String username) { + int lastSlash = username.lastIndexOf('\\'); + if (lastSlash > -1) { + username = username.substring(lastSlash + 1); + } + + return username; + } + + + /** + * Extension of the AuthorizedKeyEntry from Mina SSHD with better option parsing. + * + * The class makes use of code from the two methods copied from the original + * Mina SSHD AuthorizedKeyEntry class. The code is rewritten to improve user login + * option support. Options are correctly parsed even if they have whitespace within + * double quotes. Options can occur multiple times, which is needed for example for + * the "environment" option. Thus for an option a list of strings is kept, holding + * multiple option values. + */ + private static class GbAuthorizedKeyEntry extends AuthorizedKeyEntry { + + private static final long serialVersionUID = 1L; + /** + * Pattern to extract the first part of the key entry without whitespace or only with quoted whitespace. + * The pattern essentially splits the line in two parts with two capturing groups. All other groups + * in the pattern are non-capturing. The first part is a continuous string that only includes double quoted + * whitespace and ends in whitespace. The second part is the rest of the line. + * The first part is at the beginning of the line, the lead-in. For a SSH key entry this can either be + * login options (see authorized keys file description) or the key type. Since options, other than the + * key type, can include whitespace and escaped double quotes within double quotes, the pattern takes + * care of that by searching for either "characters that are not whitespace and not double quotes" + * or "a double quote, followed by 'characters that are not a double quote or backslash, or a backslash + * and then a double quote, or a backslash', followed by a double quote". + */ + private static final Pattern LEADIN_PATTERN = Pattern.compile("^((?:[^\\s\"]*|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))*\\s+)(.+)"); + /** + * Pattern to split a comma separated list of options. + * Since an option could contain commas (as well as escaped double quotes) within double quotes + * in the option value, a simple split on comma is not enough. So the pattern searches for multiple + * occurrences of: + * characters that are not double quotes or a comma, or + * a double quote followed by: characters that are not a double quote or backslash, or + * a backslash and then a double quote, or + * a backslash, + * followed by a double quote. + */ + private static final Pattern OPTION_PATTERN = Pattern.compile("([^\",]+|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))+"); + + // for options that have no value, "true" is used + private Map> loginOptionsMulti = Collections.emptyMap(); + + + List getLoginOptionValues(String option) { + return loginOptionsMulti.get(option); + } + + + + /** + * @param line Original line from an authorized_keys file + * @return {@link GbAuthorizedKeyEntry} or {@code null} if the line is + * {@code null}/empty or a comment line + * @throws IllegalArgumentException If failed to parse/decode the line + * @see #COMMENT_CHAR + */ + public static GbAuthorizedKeyEntry parseAuthorizedKeyEntry(String line) throws IllegalArgumentException { + line = GenericUtils.trimToEmpty(line); + if (StringUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR) /* comment ? */) { + return null; + } + + Matcher m = LEADIN_PATTERN.matcher(line); + if (! m.lookingAt()) { + throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); + } + + String keyType = m.group(1).trim(); + final GbAuthorizedKeyEntry entry; + if (KeyUtils.getPublicKeyEntryDecoder(keyType) == null) { // assume this is due to the fact that it starts with login options + entry = parseAuthorizedKeyEntry(m.group(2)); + if (entry == null) { + throw new IllegalArgumentException("Bad format (no key data after login options): " + line); + } + + entry.parseAndSetLoginOptions(keyType); + } else { + int startPos = line.indexOf(' '); + if (startPos <= 0) { + throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); + } + + int endPos = line.indexOf(' ', startPos + 1); + if (endPos <= startPos) { + endPos = line.length(); + } + + String encData = (endPos < (line.length() - 1)) ? line.substring(0, endPos).trim() : line; + String comment = (endPos < (line.length() - 1)) ? line.substring(endPos + 1).trim() : null; + entry = parsePublicKeyEntry(new GbAuthorizedKeyEntry(), encData); + entry.setComment(comment); + } + + return entry; + } + + private void parseAndSetLoginOptions(String options) { + Matcher m = OPTION_PATTERN.matcher(options); + if (! m.find()) { + loginOptionsMulti = Collections.emptyMap(); + } + Map> optsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + do { + String p = m.group(); + p = GenericUtils.trimToEmpty(p); + if (StringUtils.isEmpty(p)) { + continue; + } + + int pos = p.indexOf('='); + String name = (pos < 0) ? p : GenericUtils.trimToEmpty(p.substring(0, pos)); + CharSequence value = (pos < 0) ? null : GenericUtils.trimToEmpty(p.substring(pos + 1)); + value = GenericUtils.stripQuotes(value); + + // For options without value the value is set to TRUE. + if (value == null) { + value = Boolean.TRUE.toString(); + } + + List opts = optsMap.get(name); + if (opts == null) { + opts = new ArrayList(); + optsMap.put(name, opts); + } + opts.add(value.toString()); + } while(m.find()); + + loginOptionsMulti = optsMap; + } + } + +} diff --git a/src/test/java/com/gitblit/tests/LdapConnectionTest.java b/src/test/java/com/gitblit/tests/LdapConnectionTest.java index f8d2fed0d..3da547772 100644 --- a/src/test/java/com/gitblit/tests/LdapConnectionTest.java +++ b/src/test/java/com/gitblit/tests/LdapConnectionTest.java @@ -245,4 +245,36 @@ public void testSearch() throws LDAPException { } } + + @Test + public void testSearchUser() throws LDAPException { + LdapConnection conn = new LdapConnection(settings); + try { + assertTrue(conn.connect()); + BindResult br = conn.bind(); + assertNotNull(br); + + SearchResult result; + SearchResultEntry entry; + + result = conn.searchUser("UserOne"); + assertNotNull(result); + assertEquals(1, result.getEntryCount()); + entry = result.getSearchEntries().get(0); + assertEquals("CN=UserOne,OU=US," + ACCOUNT_BASE, entry.getDN()); + + result = conn.searchUser("UserFour", Arrays.asList("givenName", "surname")); + assertNotNull(result); + assertEquals(1, result.getEntryCount()); + entry = result.getSearchEntries().get(0); + assertEquals("CN=UserFour,OU=Canada," + ACCOUNT_BASE, entry.getDN()); + assertEquals(2, entry.getAttributes().size()); + assertEquals("User", entry.getAttributeValue("givenName")); + assertEquals("Four", entry.getAttributeValue("surname")); + + } finally { + conn.close(); + } + } + } diff --git a/src/test/java/com/gitblit/tests/LdapPublicKeyManagerTest.java b/src/test/java/com/gitblit/tests/LdapPublicKeyManagerTest.java new file mode 100644 index 000000000..c426254f1 --- /dev/null +++ b/src/test/java/com/gitblit/tests/LdapPublicKeyManagerTest.java @@ -0,0 +1,723 @@ +/* + * Copyright 2016 Florian Zschocke + * Copyright 2016 gitblit.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tests; + +import static org.junit.Assume.assumeTrue; + +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.sshd.common.util.SecurityUtils; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import com.gitblit.Keys; +import com.gitblit.Constants.AccessPermission; +import com.gitblit.transport.ssh.LdapKeyManager; +import com.gitblit.transport.ssh.SshKey; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.Modification; +import com.unboundid.ldap.sdk.ModificationType; + +/** + * Test LdapPublicKeyManager going against an in-memory UnboundID + * LDAP server. + * + * @author Florian Zschocke + * + */ +@RunWith(Parameterized.class) +public class LdapPublicKeyManagerTest extends LdapBasedUnitTest { + + private static Map keyPairs = new HashMap<>(10); + private static KeyPairGenerator rsaGenerator; + private static KeyPairGenerator dsaGenerator; + private static KeyPairGenerator ecGenerator; + + + + @BeforeClass + public static void init() throws GeneralSecurityException { + rsaGenerator = SecurityUtils.getKeyPairGenerator("RSA"); + dsaGenerator = SecurityUtils.getKeyPairGenerator("DSA"); + ecGenerator = SecurityUtils.getKeyPairGenerator("ECDSA"); + } + + + + @Test + public void testGetKeys() throws LDAPException { + String keyRsaOne = getRsaPubKey("UserOne@example.com"); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne)); + + String keyRsaTwo = getRsaPubKey("UserTwo@example.com"); + String keyDsaTwo = getDsaPubKey("UserTwo@example.com"); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaTwo, keyDsaTwo)); + + String keyRsaThree = getRsaPubKey("UserThree@example.com"); + String keyDsaThree = getDsaPubKey("UserThree@example.com"); + String keyEcThree = getEcPubKey("UserThree@example.com"); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", keyEcThree, keyRsaThree, keyDsaThree)); + + LdapKeyManager kmgr = new LdapKeyManager(settings); + + List keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertTrue(keys.size() == 1); + assertEquals(keyRsaOne, keys.get(0).getRawData()); + + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertTrue(keys.size() == 2); + if (keyRsaTwo.equals(keys.get(0).getRawData())) { + assertEquals(keyDsaTwo, keys.get(1).getRawData()); + } else if (keyDsaTwo.equals(keys.get(0).getRawData())) { + assertEquals(keyRsaTwo, keys.get(1).getRawData()); + } else { + fail("Mismatch in UserTwo keys."); + } + + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertTrue(keys.size() == 3); + assertEquals(keyEcThree, keys.get(0).getRawData()); + assertEquals(keyRsaThree, keys.get(1).getRawData()); + assertEquals(keyDsaThree, keys.get(2).getRawData()); + + keys = kmgr.getKeys("UserFour"); + assertNotNull(keys); + assertTrue(keys.size() == 0); + } + + + @Test + public void testGetKeysAttributeName() throws LDAPException { + settings.put(Keys.realm.ldap.sshPublicKey, "sshPublicKey"); + + String keyRsaOne = getRsaPubKey("UserOne@example.com"); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne)); + + String keyDsaTwo = getDsaPubKey("UserTwo@example.com"); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "publicsshkey", keyDsaTwo)); + + String keyRsaThree = getRsaPubKey("UserThree@example.com"); + String keyDsaThree = getDsaPubKey("UserThree@example.com"); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "publicsshkey", keyDsaThree)); + + + LdapKeyManager kmgr = new LdapKeyManager(settings); + + List keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(1, keys.size()); + assertEquals(keyRsaOne, keys.get(0).getRawData()); + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(1, keys.size()); + assertEquals(keyRsaThree, keys.get(0).getRawData()); + + keys = kmgr.getKeys("UserFour"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + + settings.put(Keys.realm.ldap.sshPublicKey, "publicsshkey"); + + keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(1, keys.size()); + assertEquals(keyDsaTwo, keys.get(0).getRawData()); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(1, keys.size()); + assertEquals(keyDsaThree, keys.get(0).getRawData()); + + keys = kmgr.getKeys("UserFour"); + assertNotNull(keys); + assertEquals(0, keys.size()); + } + + + @Test + public void testGetKeysPrefixed() throws LDAPException { + // This test is independent from authentication mode, so run only once. + assumeTrue(authMode == AuthMode.ANONYMOUS); + + String keyRsaOne = getRsaPubKey("UserOne@example.com"); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne)); + + String keyRsaTwo = getRsaPubKey("UserTwo@example.com"); + String keyDsaTwo = getDsaPubKey("UserTwo@example.com"); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", keyRsaTwo)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHKey: " + keyDsaTwo)); + + String keyRsaThree = getRsaPubKey("UserThree@example.com"); + String keyDsaThree = getDsaPubKey("UserThree@example.com"); + String keyEcThree = getEcPubKey("UserThree@example.com"); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", " SshKey :\r\n" + keyRsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", " sshkey: " + keyDsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "ECDSAKey :\n " + keyEcThree)); + + + LdapKeyManager kmgr = new LdapKeyManager(settings); + + settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities"); + + List keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(1, keys.size()); + assertEquals(keyRsaTwo, keys.get(0).getRawData()); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + keys = kmgr.getKeys("UserFour"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + + + settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:SSHKey"); + + keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(1, keys.size()); + assertEquals(keyDsaTwo, keys.get(0).getRawData()); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(2, keys.size()); + assertEquals(keyRsaThree, keys.get(0).getRawData()); + assertEquals(keyDsaThree, keys.get(1).getRawData()); + + keys = kmgr.getKeys("UserFour"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + + + settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:ECDSAKey"); + + keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(0, keys.size()); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(1, keys.size()); + assertEquals(keyEcThree, keys.get(0).getRawData()); + + keys = kmgr.getKeys("UserFour"); + assertNotNull(keys); + assertEquals(0, keys.size()); + } + + + @Test + public void testGetKeysPermissions() throws LDAPException { + // This test is independent from authentication mode, so run only once. + assumeTrue(authMode == AuthMode.ANONYMOUS); + + String keyRsaOne = getRsaPubKey("UserOne@example.com"); + String keyRsaTwo = getRsaPubKey(""); + String keyDsaTwo = getDsaPubKey("UserTwo at example.com"); + String keyRsaThree = getRsaPubKey("UserThree@example.com"); + String keyDsaThree = getDsaPubKey("READ key for user 'Three' @example.com"); + String keyEcThree = getEcPubKey("UserThree@example.com"); + + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", " " + keyRsaTwo)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", "no-agent-forwarding " + keyDsaTwo)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", " command=\"sh /etc/netstart tun0 \" " + keyRsaThree)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", " command=\"netstat -nult\",environment=\"gb=\\\"What now\\\"\" " + keyDsaThree)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerms=VIEW\" " + keyEcThree)); + + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"gbPerm=R\" " + keyRsaOne)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", " restrict,environment=\"gbperm=V\" " + keyRsaTwo)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", "restrict,environment=\"GBPerm=RW\",pty " + keyDsaTwo)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", " environment=\"gbPerm=CLONE\",environment=\"X=\\\" Y \\\"\" " + keyRsaThree)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", " environment=\"A = B \",from=\"*.example.com,!pc.example.com\",environment=\"gbPerm=VIEW\" " + keyDsaThree)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"SSH=git\",environment=\"gbPerm=PUSH\",environment=\"XYZ='Ali Baba'\" " + keyEcThree)); + + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"gbPerm=R\",environment=\"josh=\\\"mean\\\"\",tunnel=\"0\" " + keyRsaOne)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", " environment=\" gbPerm = V \" " + keyRsaTwo)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "command=\"sh echo \\\"Nope, not you!\\\" \",user-rc,environment=\"gbPerm=RW\" " + keyDsaTwo)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"gbPerm=VIEW\",command=\"sh /etc/netstart tun0 \",environment=\"gbPerm=CLONE\",no-pty " + keyRsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", " command=\"netstat -nult\",environment=\"gbPerm=VIEW\" " + keyDsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerm=PUSH\" " + keyEcThree)); + + + LdapKeyManager kmgr = new LdapKeyManager(settings); + + List keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(6, keys.size()); + for (SshKey key : keys) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + } + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(6, keys.size()); + int seen = 0; + for (SshKey key : keys) { + if (keyRsaOne.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 5; + } + } + assertEquals(63, seen); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(6, keys.size()); + seen = 0; + for (SshKey key : keys) { + if (keyRsaOne.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 5; + } + } + assertEquals(63, seen); + } + + + @Test + public void testGetKeysPrefixedPermissions() throws LDAPException { + // This test is independent from authentication mode, so run only once. + assumeTrue(authMode == AuthMode.ANONYMOUS); + + String keyRsaOne = getRsaPubKey("UserOne@example.com"); + String keyRsaTwo = getRsaPubKey("UserTwo at example.com"); + String keyDsaTwo = getDsaPubKey("UserTwo@example.com"); + String keyRsaThree = getRsaPubKey("example.com: user Three"); + String keyDsaThree = getDsaPubKey(""); + String keyEcThree = getEcPubKey(" "); + + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "permitopen=\"host:220\"" + keyRsaOne)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "sshkey:" + " " + keyRsaTwo)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHKEY :" + "no-agent-forwarding " + keyDsaTwo)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + " command=\"sh /etc/netstart tun0 \" " + keyRsaThree)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + " command=\"netstat -nult\",environment=\"gb=\\\"What now\\\"\" " + keyDsaThree)); + getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerms=VIEW\" " + keyEcThree)); + + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "environment=\"gbPerm=R\" " + keyRsaOne)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHKey : " + " restrict,environment=\"gbPerm=V\",permitopen=\"sshkey: 220\" " + keyRsaTwo)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "permitopen=\"sshkey: 443\",restrict,environment=\"gbPerm=RW\",pty " + keyDsaTwo)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"gbPerm=CLONE\",permitopen=\"pubkey: 29184\",environment=\"X=\\\" Y \\\"\" " + keyRsaThree)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + " environment=\"A = B \",from=\"*.example.com,!pc.example.com\",environment=\"gbPerm=VIEW\" " + keyDsaThree)); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"SSH=git\",environment=\"gbPerm=PUSH\",environemnt=\"XYZ='Ali Baba'\" " + keyEcThree)); + + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "environment=\"gbPerm=R\",environment=\"josh=\\\"mean\\\"\",tunnel=\"0\" " + keyRsaOne)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey : " + " environment=\" gbPerm = V \" " + keyRsaTwo)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "command=\"sh echo \\\"Nope, not you! \\b (bell)\\\" \",user-rc,environment=\"gbPerm=RW\" " + keyDsaTwo)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"gbPerm=VIEW\",command=\"sh /etc/netstart tun0 \",environment=\"gbPerm=CLONE\",no-pty " + keyRsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + " command=\"netstat -nult\",environment=\"gbPerm=VIEW\" " + keyDsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerm=PUSH\" " + keyEcThree)); + + // Weird stuff, not to specification but shouldn't make it stumble. + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "opttest: " + "permitopen=host:443,command=,environment=\"gbPerm=CLONE\",no-pty= " + keyRsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", " opttest: " + " cmd=git,environment=\"gbPerm=\\\"VIEW\\\"\" " + keyDsaThree)); + getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", " opttest:" + "environment=,command=netstat,environment=gbperm=push " + keyEcThree)); + + + LdapKeyManager kmgr = new LdapKeyManager(settings); + + settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:SSHkey"); + + List keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(2, keys.size()); + int seen = 0; + for (SshKey key : keys) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + if (keyRsaOne.equals(key.getRawData())) { + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + seen += 1 << 5; + } + } + assertEquals(6, seen); + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(3, keys.size()); + seen = 0; + for (SshKey key : keys) { + if (keyRsaOne.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 5; + } + } + assertEquals(7, seen); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(3, keys.size()); + seen = 0; + for (SshKey key : keys) { + if (keyRsaOne.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 5; + } + } + assertEquals(7, seen); + + + + settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:pubKey"); + + keys = kmgr.getKeys("UserOne"); + assertNotNull(keys); + assertEquals(3, keys.size()); + seen = 0; + for (SshKey key : keys) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + if (keyRsaOne.equals(key.getRawData())) { + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + seen += 1 << 5; + } + } + assertEquals(56, seen); + + keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(3, keys.size()); + seen = 0; + for (SshKey key : keys) { + if (keyRsaOne.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 5; + } + } + assertEquals(56, seen); + + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(3, keys.size()); + seen = 0; + for (SshKey key : keys) { + if (keyRsaOne.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 5; + } + } + assertEquals(56, seen); + + + settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:opttest"); + keys = kmgr.getKeys("UserThree"); + assertNotNull(keys); + assertEquals(3, keys.size()); + seen = 0; + for (SshKey key : keys) { + if (keyRsaOne.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 0; + } + else if (keyRsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 1; + } + else if (keyDsaTwo.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 2; + } + else if (keyRsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.CLONE, key.getPermission()); + seen += 1 << 3; + } + else if (keyDsaThree.equals(key.getRawData())) { + assertEquals(AccessPermission.VIEW, key.getPermission()); + seen += 1 << 4; + } + else if (keyEcThree.equals(key.getRawData())) { + assertEquals(AccessPermission.PUSH, key.getPermission()); + seen += 1 << 5; + } + } + assertEquals(56, seen); + + } + + + @Test + public void testKeyValidity() throws LDAPException, GeneralSecurityException { + LdapKeyManager kmgr = new LdapKeyManager(settings); + + String comment = "UserTwo@example.com"; + String keyDsaTwo = getDsaPubKey(comment); + getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", keyDsaTwo)); + + + List keys = kmgr.getKeys("UserTwo"); + assertNotNull(keys); + assertEquals(1, keys.size()); + SshKey sshKey = keys.get(0); + assertEquals(keyDsaTwo, sshKey.getRawData()); + + Signature signature = SecurityUtils.getSignature("DSA"); + signature.initSign(getDsaKeyPair(comment).getPrivate()); + byte[] message = comment.getBytes(); + signature.update(message); + byte[] sigBytes = signature.sign(); + + signature.initVerify(sshKey.getPublicKey()); + signature.update(message); + assertTrue("Verify failed with retrieved SSH key.", signature.verify(sigBytes)); + } + + + + + + + + + private KeyPair getDsaKeyPair(String comment) { + return getKeyPair("DSA", comment, dsaGenerator); + } + + private KeyPair getKeyPair(String type, String comment, KeyPairGenerator generator) { + String kpkey = type + ":" + comment; + KeyPair kp = keyPairs.get(kpkey); + if (kp == null) { + if ("EC".equals(type)) { + ECGenParameterSpec ecSpec = new ECGenParameterSpec("P-384"); + try { + ecGenerator.initialize(ecSpec); + } catch (InvalidAlgorithmParameterException e) { + kp = generator.generateKeyPair(); + e.printStackTrace(); + } + kp = ecGenerator.generateKeyPair(); + } else { + kp = generator.generateKeyPair(); + } + keyPairs.put(kpkey, kp); + } + + return kp; + } + + + private String getRsaPubKey(String comment) { + return getPubKey("RSA", comment, rsaGenerator); + } + + private String getDsaPubKey(String comment) { + return getPubKey("DSA", comment, dsaGenerator); + } + + private String getEcPubKey(String comment) { + return getPubKey("EC", comment, ecGenerator); + } + + private String getPubKey(String type, String comment, KeyPairGenerator generator) { + KeyPair kp = getKeyPair(type, comment, generator); + if (kp == null) { + return null; + } + + SshKey sk = new SshKey(kp.getPublic()); + sk.setComment(comment); + return sk.getRawData(); + } + +} From 51e70f4233400ccf90c4e05638df53f2d5784d3c Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Tue, 6 Dec 2016 14:44:18 +0100 Subject: [PATCH 07/66] Set list of offered SSH authentication methods. Make the SSH authentication methods used by the server configurable, so that for example password authentication can be turned off. For this, a `git.sshAuthenticationMethods` setting is added which is a space separated list of authentication method names. Only the methods listed will be enabled in the server. This is modeled after the option of the same name from sshd_config, but it does not offer listing multiple required methods. It leaves the door open, though, for a later extension to support such a multi-factor authentication. Since this also includes Kerberos authentication with GSS API, this obsoletes the `git.sshWithKrb5` property. The latter is removed. Instead, to enable Kerberos5 authentication, add the method name `gssapi-with-mic` to the authentication methods list. --- src/main/distrib/data/defaults.properties | 21 ++++++++-- .../com/gitblit/transport/ssh/SshDaemon.java | 40 ++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/main/distrib/data/defaults.properties b/src/main/distrib/data/defaults.properties index 0c7d6cd42..f0c59f657 100644 --- a/src/main/distrib/data/defaults.properties +++ b/src/main/distrib/data/defaults.properties @@ -138,10 +138,25 @@ git.sshKeysManager = com.gitblit.transport.ssh.FileKeyManager # SINCE 1.5.0 git.sshKeysFolder= ${baseFolder}/ssh -# Use Kerberos5 (GSS) authentication + +# Authentication methods offered by the SSH server. +# Space separated list of authentication method names that the +# server shall offer. The default is "publickey password". # -# SINCE 1.7.0 -git.sshWithKrb5 = false +# Valid authentication method names are: +# publickey - authenticate with SSH public key +# password - authenticate with username, password +# keyboard-interactive - currently synonym to 'password' +# gssapi-with-mic - GSS API Kerberos 5 authentication +# +# This setting obsoletes the "git.sshWithKrb5" setting. To enable +# Kerberos5 (GSS) authentication, add 'gssapi-with-mic' to the list. +# +# SINCE 1.9.0 +# RESTART REQUIRED +# SPACE-DELIMITED +git.sshAuthenticationMethods = publickey password + # The path to a Kerberos 5 keytab. # diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java index 5a94c9a3f..3189058b6 100644 --- a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java +++ b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java @@ -23,6 +23,7 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.text.MessageFormat; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.apache.sshd.common.io.IoServiceFactoryFactory; @@ -55,6 +56,13 @@ public class SshDaemon { private final Logger log = LoggerFactory.getLogger(SshDaemon.class); + private static final String AUTH_PUBLICKEY = "publickey"; + private static final String AUTH_PASSWORD = "password"; + private static final String AUTH_KBD_INTERACTIVE = "keyboard-interactive"; + private static final String AUTH_GSSAPI = "gssapi-with-mic"; + + + public static enum SshSessionBackend { MINA, NIO2 } @@ -97,9 +105,6 @@ public SshDaemon(IGitblit gitblit, WorkQueue workQueue) { FileKeyPairProvider hostKeyPairProvider = new FileKeyPairProvider(); hostKeyPairProvider.setFiles(new String [] { rsaKeyStore.getPath(), dsaKeyStore.getPath(), dsaKeyStore.getPath() }); - // Client public key authenticator - SshKeyAuthenticator keyAuthenticator = - new SshKeyAuthenticator(gitblit.getPublicKeyManager(), gitblit); // Configure the preferred SSHD backend String sshBackendStr = settings.getString(Keys.git.sshBackend, @@ -125,11 +130,34 @@ public SshDaemon(IGitblit gitblit, WorkQueue workQueue) { sshd.setPort(addr.getPort()); sshd.setHost(addr.getHostName()); sshd.setKeyPairProvider(hostKeyPairProvider); - sshd.setPublickeyAuthenticator(new CachingPublicKeyAuthenticator(keyAuthenticator)); - sshd.setPasswordAuthenticator(new UsernamePasswordAuthenticator(gitblit)); - if (settings.getBoolean(Keys.git.sshWithKrb5, false)) { + + List authMethods = settings.getStrings(Keys.git.sshAuthenticationMethods); + if (authMethods.isEmpty()) { + authMethods.add(AUTH_PUBLICKEY); + authMethods.add(AUTH_PASSWORD); + } + // Keep backward compatibility with old setting files that use the git.sshWithKrb5 setting. + if (settings.getBoolean("git.sshWithKrb5", false) && !authMethods.contains(AUTH_GSSAPI)) { + authMethods.add(AUTH_GSSAPI); + log.warn("git.sshWithKrb5 is obsolete!"); + log.warn("Please add {} to {} in gitblit.properties!", AUTH_GSSAPI, Keys.git.sshAuthenticationMethods); + settings.overrideSetting(Keys.git.sshAuthenticationMethods, + settings.getString(Keys.git.sshAuthenticationMethods, AUTH_PUBLICKEY + " " + AUTH_PASSWORD) + " " + AUTH_GSSAPI); + } + if (authMethods.contains(AUTH_PUBLICKEY)) { + SshKeyAuthenticator keyAuthenticator = new SshKeyAuthenticator(gitblit.getPublicKeyManager(), gitblit); + sshd.setPublickeyAuthenticator(new CachingPublicKeyAuthenticator(keyAuthenticator)); + log.info("SSH: adding public key authentication method."); + } + if (authMethods.contains(AUTH_PASSWORD) || authMethods.contains(AUTH_KBD_INTERACTIVE)) { + sshd.setPasswordAuthenticator(new UsernamePasswordAuthenticator(gitblit)); + log.info("SSH: adding password authentication method."); + } + if (authMethods.contains(AUTH_GSSAPI)) { sshd.setGSSAuthenticator(new SshKrbAuthenticator(settings, gitblit)); + log.info("SSH: adding GSSAPI authentication method."); } + sshd.setSessionFactory(new SshServerSessionFactory()); sshd.setFileSystemFactory(new DisabledFilesystemFactory()); sshd.setTcpipForwardingFilter(new NonForwardingFilter()); From cb89090d936ff8383d26f69eaeae6717d3a701e1 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Sat, 26 Nov 2016 17:35:21 +0100 Subject: [PATCH 08/66] Use dynamic port selection for LDAP listeners in LDAP tests. Instead of using fixed ports for the listeners of the in-memory LDAP server, let the listeners select ports and then save them in the authentication mode instance. This way we prevent port collisions, which especially showed up under Windows. --- .../com/gitblit/tests/LdapBasedUnitTest.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java b/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java index cf3ab1fa0..7aec50e6e 100644 --- a/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java +++ b/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java @@ -74,9 +74,9 @@ public abstract class LdapBasedUnitTest extends GitblitUnitTest { * */ protected enum AuthMode { - ANONYMOUS(1389), - DS_MANAGER(2389), - USR_MANAGER(3389); + ANONYMOUS, + DS_MANAGER, + USR_MANAGER; private int ldapPort; @@ -84,7 +84,7 @@ protected enum AuthMode { private InMemoryDirectoryServerSnapshot dsSnapshot; private BindTracker bindTracker; - AuthMode(int port) { + void setLdapPort(int port) { this.ldapPort = port; } @@ -151,23 +151,26 @@ public static Collection data() { public static void ldapInit() throws Exception { InMemoryDirectoryServer ds; InMemoryDirectoryServerConfig config = createInMemoryLdapServerConfig(AuthMode.ANONYMOUS); - config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.ANONYMOUS.ldapPort())); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("anonymous")); ds = createInMemoryLdapServer(config); AuthMode.ANONYMOUS.setDS(ds); + AuthMode.ANONYMOUS.setLdapPort(ds.getListenPort("anonymous")); config = createInMemoryLdapServerConfig(AuthMode.DS_MANAGER); - config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.DS_MANAGER.ldapPort())); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("ds_manager")); config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class)); ds = createInMemoryLdapServer(config); AuthMode.DS_MANAGER.setDS(ds); + AuthMode.DS_MANAGER.setLdapPort(ds.getListenPort("ds_manager")); config = createInMemoryLdapServerConfig(AuthMode.USR_MANAGER); - config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.USR_MANAGER.ldapPort())); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("usr_manager")); config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class)); ds = createInMemoryLdapServer(config); AuthMode.USR_MANAGER.setDS(ds); + AuthMode.USR_MANAGER.setLdapPort(ds.getListenPort("usr_manager")); } @@ -220,19 +223,17 @@ protected InMemoryDirectoryServer getDS() { protected MemorySettings getSettings() { Map backingMap = new HashMap(); backingMap.put(Keys.realm.userService, usersConf.getAbsolutePath()); + backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); switch(authMode) { case ANONYMOUS: - backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); backingMap.put(Keys.realm.ldap.username, ""); backingMap.put(Keys.realm.ldap.password, ""); break; case DS_MANAGER: - backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); backingMap.put(Keys.realm.ldap.username, DIRECTORY_MANAGER); backingMap.put(Keys.realm.ldap.password, "password"); break; case USR_MANAGER: - backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort()); backingMap.put(Keys.realm.ldap.username, USER_MANAGER); backingMap.put(Keys.realm.ldap.password, "passwd"); break; From a3f9b4f64e52ba1833c3bcb18cf7f05b4d35714e Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Tue, 29 Nov 2016 21:46:54 +0100 Subject: [PATCH 09/66] Fix SshKeysDispatcher test failing on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `SshKeysDispatcher` tests that use the keys list command are failing on Windows because they assume a Unix line ending after each key. But the command will use a system line ending. So this fix uses system line endings in the reference string for the assert, too. In addition, two `assertTrue(false)´ are replaced with a proper `fail`. --- .../com/gitblit/tests/SshKeysDispatcherTest.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java b/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java index 23e617952..4784e4685 100644 --- a/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java +++ b/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java @@ -37,7 +37,7 @@ public void testKeysListCommand() throws Exception { String result = testSshCommand("keys ls -L"); List keys = getKeyManager().getKeys(username); assertEquals(String.format("There are %d keys!", keys.size()), 2, keys.size()); - assertEquals(keys.get(0).getRawData() + "\n" + keys.get(1).getRawData(), result); + assertEquals(String.format("%s%n%s", keys.get(0).getRawData(), keys.get(1).getRawData()), result); } @Test @@ -64,9 +64,9 @@ public void testKeysRmAllByIndexCommand() throws Exception { assertEquals(String.format("There are %d keys!", keys.size()), 0, keys.size()); try { testSshCommand("keys ls -L"); - assertTrue("Authentication worked without a public key?!", false); + fail("Authentication worked without a public key?!"); } catch (AssertionError e) { - assertTrue(true); + // expected } } @@ -77,9 +77,9 @@ public void testKeysRmAllCommand() throws Exception { assertEquals(String.format("There are %d keys!", keys.size()), 0, keys.size()); try { testSshCommand("keys ls -L"); - assertTrue("Authentication worked without a public key?!", false); + fail("Authentication worked without a public key?!"); } catch (AssertionError e) { - assertTrue(true); + // expected } } @@ -96,9 +96,9 @@ public void testKeysAddCommand() throws Exception { StringBuilder sb = new StringBuilder(); for (SshKey sk : keys) { sb.append(sk.getRawData()); - sb.append('\n'); + sb.append(System.getProperty("line.separator", "\n")); } - sb.setLength(sb.length() - 1); + sb.setLength(sb.length() - System.getProperty("line.separator", "\n").length()); assertEquals(sb.toString(), result); } From 40040b656299bfafcaa92b12b916f93e8c5aed1d Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Tue, 29 Nov 2016 22:08:50 +0100 Subject: [PATCH 10/66] The public key manager can disable writing keys, which hides commands Some public key mangers may be read-only, i.e. not allow to add or delete keys, or to change the key comment or assigned permissions. In such a case the respective commands should not be available on the SSH shell and the SSH Keys panel should also not offer the possibility. The `IPublicKeyManager` gets three new methods, modelled after the `AuthenticationManager`: `supportsWritingKeys`, `supportsCommentChanges` and `supportsPermissionChanges`. They return true if a key manager allows for keys to be written or updated. For example the existing `FileKeyManager` will return true for all three since it allows to store and update keys in a file. The new `LdapKeyManager` returns false since it only accesses LDAP and can not add or update any keys in the directory. A future key manager might get keys from an LDAP directory but still keep comments and permissions for it in a local copy. If writing of keys is not supported: * the welcome shell does not suggest adding a key, * the `SshKeysDispatcher` does not offer the "add", "remove", "comment" and "permission" commands, and * the SSH keys panel hides the "delete" button in the key list, and the "Add Key" form. The hiding of the "Add key" form is not perfect since the surrounding div is still shown, but I don't know how to hide it and it didn't look too bad, either. --- .../transport/ssh/IPublicKeyManager.java | 13 ++++++++++++ .../gitblit/transport/ssh/LdapKeyManager.java | 13 ++++++++++++ .../com/gitblit/transport/ssh/SshDaemon.java | 2 +- .../gitblit/transport/ssh/WelcomeShell.java | 21 ++++++++++++------- .../transport/ssh/keys/KeysDispatcher.java | 17 +++++++++++---- .../gitblit/wicket/panels/SshKeysPanel.java | 9 ++++++++ 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java b/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java index 1e74b2f03..ffe64f593 100644 --- a/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java +++ b/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java @@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory; import com.gitblit.manager.IManager; +import com.gitblit.models.UserModel; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheLoader.InvalidCacheLoadException; @@ -99,4 +100,16 @@ public final void renameUser(String oldName, String newName) { public abstract boolean removeKey(String username, SshKey key); public abstract boolean removeAllKeys(String username); + + public boolean supportsWritingKeys(UserModel user) { + return (user != null); + } + + public boolean supportsCommentChanges(UserModel user) { + return (user != null); + } + + public boolean supportsPermissionChanges(UserModel user) { + return (user != null); + } } diff --git a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java index 9612a96b9..6b8f1e45f 100644 --- a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java +++ b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java @@ -34,6 +34,7 @@ import com.gitblit.Keys; import com.gitblit.Constants.AccessPermission; import com.gitblit.ldap.LdapConnection; +import com.gitblit.models.UserModel; import com.gitblit.utils.StringUtils; import com.google.common.base.Joiner; import com.google.inject.Inject; @@ -219,6 +220,18 @@ public boolean removeAllKeys(String username) { } + public boolean supportsWritingKeys(UserModel user) { + return false; + } + + public boolean supportsCommentChanges(UserModel user) { + return false; + } + + public boolean supportsPermissionChanges(UserModel user) { + return false; + } + private void setKeyPermissions(SshKey key, GbAuthorizedKeyEntry keyEntry) { List env = keyEntry.getLoginOptionValues("environment"); diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java index 5a94c9a3f..4fb05f79c 100644 --- a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java +++ b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java @@ -134,7 +134,7 @@ public SshDaemon(IGitblit gitblit, WorkQueue workQueue) { sshd.setFileSystemFactory(new DisabledFilesystemFactory()); sshd.setTcpipForwardingFilter(new NonForwardingFilter()); sshd.setCommandFactory(new SshCommandFactory(gitblit, workQueue)); - sshd.setShellFactory(new WelcomeShell(settings)); + sshd.setShellFactory(new WelcomeShell(gitblit)); // Set the server id. This can be queried with: // ssh-keyscan -t rsa,dsa -p 29418 localhost diff --git a/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java b/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java index ec6f72914..7c407d365 100644 --- a/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java +++ b/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java @@ -34,6 +34,7 @@ import com.gitblit.IStoredSettings; import com.gitblit.Keys; +import com.gitblit.manager.IGitblit; import com.gitblit.models.UserModel; import com.gitblit.transport.ssh.commands.DispatchCommand; import com.gitblit.transport.ssh.commands.SshCommandFactory; @@ -45,19 +46,20 @@ */ public class WelcomeShell implements Factory { - private final IStoredSettings settings; + private final IGitblit gitblit; - public WelcomeShell(IStoredSettings settings) { - this.settings = settings; + public WelcomeShell(IGitblit gitblit) { + this.gitblit = gitblit; } @Override public Command create() { - return new SendMessage(settings); + return new SendMessage(gitblit); } private static class SendMessage implements Command, SessionAware { + private final IPublicKeyManager km; private final IStoredSettings settings; private ServerSession session; @@ -66,8 +68,9 @@ private static class SendMessage implements Command, SessionAware { private OutputStream err; private ExitCallback exit; - SendMessage(IStoredSettings settings) { - this.settings = settings; + SendMessage(IGitblit gitblit) { + this.km = gitblit.getPublicKeyManager(); + this.settings = gitblit.getSettings(); } @Override @@ -116,6 +119,10 @@ String getMessage() { UserModel user = client.getUser(); String hostname = getHostname(); int port = settings.getInteger(Keys.git.sshPort, 0); + boolean writeKeysIsSupported = true; + if (km != null) { + writeKeysIsSupported = km.supportsWritingKeys(user); + } final String b1 = StringUtils.rightPad("", 72, '═'); final String b2 = StringUtils.rightPad("", 72, '─'); @@ -159,7 +166,7 @@ String getMessage() { msg.append(nl); msg.append(nl); - if (client.getKey() == null) { + if (writeKeysIsSupported && client.getKey() == null) { // user has authenticated with a password // display add public key instructions msg.append(" You may upload an SSH public key with the following syntax:"); diff --git a/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java b/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java index da58584c9..817a98ffc 100644 --- a/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java +++ b/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java @@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory; import com.gitblit.Constants.AccessPermission; +import com.gitblit.models.UserModel; import com.gitblit.transport.ssh.IPublicKeyManager; import com.gitblit.transport.ssh.SshKey; import com.gitblit.transport.ssh.commands.CommandMetaData; @@ -47,12 +48,20 @@ public class KeysDispatcher extends DispatchCommand { @Override protected void setup() { - register(AddKey.class); - register(RemoveKey.class); + IPublicKeyManager km = getContext().getGitblit().getPublicKeyManager(); + UserModel user = getContext().getClient().getUser(); + if (km != null && km.supportsWritingKeys(user)) { + register(AddKey.class); + register(RemoveKey.class); + } register(ListKeys.class); register(WhichKey.class); - register(CommentKey.class); - register(PermissionKey.class); + if (km != null && km.supportsCommentChanges(user)) { + register(CommentKey.class); + } + if (km != null && km.supportsPermissionChanges(user)) { + register(PermissionKey.class); + } } @CommandMetaData(name = "add", description = "Add an SSH public key to your account") diff --git a/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java index 15ebd67b7..4b8787630 100644 --- a/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java +++ b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java @@ -48,11 +48,13 @@ public class SshKeysPanel extends BasePanel { private static final long serialVersionUID = 1L; private final UserModel user; + private final boolean canWriteKeys; public SshKeysPanel(String wicketId, UserModel user) { super(wicketId); this.user = user; + this.canWriteKeys = app().keys().supportsWritingKeys(user); } @Override @@ -90,6 +92,9 @@ public void onClick(AjaxRequestTarget target) { } } }; + if (!canWriteKeys) { + delete.setVisibilityAllowed(false); + } item.add(delete); } }; @@ -164,6 +169,10 @@ protected void onSubmit(AjaxRequestTarget target, Form form) { } }); + if (! canWriteKeys) { + addKeyForm.setVisibilityAllowed(false); + } + add(addKeyForm); } } From 1afeccc09bfaa885b5c01d3db29d42695b8290a1 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Mon, 5 Dec 2016 15:58:06 +0100 Subject: [PATCH 11/66] Extend documentation in default.properties and LdapKeyManager.java. --- src/main/distrib/data/defaults.properties | 6 ++++- .../gitblit/transport/ssh/LdapKeyManager.java | 27 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/distrib/data/defaults.properties b/src/main/distrib/data/defaults.properties index 1fe5b345e..b9d77fe7f 100644 --- a/src/main/distrib/data/defaults.properties +++ b/src/main/distrib/data/defaults.properties @@ -1938,7 +1938,11 @@ realm.ldap.uid = uid # Attribute on the USER record that indicates their public SSH key. # Leave blank when public SSH keys shall not be retrieved from LDAP. # -# This may be a simple attribute or an attribute and a value prefix. Examples: +# This setting is only relevant when a public key manager is used that +# retrieves SSH keys from LDAP (e.g. com.gitblit.transport.ssh.LdapKeyManager). +# +# The accepted format of the value is dependent on the public key manager used. +# Examples: # sshPublicKey - Use the attribute 'sshPublicKey' on the user record. # altSecurityIdentities:SshKey - Use the attribute 'altSecurityIdentities' # on the user record, for which the record value diff --git a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java index 6b8f1e45f..c62c4dee2 100644 --- a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java +++ b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java @@ -44,11 +44,36 @@ import com.unboundid.ldap.sdk.SearchResultEntry; /** - * LDAP public key manager + * LDAP-only public key manager * * Retrieves public keys from user's LDAP entries. Using this key manager, * no SSH keys can be edited, i.e. added, removed, permissions changed, etc. * + * This key manager supports SSH key entries in LDAP of the following form: + * [:] [] [] + * This follows the required form of entries in the authenticated_keys file, + * with an additional optional prefix. Key entries must have a key type + * (like "ssh-rsa") and a key, and may have a comment at the end. + * + * An entry may specify login options as specified for the authorized_keys file. + * The 'environment' option may be used to set the permissions for the key + * by setting a 'gbPerm' environment variable. The key manager will interpret + * such a environment variable option and use the set permission string to set + * the permission on the key in Gitblit. Example: + * environment="gbPerm=V",pty ssh-rsa AAAxjka.....dv= Clone only key + * Above entry would create a RSA key with the comment "Clone only key" and + * set the key permission to CLONE. All other options are ignored. + * + * In Active Directory SSH public keys are sometimes stored in the attribute + * 'altSecurityIdentity'. The attribute value is usually prefixed by a type + * identifier. LDAP entries could have the following attribute values: + * altSecurityIdentity: X.509: ADKEJBAKDBZUPABBD... + * altSecurityIdentity: SshKey: ssh-dsa AAAAknenazuzucbhda... + * This key manager supports this by allowing an optional prefix to identify + * SSH keys. The prefix to be used should be set in the 'realm.ldap.sshPublicKey' + * setting by separating it from the attribute name with a colon, e.g.: + * realm.ldap.sshPublicKey = altSecurityIdentity:SshKey + * * @author Florian Zschocke * */ From 90a8d1af6c202c8efcca5a0fdaf341494cb0b8eb Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Sat, 10 Dec 2016 10:57:45 +0100 Subject: [PATCH 12/66] Set secure user cookies and only for HTTP. Mark the user authentication cookie to be only used for HTTP, making it inaccessible for JavaScript engines. If only HTTPS is used and no HTTP (i.e. also if HTTP is redirected to HTTPS) then mark the user cookie to be sent only over secure connections. --- .../com/gitblit/manager/AuthenticationManager.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/com/gitblit/manager/AuthenticationManager.java b/src/main/java/com/gitblit/manager/AuthenticationManager.java index 497876315..0a4d8ed72 100644 --- a/src/main/java/com/gitblit/manager/AuthenticationManager.java +++ b/src/main/java/com/gitblit/manager/AuthenticationManager.java @@ -608,6 +608,11 @@ public void setCookie(HttpServletRequest request, HttpServletResponse response, userCookie = new Cookie(Constants.NAME, cookie); // expire the cookie in 7 days userCookie.setMaxAge((int) TimeUnit.DAYS.toSeconds(7)); + + // Set cookies HttpOnly so they are not accessible to JavaScript engines + userCookie.setHttpOnly(true); + // Set secure cookie if only HTTPS is used + userCookie.setSecure(httpsOnly()); } } String path = "/"; @@ -622,6 +627,15 @@ public void setCookie(HttpServletRequest request, HttpServletResponse response, } } + + private boolean httpsOnly() { + int port = settings.getInteger(Keys.server.httpPort, 0); + int tlsPort = settings.getInteger(Keys.server.httpsPort, 0); + return (port <= 0 && tlsPort > 0) || + (port > 0 && tlsPort > 0 && settings.getBoolean(Keys.server.redirectToHttpsPort, true) ); + } + + /** * Logout a user. * From 60099a42faf7c34edb4651253cdb1a7723fbf029 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Sat, 10 Dec 2016 11:30:28 +0100 Subject: [PATCH 13/66] Set secure session cookies when redirecting from HTTP to HTTPS. So far for session cookies the secure property was only set when no HTTP port was opened. This changes to also set it when HTTP is redirected to the HTTPS port. --- src/main/java/com/gitblit/GitBlitServer.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gitblit/GitBlitServer.java b/src/main/java/com/gitblit/GitBlitServer.java index d56d9c0c6..6123a872d 100644 --- a/src/main/java/com/gitblit/GitBlitServer.java +++ b/src/main/java/com/gitblit/GitBlitServer.java @@ -375,7 +375,8 @@ public void log(String message) { HashSessionManager sessionManager = new HashSessionManager(); sessionManager.setHttpOnly(true); // Use secure cookies if only serving https - sessionManager.setSecureRequestOnly(params.port <= 0 && params.securePort > 0); + sessionManager.setSecureRequestOnly( (params.port <= 0 && params.securePort > 0) || + (params.port > 0 && params.securePort > 0 && settings.getBoolean(Keys.server.redirectToHttpsPort, true)) ); rootContext.getSessionHandler().setSessionManager(sessionManager); // Ensure there is a defined User Service From a1d8cfcf60bf23136512afad95edc3faede7108c Mon Sep 17 00:00:00 2001 From: Glenn Matthys Date: Fri, 8 Jan 2016 12:48:21 +0100 Subject: [PATCH 14/66] Introduce new constant REGEX_TICKET_MENTION --- src/main/java/com/gitblit/Constants.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/gitblit/Constants.java b/src/main/java/com/gitblit/Constants.java index 6232552e1..acfc20422 100644 --- a/src/main/java/com/gitblit/Constants.java +++ b/src/main/java/com/gitblit/Constants.java @@ -62,6 +62,12 @@ public class Constants { public static final String GIT_PATH = "/git/"; public static final String REGEX_SHA256 = "[a-fA-F0-9]{64}"; + + /** + * This regular expression is used when searching for "mentions" in tickets + * (when someone writes @thisOtherUser) + */ + public static final String REGEX_TICKET_MENTION = "\\s@([^\\s]+)"; public static final String ZIP_PATH = "/zip/"; From 0e4eebcfd98d079c93c33510284cdfe976e27725 Mon Sep 17 00:00:00 2001 From: Glenn Matthys Date: Fri, 8 Jan 2016 12:48:47 +0100 Subject: [PATCH 15/66] Use REGEX_TICKET_MENTION instead of hardcoded regular expression --- src/main/java/com/gitblit/models/TicketModel.java | 4 +++- src/main/java/com/gitblit/tickets/TicketNotifier.java | 2 +- src/main/java/com/gitblit/utils/MarkdownUtils.java | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java index d53458919..924400f53 100644 --- a/src/main/java/com/gitblit/models/TicketModel.java +++ b/src/main/java/com/gitblit/models/TicketModel.java @@ -43,6 +43,8 @@ import org.eclipse.jgit.util.RelativeDateFormatter; +import com.gitblit.Constants; + /** * The Gitblit Ticket model, its component classes, and enums. * @@ -773,7 +775,7 @@ public Comment comment(String text) { } try { - Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)"); + Pattern mentions = Pattern.compile(Constants.REGEX_TICKET_MENTION); Matcher m = mentions.matcher(text); while (m.find()) { String username = m.group(1); diff --git a/src/main/java/com/gitblit/tickets/TicketNotifier.java b/src/main/java/com/gitblit/tickets/TicketNotifier.java index 8c7fe6d46..1d7e4f243 100644 --- a/src/main/java/com/gitblit/tickets/TicketNotifier.java +++ b/src/main/java/com/gitblit/tickets/TicketNotifier.java @@ -573,7 +573,7 @@ protected void setRecipients(TicketModel ticket, Mailing mailing) { // cc users mentioned in last comment Change lastChange = ticket.changes.get(ticket.changes.size() - 1); if (lastChange.hasComment()) { - Pattern p = Pattern.compile("\\s@([A-Za-z0-9-_]+)"); + Pattern p = Pattern.compile(Constants.REGEX_TICKET_MENTION); Matcher m = p.matcher(lastChange.comment.text); while (m.find()) { String username = m.group(); diff --git a/src/main/java/com/gitblit/utils/MarkdownUtils.java b/src/main/java/com/gitblit/utils/MarkdownUtils.java index e0c9dd4e7..794d54ab1 100644 --- a/src/main/java/com/gitblit/utils/MarkdownUtils.java +++ b/src/main/java/com/gitblit/utils/MarkdownUtils.java @@ -30,6 +30,7 @@ import org.pegdown.PegDownProcessor; import org.pegdown.ast.RootNode; +import com.gitblit.Constants; import com.gitblit.IStoredSettings; import com.gitblit.Keys; import com.gitblit.wicket.MarkupProcessor.WorkaroundHtmlSerializer; @@ -138,7 +139,7 @@ public static String transformGFM(IStoredSettings settings, String input, String // emphasize and link mentions String mentionReplacement = String.format(" **[@$1](%1s/user/$1)**", canonicalUrl); - text = text.replaceAll("\\s@([A-Za-z0-9-_]+)", mentionReplacement); + text = text.replaceAll(Constants.REGEX_TICKET_MENTION, mentionReplacement); // link ticket refs String ticketReplacement = MessageFormat.format("$1[#$2]({0}/tickets?r={1}&h=$2)$3", canonicalUrl, repositoryName); From 7985115bd1301db867935b52a689ccfc32f13794 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Sat, 10 Dec 2016 16:02:21 +0100 Subject: [PATCH 16/66] Fix user mention regular expression and group replacement. The regular expression used for user mentions used to work only inside sentences. Also, since it tested for whitespace, the whitespace would get replaced, too, which would join lines together. Instead the new regex uses boundary matchers to match against word boundaires. As these are not capturing only the actual user mention can be captured and is then replaced. Also, this way the regex can ignore punctuation like in "@jim, look at this." Since Gibtlit now requires Java 7 we can use named capture groups. This makes the use of a centrally defined regular expression much safer. The (admittedly only) group to capture the user name is named "user" and can be referenced by this name. By using the name instead of a group number, the regex could be changed without the code using it breaking because the group number changed. A simple test is added for user mentions, which unfortunately has to deal with the full markdown replacement, too. Fixes #985 --- src/main/java/com/gitblit/Constants.java | 2 +- .../java/com/gitblit/models/TicketModel.java | 2 +- .../com/gitblit/tickets/TicketNotifier.java | 2 +- .../java/com/gitblit/utils/MarkdownUtils.java | 2 +- .../com/gitblit/tests/MarkdownUtilsTest.java | 74 ++++++++++++++++++- 5 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/gitblit/Constants.java b/src/main/java/com/gitblit/Constants.java index acfc20422..c71128359 100644 --- a/src/main/java/com/gitblit/Constants.java +++ b/src/main/java/com/gitblit/Constants.java @@ -67,7 +67,7 @@ public class Constants { * This regular expression is used when searching for "mentions" in tickets * (when someone writes @thisOtherUser) */ - public static final String REGEX_TICKET_MENTION = "\\s@([^\\s]+)"; + public static final String REGEX_TICKET_MENTION = "\\B@(?[^\\s]+)\\b"; public static final String ZIP_PATH = "/zip/"; diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java index 924400f53..65e29dc06 100644 --- a/src/main/java/com/gitblit/models/TicketModel.java +++ b/src/main/java/com/gitblit/models/TicketModel.java @@ -778,7 +778,7 @@ public Comment comment(String text) { Pattern mentions = Pattern.compile(Constants.REGEX_TICKET_MENTION); Matcher m = mentions.matcher(text); while (m.find()) { - String username = m.group(1); + String username = m.group("user"); plusList(Field.mentions, username); } } catch (Exception e) { diff --git a/src/main/java/com/gitblit/tickets/TicketNotifier.java b/src/main/java/com/gitblit/tickets/TicketNotifier.java index 1d7e4f243..b913db257 100644 --- a/src/main/java/com/gitblit/tickets/TicketNotifier.java +++ b/src/main/java/com/gitblit/tickets/TicketNotifier.java @@ -576,7 +576,7 @@ protected void setRecipients(TicketModel ticket, Mailing mailing) { Pattern p = Pattern.compile(Constants.REGEX_TICKET_MENTION); Matcher m = p.matcher(lastChange.comment.text); while (m.find()) { - String username = m.group(); + String username = m.group("user"); ccs.add(username); } } diff --git a/src/main/java/com/gitblit/utils/MarkdownUtils.java b/src/main/java/com/gitblit/utils/MarkdownUtils.java index 794d54ab1..8371b3c64 100644 --- a/src/main/java/com/gitblit/utils/MarkdownUtils.java +++ b/src/main/java/com/gitblit/utils/MarkdownUtils.java @@ -138,7 +138,7 @@ public static String transformGFM(IStoredSettings settings, String input, String String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); // emphasize and link mentions - String mentionReplacement = String.format(" **[@$1](%1s/user/$1)**", canonicalUrl); + String mentionReplacement = String.format("**[@${user}](%1s/user/${user})**", canonicalUrl); text = text.replaceAll(Constants.REGEX_TICKET_MENTION, mentionReplacement); // link ticket refs diff --git a/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java b/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java index e40f1057a..bc7aad49a 100644 --- a/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java +++ b/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java @@ -15,8 +15,14 @@ */ package com.gitblit.tests; +import java.util.HashMap; +import java.util.Map; + import org.junit.Test; +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.tests.mock.MemorySettings; import com.gitblit.utils.MarkdownUtils; public class MarkdownUtilsTest extends GitblitUnitTest { @@ -39,4 +45,70 @@ public void testMarkdown() throws Exception { assertEquals("
<test>
", MarkdownUtils.transformMarkdown("
<test>
")); } -} \ No newline at end of file + + + @Test + public void testUserMentions() { + IStoredSettings settings = getSettings(); + String repositoryName = "test3"; + String mentionHtml = "@%1$s"; + + String input = "@j.doe"; + String output = "

" + String.format(mentionHtml, "j.doe") + "

"; + assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName)); + + input = " @j.doe"; + output = "

" + String.format(mentionHtml, "j.doe") + "

"; + assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName)); + + input = "@j.doe."; + output = "

" + String.format(mentionHtml, "j.doe") + ".

"; + assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName)); + + input = "To @j.doe: ask @jim.beam!"; + output = "

To " + String.format(mentionHtml, "j.doe") + + ": ask " + String.format(mentionHtml, "jim.beam") + "!

"; + assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName)); + + input = "@sta.rt\n" + + "\n" + + "User mentions in tickets are broken.\n" + + "So:\n" + + "@mc_guyver can fix this.\n" + + "@j.doe, can you test after the fix by @m+guyver?\n" + + "Please review this, @jim.beam!\n" + + "Was reported by @jill and @j!doe from jane@doe yesterday.\n" + + "\n" + + "@jack.daniels can vote for john@wayne.name hopefully.\n" + + "@en.de"; + output = "

" + String.format(mentionHtml, "sta.rt") + "

" + + "

" + "User mentions in tickets are broken.
" + + "So:
" + + String.format(mentionHtml, "mc_guyver") + " can fix this.
" + + String.format(mentionHtml, "j.doe") + ", can you test after the fix by " + String.format(mentionHtml, "m+guyver") + "?
" + + "Please review this, " + String.format(mentionHtml, "jim.beam") + "!
" + + "Was reported by " + String.format(mentionHtml, "jill") + + " and " + String.format(mentionHtml, "j!doe") + + " from jane@doe yesterday." + + "

" + + "

" + String.format(mentionHtml, "jack.daniels") + " can vote for " + + "john@wayne.name hopefully.
" + + String.format(mentionHtml, "en.de") + + "

"; + assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName)); + + } + + + + + private MemorySettings getSettings() { + Map backingMap = new HashMap(); + + backingMap.put(Keys.web.canonicalUrl, "http://localhost"); + backingMap.put(Keys.web.shortCommitIdLength, "7"); + + MemorySettings ms = new MemorySettings(backingMap); + return ms; + } +} From a1fc7e7228d7b8de05bc2cf074f112af757401d0 Mon Sep 17 00:00:00 2001 From: rcaa Date: Sun, 11 Dec 2016 19:12:27 -0300 Subject: [PATCH 17/66] changing Math.random to SecureRandom --- src/main/java/com/gitblit/models/UserModel.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java index d411e5040..edbdf028b 100644 --- a/src/main/java/com/gitblit/models/UserModel.java +++ b/src/main/java/com/gitblit/models/UserModel.java @@ -17,6 +17,7 @@ import java.io.Serializable; import java.security.Principal; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -662,6 +663,9 @@ public boolean isMyPersonalRepository(String repository) { } public String createCookie() { - return StringUtils.getSHA1(String.valueOf(Math.random())); + SecureRandom random = new SecureRandom(); + byte[] values = new byte[20]; + random.nextBytes(values); + return StringUtils.getSHA1(String.valueOf(values)); } } From 2be2c2c95c9a3747fd200e3ea3623607053d5299 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Sat, 10 Dec 2016 01:00:27 +0100 Subject: [PATCH 18/66] Introduce SecureRandom wrapper for properly seeded static instances Introduce our own wrapper `SecureRandom` around `java.security.SecureRandom`. This a) makes sure that the PRNG is seeded on creation and not when random bytes are retrieved, and b) uses a static instance in the `UserModel` so that lags do not occur during operation due to potentially seeding getting blocked on Unix when reading from the system's entropy pool. To keep the random data still secure, the static instance will reseed all 24 hours, also a functionality of the wrapper class. This fixes #1063 and extends and closes PR #1116 --- .../java/com/gitblit/models/UserModel.java | 10 +-- .../java/com/gitblit/utils/SecureRandom.java | 83 +++++++++++++++++++ .../com/gitblit/utils/SecureRandomTest.java | 33 ++++++++ 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/gitblit/utils/SecureRandom.java create mode 100644 src/test/java/com/gitblit/utils/SecureRandomTest.java diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java index edbdf028b..f8f7ed6dc 100644 --- a/src/main/java/com/gitblit/models/UserModel.java +++ b/src/main/java/com/gitblit/models/UserModel.java @@ -37,6 +37,7 @@ import com.gitblit.Constants.RegistrantType; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.ModelUtils; +import com.gitblit.utils.SecureRandom; import com.gitblit.utils.StringUtils; /** @@ -53,6 +54,8 @@ public class UserModel implements Principal, Serializable, Comparable public static final UserModel ANONYMOUS = new UserModel(); + private static final SecureRandom RANDOM = new SecureRandom(); + // field names are reflectively mapped in EditUser page public String username; public String password; @@ -661,11 +664,8 @@ public boolean isMyPersonalRepository(String repository) { String projectPath = StringUtils.getFirstPathElement(repository); return !StringUtils.isEmpty(projectPath) && projectPath.equalsIgnoreCase(getPersonalPath()); } - + public String createCookie() { - SecureRandom random = new SecureRandom(); - byte[] values = new byte[20]; - random.nextBytes(values); - return StringUtils.getSHA1(String.valueOf(values)); + return StringUtils.getSHA1(RANDOM.randomBytes(32)); } } diff --git a/src/main/java/com/gitblit/utils/SecureRandom.java b/src/main/java/com/gitblit/utils/SecureRandom.java new file mode 100644 index 000000000..119533d41 --- /dev/null +++ b/src/main/java/com/gitblit/utils/SecureRandom.java @@ -0,0 +1,83 @@ +/* + * Copyright 2016 gitblit.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.utils; + +/** + * Wrapper class for java.security.SecureRandom, which will periodically reseed + * the PRNG in case an instance of the class has been running for a long time. + * + * @author Florian Zschocke + */ +public class SecureRandom { + + /** Period (in ms) after which a new SecureRandom will be created in order to get a fresh random seed. */ + private static final long RESEED_PERIOD = 24 * 60 * 60 * 1000; /* 24 hours */ + + + private long last; + private java.security.SecureRandom random; + + + + public SecureRandom() { + // Make sure the SecureRandom is seeded right from the start. + // This also lets any blocks during seeding occur at creation + // and prevents it from happening when getting next random bytes. + seed(); + } + + + + public byte[] randomBytes(int num) { + byte[] bytes = new byte[num]; + nextBytes(bytes); + return bytes; + } + + + public void nextBytes(byte[] bytes) { + random.nextBytes(bytes); + reseed(false); + } + + + void reseed(boolean forced) { + long ts = System.currentTimeMillis(); + if (forced || (ts - last) > RESEED_PERIOD) { + last = ts; + runReseed(); + } + } + + + + private void seed() { + random = new java.security.SecureRandom(); + random.nextBytes(new byte[0]); + last = System.currentTimeMillis(); + } + + + private void runReseed() { + // Have some other thread hit the penalty potentially incurred by reseeding, + // so that we can immediately return and not block the operation in progress. + new Thread() { + public void run() { + seed(); + } + }.start(); + } +} diff --git a/src/test/java/com/gitblit/utils/SecureRandomTest.java b/src/test/java/com/gitblit/utils/SecureRandomTest.java new file mode 100644 index 000000000..c4098c2ff --- /dev/null +++ b/src/test/java/com/gitblit/utils/SecureRandomTest.java @@ -0,0 +1,33 @@ +package com.gitblit.utils; + +import static org.junit.Assert.*; + +import java.util.Arrays; + +import org.junit.Test; + +public class SecureRandomTest { + + @Test + public void testRandomBytes() { + SecureRandom sr = new SecureRandom(); + byte[] bytes1 = sr.randomBytes(10); + assertEquals(10, bytes1.length); + byte[] bytes2 = sr.randomBytes(10); + assertEquals(10, bytes2.length); + assertFalse(Arrays.equals(bytes1, bytes2)); + + assertEquals(0, sr.randomBytes(0).length); + assertEquals(200, sr.randomBytes(200).length); + } + + @Test + public void testNextBytes() { + SecureRandom sr = new SecureRandom(); + byte[] bytes1 = new byte[32]; + sr.nextBytes(bytes1); + byte[] bytes2 = new byte[32]; + sr.nextBytes(bytes2); + assertFalse(Arrays.equals(bytes1, bytes2)); + } +} From 34b98a07b8f01015ddbafd4bdaad0458c25765ae Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Thu, 15 Dec 2016 20:28:37 +0100 Subject: [PATCH 19/66] Remove duplicate import of class SecureRandom Fixes the build that was broken by cherry-picking commit 2be2c2, which resulted in an import collision on the `SecureRandom` class. --- src/main/java/com/gitblit/models/UserModel.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java index f8f7ed6dc..1d9e413bb 100644 --- a/src/main/java/com/gitblit/models/UserModel.java +++ b/src/main/java/com/gitblit/models/UserModel.java @@ -17,7 +17,6 @@ import java.io.Serializable; import java.security.Principal; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; From 472b74324eb40fda5892fa4dfe3a35cd87e75a2a Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Fri, 16 Dec 2016 13:36:43 +0100 Subject: [PATCH 20/66] Fix typo in defaults.properties. --- src/main/distrib/data/defaults.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/distrib/data/defaults.properties b/src/main/distrib/data/defaults.properties index 4b12ba24b..9bb024823 100644 --- a/src/main/distrib/data/defaults.properties +++ b/src/main/distrib/data/defaults.properties @@ -301,7 +301,7 @@ git.defaultIncrementalPushTagPrefix = r # the repository should be created with 'git init --shared' to make sure that # it can be accessed e.g. via ssh (user git) and http (user www-data). # -# Valid values are the values available for the '--shared' option. The the manual +# Valid values are the values available for the '--shared' option. See the manual # page for 'git init' for more information on shared repositories. # # SINCE 1.4.0 From 99b4a1898f0c3533062263cda18b456f099ee2cf Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Mon, 19 Dec 2016 10:08:42 +0100 Subject: [PATCH 21/66] Add test resources path `src/test/resources` to project configuration --- .classpath | 1 + build.moxie | 1 + gitblit.iml | 1 + 3 files changed, 3 insertions(+) diff --git a/.classpath b/.classpath index ccf6a4e03..0f7c09a79 100644 --- a/.classpath +++ b/.classpath @@ -5,6 +5,7 @@ + diff --git a/build.moxie b/build.moxie index e84ab4010..5cb08b683 100644 --- a/build.moxie +++ b/build.moxie @@ -66,6 +66,7 @@ sourceDirectories: resourceDirectories: - compile 'src/main/resources' +- test 'src/test/resources' - site 'src/site/resources' # compile for Java 7 class format diff --git a/gitblit.iml b/gitblit.iml index 93331b20b..163491bc5 100644 --- a/gitblit.iml +++ b/gitblit.iml @@ -11,6 +11,7 @@ + From 23072ffb51710d0c850adaa2bac241f3d91c390b Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Wed, 21 Dec 2016 22:02:46 +0100 Subject: [PATCH 22/66] Update to explicit versions of JUnit 4.12 and JaCoCo 0.7.8 Use explicit coordinates, and therefor version numbers fro JUnit in the build.moxie file. It should not be some version that just happens to be used. Update JUnit to latest 4.12. Update JaCoCo to lates 0.7.8, which makes it work under Java 8. The last used version would fail when tests are run under Java 8. --- .classpath | 2 +- build.moxie | 4 ++-- gitblit.iml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.classpath b/.classpath index 0f7c09a79..095cb036f 100644 --- a/.classpath +++ b/.classpath @@ -80,7 +80,7 @@ - + diff --git a/build.moxie b/build.moxie index 5cb08b683..07208cc38 100644 --- a/build.moxie +++ b/build.moxie @@ -181,7 +181,7 @@ dependencies: - compile 'ro.fortsoft.pf4j:pf4j:0.9.0' :war - compile 'org.apache.tika:tika-core:1.5' :war - compile 'org.jsoup:jsoup:1.7.3' :war -- test 'junit' +- test 'junit:junit:4.12' # Dependencies for Selenium web page testing - test 'org.seleniumhq.selenium:selenium-java:${selenium.version}' @jar - test 'org.seleniumhq.selenium:selenium-support:${selenium.version}' @jar @@ -189,4 +189,4 @@ dependencies: - test 'org.mockito:mockito-core:1.10.19' # Dependencies with the "build" scope are retrieved # and injected into the Ant runtime classpath -- build 'jacoco' +- build 'org.jacoco:org.jacoco.ant:0.7.8' diff --git a/gitblit.iml b/gitblit.iml index 163491bc5..71907a598 100644 --- a/gitblit.iml +++ b/gitblit.iml @@ -823,13 +823,13 @@ - + - + - + From da6a2611ed8cb73856ad41a39a322596fdd9ea05 Mon Sep 17 00:00:00 2001 From: de4c9d Date: Fri, 30 Dec 2016 17:09:16 +0100 Subject: [PATCH 23/66] update user manager to support instantiation if IUserService with IRuntimeManager as a parameter --- src/main/java/com/gitblit/IUserService.java | 3 +++ src/main/java/com/gitblit/manager/UserManager.java | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/gitblit/IUserService.java b/src/main/java/com/gitblit/IUserService.java index 6f3c54233..468f968fc 100644 --- a/src/main/java/com/gitblit/IUserService.java +++ b/src/main/java/com/gitblit/IUserService.java @@ -26,6 +26,9 @@ * Implementations of IUserService control all aspects of UserModel objects and * user authentication. * + * Plugins implementing this interface (which are instantiated during {@link com.gitblit.manager.UserManager#start()}) can provide + * a default constructor or might also use {@link IRuntimeManager} as a constructor argument which will be passed automatically then. + * * @author James Moger * */ diff --git a/src/main/java/com/gitblit/manager/UserManager.java b/src/main/java/com/gitblit/manager/UserManager.java index e88ac93c2..ba0cb35e6 100644 --- a/src/main/java/com/gitblit/manager/UserManager.java +++ b/src/main/java/com/gitblit/manager/UserManager.java @@ -17,6 +17,8 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collection; @@ -119,8 +121,15 @@ public UserManager start() { // typical file path configuration File realmFile = runtimeManager.getFileOrFolder(Keys.realm.userService, "${baseFolder}/users.conf"); service = createUserService(realmFile); - } catch (InstantiationException | IllegalAccessException e) { - logger.error("failed to instantiate user service {}: {}", realm, e.getMessage()); + } catch (InstantiationException | IllegalAccessException e1) { + logger.error("failed to instantiate user service {}: {}. Trying once again with IRuntimeManager constructor", realm, e1.getMessage()); + //try once again with file constructor. this adds support for subclasses of ConfigUserService + try { + Constructor constructor = Class.forName(realm).getConstructor(IRuntimeManager.class); + service = (IUserService) constructor.newInstance(runtimeManager); + } catch (NoSuchMethodException | SecurityException | ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e2) { + logger.error("failed to instantiate user service {}: {}", realm, e2.getMessage()); + } } } setUserService(service); From a6ae27a472af1260ab078edb7199f60086b56a4b Mon Sep 17 00:00:00 2001 From: de4c9d Date: Fri, 30 Dec 2016 18:19:58 +0100 Subject: [PATCH 24/66] updated comment --- src/main/java/com/gitblit/manager/UserManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/gitblit/manager/UserManager.java b/src/main/java/com/gitblit/manager/UserManager.java index ba0cb35e6..2e68e40db 100644 --- a/src/main/java/com/gitblit/manager/UserManager.java +++ b/src/main/java/com/gitblit/manager/UserManager.java @@ -123,7 +123,7 @@ public UserManager start() { service = createUserService(realmFile); } catch (InstantiationException | IllegalAccessException e1) { logger.error("failed to instantiate user service {}: {}. Trying once again with IRuntimeManager constructor", realm, e1.getMessage()); - //try once again with file constructor. this adds support for subclasses of ConfigUserService + //try once again with IRuntimeManager constructor. This adds support for subclasses of ConfigUserService and other custom IUserServices try { Constructor constructor = Class.forName(realm).getConstructor(IRuntimeManager.class); service = (IUserService) constructor.newInstance(runtimeManager); From ac1e8f8e5aa2ec074668ec45d5c64633907743ea Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Mon, 12 Dec 2016 13:34:17 +0100 Subject: [PATCH 25/66] Add definition file for Travis CI Add the most basic build definition file for Travis CI. It only defines the project language as Java. For the rest the defaults are kept as Travis seems to work fine with them. We add `.travis.yml` as a dotfile in order not to clutter the top directory with too much non-project files. --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..a98b76035 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,2 @@ +language: java + From 43f642bad9e3983f566c1166316f88cd69ce6318 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Thu, 15 Dec 2016 22:10:37 +0100 Subject: [PATCH 26/66] Add build definition file for Circle CI Configure the build for Circle CI in the new file circle.yml. Specify a compile step to have the build fail on compilation error. The test step is then configured as `ant test`, which will compile again due to the limits of Ant/Moxie. Contrary to the documentation, the default Java version on Circle CI is Java 8. The project is set as a Java 7 project. We define to use OpenJDK 7, because the Gitblit build has some trouble with Java 8, I consider Java 7 the default, and Circle CI does not provide an Oracle JDK 7 installation to use. I could only get it to work with OpenJDK 7. The Java version is reported in the Circle CI build script to ease analysis. Test and coverage reports get stored as artifacts for a build, which allows to browse them in the Circle CI web interface. --- circle.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 circle.yml diff --git a/circle.yml b/circle.yml new file mode 100644 index 000000000..fcf878675 --- /dev/null +++ b/circle.yml @@ -0,0 +1,22 @@ +machine: + java: + version: openjdk7 + +compile: + pre: + - java -version + - javac -version + override: + - ant + +test: + override: + - ant test + post: + - mkdir -p $CIRCLE_TEST_REPORTS/junit/ + - cp -a build/tests/TEST-*.xml $CIRCLE_TEST_REPORTS/junit/ + +general: + artifacts: + - "build/target/reports" + From 80f29044535d7a862303fa6bb9d130ac4b106c07 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Mon, 2 Jan 2017 15:47:32 +0100 Subject: [PATCH 27/66] Increase minor version number to 9 Bump version to 1.9.0-SNAPSHOT, increasing the minor as the next release includes interface changes. --- build.moxie | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.moxie b/build.moxie index 07208cc38..eb05e2c81 100644 --- a/build.moxie +++ b/build.moxie @@ -10,7 +10,7 @@ name: Gitblit description: pure Java Git solution groupId: com.gitblit artifactId: gitblit -version: 1.8.1-SNAPSHOT +version: 1.9.0-SNAPSHOT inceptionYear: 2011 # Current stable release From 84c827a3d34ac4d37622c73e8e8140eab3e0e910 Mon Sep 17 00:00:00 2001 From: "DONGSU, KIM" Date: Thu, 5 Jan 2017 15:48:45 +0900 Subject: [PATCH 28/66] Update korean translation for gitblit new version. --- .../wicket/GitBlitWebApp_ko.properties | 1477 +++++++++-------- 1 file changed, 759 insertions(+), 718 deletions(-) diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp_ko.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp_ko.properties index 404f0d283..031ff8dbd 100644 --- a/src/main/java/com/gitblit/wicket/GitBlitWebApp_ko.properties +++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp_ko.properties @@ -1,744 +1,785 @@ -gb.repository = \uc800\uc7a5\uc18c -gb.owner = \uc18c\uc720\uc790 -gb.description = \uc124\uba85 -gb.lastChange = \ucd5c\uadfc \ubcc0\uacbd +gb.repository = \uC800\uC7A5\uC18C +gb.owner = \uC18C\uC720\uC790 +gb.description = \uC124\uBA85 +gb.lastChange = \uCD5C\uADFC \uBCC0\uACBD gb.refs = refs -gb.tag = \ud0dc\uadf8 -gb.tags = \ud0dc\uadf8\ub4e4 -gb.author = \uc791\uc131\uc790 -gb.committer = \ucee4\ubbf8\ud130 -gb.commit = \ucee4\ubc0b -gb.age = \ub098\uc774 -gb.tree = \ud2b8\ub9ac -gb.parent = \ubd80\ubaa8 +gb.tag = \uD0DC\uADF8 +gb.tags = \uD0DC\uADF8\uB4E4 +gb.author = \uC791\uC131\uC790 +gb.committer = \uCEE4\uBBF8\uD130 +gb.commit = \uCEE4\uBC0B +gb.age = \uB098\uC774 +gb.tree = \uD2B8\uB9AC +gb.parent = \uBD80\uBAA8 gb.url = URL -gb.history = \ud788\uc2a4\ud1a0\ub9ac +gb.history = \uD788\uC2A4\uD1A0\uB9AC gb.raw = raw -gb.object = \uc624\ube0c\uc81d\ud2b8 -gb.ticketId = \ud2f0\ucf13 id -gb.ticketAssigned = \ud560\ub2f9 -gb.ticketOpenDate = \uc5f4\ub9b0 \ub0a0\uc790 -gb.ticketState = \uc0c1\ud0dc -gb.ticketComments = \ucf54\uba58\ud2b8 -gb.view = \ubcf4\uae30 -gb.local = \ub85c\uceec -gb.remote = \ub9ac\ubaa8\ud2b8 -gb.branches = \ube0c\ub79c\uce58 -gb.patch = \ud328\uce58 -gb.diff = \ube44\uad50 -gb.log = \ub85c\uadf8 -gb.moreLogs = \ucee4\ubc0b \ub354 \ubcf4\uae30... -gb.allTags = \ubaa8\ub4e0 \ud0dc\uadf8... -gb.allBranches = \ubaa8\ub4e0 \ube0c\ub79c\uce58... -gb.summary = \uc694\uc57d -gb.ticket = \ud2f0\ucf13 -gb.newRepository = \uc0c8 \uc800\uc7a5\uc18c -gb.newUser = \uc0c8 \uc0ac\uc6a9\uc790 -gb.commitdiff = \ucee4\ubc0b\ube44\uad50 -gb.tickets = \ud2f0\ucf13 -gb.pageFirst = \ucc98\uc74c -gb.pagePrevious = \uc774\uc804 -gb.pageNext = \ub2e4\uc74c +gb.object = \uC624\uBE0C\uC81D\uD2B8 +gb.ticketId = \uD2F0\uCF13 id +gb.ticketAssigned = \uD560\uB2F9 +gb.ticketOpenDate = \uC5F4\uB9B0 \uB0A0\uC790 +gb.ticketStatus = \uC0C1\uD0DC +gb.ticketComments = \uCF54\uBA58\uD2B8 +gb.view = \uBCF4\uAE30 +gb.local = \uB85C\uCEEC +gb.remote = \uB9AC\uBAA8\uD2B8 +gb.branches = \uBE0C\uB79C\uCE58 +gb.patch = \uD328\uCE58 +gb.diff = \uBE44\uAD50 +gb.log = \uB85C\uADF8 +gb.moreLogs = \uCEE4\uBC0B \uB354 \uBCF4\uAE30... +gb.allTags = \uBAA8\uB4E0 \uD0DC\uADF8... +gb.allBranches = \uBAA8\uB4E0 \uBE0C\uB79C\uCE58... +gb.summary = \uC694\uC57D +gb.ticket = \uD2F0\uCF13 +gb.newRepository = \uC0C8 \uC800\uC7A5\uC18C +gb.newUser = \uC0C8 \uC0AC\uC6A9\uC790 +gb.commitdiff = \uCEE4\uBC0B\uBE44\uAD50 +gb.tickets = \uD2F0\uCF13 +gb.pageFirst = \uCC98\uC74C +gb.pagePrevious = \uC774\uC804 +gb.pageNext = \uB2E4\uC74C gb.head = HEAD gb.blame = blame -gb.login = \ub85c\uadf8\uc778 -gb.logout = \ub85c\uadf8\uc544\uc6c3 -gb.username = \uc720\uc800\ub124\uc784 -gb.password = \ud328\uc2a4\uc6cc\ub4dc -gb.tagger = \ud0dc\uac70 -gb.moreHistory = \ud788\uc2a4\ud1a0\ub9ac \ub354 \ubcf4\uae30... -gb.difftocurrent = \ud604\uc7ac\uc640 \ube44\uad50 -gb.search = \uac80\uc0c9 -gb.searchForAuthor = \ucee4\ubc0b\uc744 \uc791\uc131\uc790\ub85c \uac80\uc0c9 -gb.searchForCommitter = \ucee4\ubc0b\uc744 \ucee4\ubc0b\ud130\ub85c \uac80\uc0c9 -gb.addition = \ucd94\uac00 -gb.modification = \ubcc0\uacbd -gb.deletion = \uc0ad\uc81c -gb.rename = \uc774\ub984\ubcc0\uacbd -gb.metrics = \uba54\ud2b8\ub9ad -gb.stats = \uc0c1\ud0dc -gb.markdown = \ub9c8\ud06c\ub2e4\uc6b4 -gb.changedFiles = \ud30c\uc77c \ubcc0\uacbd\ub428 -gb.filesAdded = {0}\uac1c \ud30c\uc77c \ucd94\uac00\ub428 -gb.filesModified = {0}\uac1c \ud30c\uc77c \ubcc0\uacbd\ub428 -gb.filesDeleted = {0}\uac1c \ud30c\uc77c \uc0ad\uc81c\ub428 -gb.filesCopied = {0}\uac1c \ud30c\uc77c \ubcf5\uc0ac\ub428 -gb.filesRenamed = {0}\uac1c \ud30c\uc77c \uc774\ub984 \ubcc0\uacbd\ub428 -gb.missingUsername = \uc720\uc800\ub124\uc784 \ub204\ub77d -gb.edit = \uc218\uc815 -gb.searchTypeTooltip = \uac80\uc0c9 \ud0c0\uc785 \uc120\ud0dd -gb.searchTooltip = {0} \uac80\uc0c9 -gb.delete = \uc0ad\uc81c -gb.docs = \ubb38\uc11c -gb.accessRestriction = \uc811\uc18d \uc81c\ud55c -gb.name = \uc774\ub984 -gb.enableTickets = \ud2f0\ucf13 \uc0ac\uc6a9 -gb.enableDocs = \ubb38\uc11c \uc0ac\uc6a9 -gb.save = \uc800\uc7a5 -gb.showRemoteBranches = \ub9ac\ubaa8\ud2b8 \ube0c\ub79c\uce58 \ubcf4\uae30 -gb.editUsers = \uc720\uc800 \uc218\uc815 -gb.confirmPassword = \ud328\uc2a4\uc6cc\ub4dc \ud655\uc778 -gb.restrictedRepositories = \uc81c\ud55c\ub41c \uc800\uc7a5\uc18c -gb.canAdmin = \uad00\ub9ac \uac00\ub2a5 -gb.notRestricted = \uc775\uba85 \ubdf0, \ud074\ub860, & \ud478\uc2dc -gb.pushRestricted = \uc778\uc99d\ub41c \uc720\uc800\ub9cc \ud478\uc2dc -gb.cloneRestricted = \uc778\uc99d\ub41c \uc720\uc800\ub9cc \ud074\ub860 & \ud478\uc2dc -gb.viewRestricted = \uc778\uc99d\ub41c \uc720\uc800\ub9cc \ubdf0, \ud074\ub860, & \ud478\uc2dc -gb.useTicketsDescription = Ticgit(\ubd84\uc0b0 \ud2f0\ucf13 \uc2dc\uc2a4\ud15c) \uc774\uc288 \uc0ac\uc6a9 -gb.useDocsDescription = \uc800\uc7a5\uc18c \uc788\ub294 \ub9c8\ud06c\ub2e4\uc6b4 \ubb38\uc11c \uc0ac\uc6a9 -gb.showRemoteBranchesDescription = \ub9ac\ubaa8\ud2b8 \ube0c\ub79c\uce58 \ubcf4\uae30 -gb.canAdminDescription = Gitblit \uad00\ub9ac \uad8c\ud55c \ubd80\uc5ec -gb.permittedUsers = \ud5c8\uc6a9\ub41c \uc0ac\uc6a9\uc790 -gb.isFrozen = \ud504\ub9ac\uc9d5\ub428 -gb.isFrozenDescription = \ud478\uc2dc \ucc28\ub2e8 +gb.login = \uB85C\uADF8\uC778 +gb.logout = \uB85C\uADF8\uC544\uC6C3 +gb.username = \uC720\uC800\uB124\uC784 +gb.password = \uD328\uC2A4\uC6CC\uB4DC +gb.tagger = \uD0DC\uAC70 +gb.moreHistory = \uD788\uC2A4\uD1A0\uB9AC \uB354 \uBCF4\uAE30... +gb.difftocurrent = \uD604\uC7AC\uC640 \uBE44\uAD50 +gb.search = \uAC80\uC0C9 +gb.searchForAuthor = \uCEE4\uBC0B\uC744 \uC791\uC131\uC790\uB85C \uAC80\uC0C9 +gb.searchForCommitter = \uCEE4\uBC0B\uC744 \uCEE4\uBC0B\uD130\uB85C \uAC80\uC0C9 +gb.addition = \uCD94\uAC00 +gb.modification = \uBCC0\uACBD +gb.deletion = \uC0AD\uC81C +gb.rename = \uC774\uB984\uBCC0\uACBD +gb.metrics = \uBA54\uD2B8\uB9AD +gb.stats = \uC0C1\uD0DC +gb.markdown = \uB9C8\uD06C\uB2E4\uC6B4 +gb.changedFiles = \uD30C\uC77C \uBCC0\uACBD\uB428 +gb.filesAdded = {0}\uAC1C \uD30C\uC77C \uCD94\uAC00\uB428 +gb.filesModified = {0}\uAC1C \uD30C\uC77C \uBCC0\uACBD\uB428 +gb.filesDeleted = {0}\uAC1C \uD30C\uC77C \uC0AD\uC81C\uB428 +gb.filesCopied = {0}\uAC1C \uD30C\uC77C \uBCF5\uC0AC\uB428 +gb.filesRenamed = {0}\uAC1C \uD30C\uC77C \uC774\uB984 \uBCC0\uACBD\uB428 +gb.missingUsername = \uC720\uC800\uB124\uC784 \uB204\uB77D +gb.edit = \uC218\uC815 +gb.searchTypeTooltip = \uAC80\uC0C9 \uD0C0\uC785 \uC120\uD0DD +gb.searchTooltip = {0} \uAC80\uC0C9 +gb.delete = \uC0AD\uC81C +gb.docs = \uBB38\uC11C +gb.accessRestriction = \uC811\uC18D \uC81C\uD55C +gb.name = \uC774\uB984 +gb.enableTickets = \uD2F0\uCF13 \uC0AC\uC6A9 +gb.enableDocs = \uBB38\uC11C \uC0AC\uC6A9 +gb.save = \uC800\uC7A5 +gb.showRemoteBranches = \uB9AC\uBAA8\uD2B8 \uBE0C\uB79C\uCE58 \uBCF4\uAE30 +gb.editUsers = \uC720\uC800 \uC218\uC815 +gb.confirmPassword = \uD328\uC2A4\uC6CC\uB4DC \uD655\uC778 +gb.restrictedRepositories = \uC81C\uD55C\uB41C \uC800\uC7A5\uC18C +gb.canAdmin = \uAD00\uB9AC \uAC00\uB2A5 +gb.notRestricted = \uC775\uBA85 \uBDF0, \uD074\uB860, & \uD478\uC2DC +gb.pushRestricted = \uC778\uC99D\uB41C \uC720\uC800\uB9CC \uD478\uC2DC +gb.cloneRestricted = \uC778\uC99D\uB41C \uC720\uC800\uB9CC \uD074\uB860 & \uD478\uC2DC +gb.viewRestricted = \uC778\uC99D\uB41C \uC720\uC800\uB9CC \uBDF0, \uD074\uB860, & \uD478\uC2DC +gb.useTicketsDescription = Ticgit(\uBD84\uC0B0 \uD2F0\uCF13 \uC2DC\uC2A4\uD15C) \uC774\uC288 \uC0AC\uC6A9 +gb.useDocsDescription = \uC800\uC7A5\uC18C \uC788\uB294 \uB9C8\uD06C\uB2E4\uC6B4 \uBB38\uC11C \uC0AC\uC6A9 +gb.showRemoteBranchesDescription = \uB9AC\uBAA8\uD2B8 \uBE0C\uB79C\uCE58 \uBCF4\uAE30 +gb.canAdminDescription = Gitblit \uAD00\uB9AC \uAD8C\uD55C \uBD80\uC5EC +gb.permittedUsers = \uD5C8\uC6A9\uB41C \uC0AC\uC6A9\uC790 +gb.isFrozen = \uD504\uB9AC\uC9D5\uB428 +gb.isFrozenDescription = \uD478\uC2DC \uCC28\uB2E8 gb.zip = zip -gb.showReadme = \ub9ac\ub4dc\ubbf8(readme) \ubcf4\uae30 -gb.showReadmeDescription = \uc694\uc57d\ud398\uc774\uc9c0\uc5d0\uc11c \"readme\" \ub9c8\ud06c\ub2e4\uc6b4 \ud30c\uc77c \ubcf4\uae30 -gb.nameDescription = \uc800\uc7a5\uc18c\ub97c \uadf8\ub8f9\uc73c\ub85c \ubb36\uc73c\ub824\uba74 '/' \ub97c \uc0ac\uc6a9. \uc608) libraries/reponame.git -gb.ownerDescription = \uc18c\uc720\uc790\ub294 \uc800\uc7a5\uc18c \uc124\uc815\uc744 \ubcc0\uacbd\ud560 \uc218 \uc788\uc74c +gb.showReadme = \uB9AC\uB4DC\uBBF8(readme) \uBCF4\uAE30 +gb.showReadmeDescription = \uC694\uC57D\uD398\uC774\uC9C0\uC5D0\uC11C \"readme\" \uB9C8\uD06C\uB2E4\uC6B4 \uD30C\uC77C \uBCF4\uAE30 +gb.nameDescription = \uC800\uC7A5\uC18C\uB97C \uADF8\uB8F9\uC73C\uB85C \uBB36\uC73C\uB824\uBA74 '/' \uB97C \uC0AC\uC6A9. \uC608) libraries/reponame.git +gb.ownerDescription = \uC18C\uC720\uC790\uB294 \uC800\uC7A5\uC18C \uC124\uC815\uC744 \uBCC0\uACBD\uD560 \uC218 \uC788\uC74C gb.blob = blob -gb.commitActivityTrend = \ucee4\ubc0b \ud65c\ub3d9 \ud2b8\ub79c\ub4dc -gb.commitActivityDOW = 1\uc8fc\uc77c\uc758 \uc77c\ub2e8\uc704 \ucee4\ubc0b \ud65c\ub3d9 -gb.commitActivityAuthors = \ucee4\ubc0b \ud65c\ub3d9\uc758 \uc8fc \uc791\uc131\uc790 -gb.feed = \ud53c\ub4dc -gb.cancel = \ucde8\uc18c -gb.changePassword = \ud328\uc2a4\uc6cc\ub4dc \ubcc0\uacbd -gb.isFederated = \ud398\ub354\ub808\uc774\uc158\ub428 -gb.federateThis = \uc774 \uc800\uc7a5\uc18c\ub97c \ud398\ub354\ub808\uc774\uc158\ud568 -gb.federateOrigin = origin \uc5d0 \ud398\ub354\ub808\uc774\uc158 -gb.excludeFromFederation = \ud398\ub354\ub808\uc774\uc158 \uc81c\uc678 -gb.excludeFromFederationDescription = \uc774 \uacc4\uc815\uc73c\ub85c \ud480\ub9c1\ub418\ub294 \ud398\ub7ec\ub808\uc774\uc158 \ub41c Gitblit \uc778\uc2a4\ud134\uc2a4 \ucc28\ub2e8 -gb.tokens = \ud398\ub354\ub808\uc774\uc158 \ud1a0\ud070 -gb.tokenAllDescription = \ubaa8\ub4e0 \uc800\uc7a5\uc18c, \ud1a0\ud070, \uc0ac\uc6a9\uc790 & \uc124\uc815 -gb.tokenUnrDescription = \ubaa8\ub4e0 \uc800\uc7a5\uc18c & \uc0ac\uc6a9\uc790 -gb.tokenJurDescription = \ubaa8\ub4e0 \uc800\uc7a5\uc18c -gb.federatedRepositoryDefinitions = \uc800\uc7a5\uc18c \uc815\uc758 -gb.federatedUserDefinitions = \uc0ac\uc6a9\uc790 \uc815\uc758 -gb.federatedSettingDefinitions = \uc124\uc815 \uc815\uc758 -gb.proposals = \ud398\ub354\ub808\uc774\uc158 \uc81c\uc548 -gb.received = \uc218\uc2e0\ud568 -gb.type = \ud0c0\uc785 -gb.token = \ud1a0\ud070 -gb.repositories = \uc800\uc7a5\uc18c -gb.proposal = \uc81c\uc548 -gb.frequency = \ube48\ub3c4 -gb.folder = \ud3f4\ub354 -gb.lastPull = \ub9c8\uc9c0\ub9c9 \ud480 -gb.nextPull = \ub2e4\uc74c \ud480 -gb.inclusions = \ud3ec\ud568 -gb.exclusions = \uc81c\uc678 -gb.registration = \ub4f1\ub85d -gb.registrations = \ud398\ub354\ub808\uc774\uc158 \ub4f1\ub85d -gb.sendProposal = \uc81c\uc548\ud558\uae30 -gb.status = \uc0c1\ud0dc +gb.commitActivityTrend = \uCEE4\uBC0B \uD65C\uB3D9 \uD2B8\uB79C\uB4DC +gb.commitActivityDOW = 1\uC8FC\uC77C\uC758 \uC77C\uB2E8\uC704 \uCEE4\uBC0B \uD65C\uB3D9 +gb.commitActivityAuthors = \uCEE4\uBC0B \uD65C\uB3D9\uC758 \uC8FC \uC791\uC131\uC790 +gb.feed = \uD53C\uB4DC +gb.cancel = \uCDE8\uC18C +gb.changePassword = \uD328\uC2A4\uC6CC\uB4DC \uBCC0\uACBD +gb.isFederated = \uD398\uB354\uB808\uC774\uC158\uB428 +gb.federateThis = \uC774 \uC800\uC7A5\uC18C\uB97C \uD398\uB354\uB808\uC774\uC158\uD568 +gb.federateOrigin = origin \uC5D0 \uD398\uB354\uB808\uC774\uC158 +gb.excludeFromFederation = \uD398\uB354\uB808\uC774\uC158 \uC81C\uC678 +gb.excludeFromFederationDescription = \uC774 \uACC4\uC815\uC73C\uB85C \uD480\uB9C1\uB418\uB294 \uD398\uB7EC\uB808\uC774\uC158 \uB41C Gitblit \uC778\uC2A4\uD134\uC2A4 \uCC28\uB2E8 +gb.tokens = \uD398\uB354\uB808\uC774\uC158 \uD1A0\uD070 +gb.tokenAllDescription = \uBAA8\uB4E0 \uC800\uC7A5\uC18C, \uD1A0\uD070, \uC0AC\uC6A9\uC790 & \uC124\uC815 +gb.tokenUnrDescription = \uBAA8\uB4E0 \uC800\uC7A5\uC18C & \uC0AC\uC6A9\uC790 +gb.tokenJurDescription = \uBAA8\uB4E0 \uC800\uC7A5\uC18C +gb.federatedRepositoryDefinitions = \uC800\uC7A5\uC18C \uC815\uC758 +gb.federatedUserDefinitions = \uC0AC\uC6A9\uC790 \uC815\uC758 +gb.federatedSettingDefinitions = \uC124\uC815 \uC815\uC758 +gb.proposals = \uD398\uB354\uB808\uC774\uC158 \uC81C\uC548 +gb.received = \uC218\uC2E0\uD568 +gb.type = \uD0C0\uC785 +gb.token = \uD1A0\uD070 +gb.repositories = \uC800\uC7A5\uC18C +gb.proposal = \uC81C\uC548 +gb.frequency = \uBE48\uB3C4 +gb.folder = \uD3F4\uB354 +gb.lastPull = \uB9C8\uC9C0\uB9C9 \uD480 +gb.nextPull = \uB2E4\uC74C \uD480 +gb.inclusions = \uD3EC\uD568 +gb.exclusions = \uC81C\uC678 +gb.registration = \uB4F1\uB85D +gb.registrations = \uD398\uB354\uB808\uC774\uC158 \uB4F1\uB85D +gb.sendProposal = \uC81C\uC548\uD558\uAE30 +gb.status = \uC0C1\uD0DC gb.origin = origin -gb.headRef = \ub514\ud3f4\ud2b8 \ube0c\ub79c\uce58(HEAD) -gb.headRefDescription = \ub514\ud3f4\ud2b8 \ube0c\ub79c\uce58\ub97c \uc785\ub825. \uc608) refs/heads/master -gb.federationStrategy = \ud398\ub354\ub808\uc774\uc158 \uc815\ucc45 -gb.federationRegistration = \ud398\ub354\ub808\uc774\uc158 \ub4f1\ub85d -gb.federationResults = \ud398\ub354\ub808\uc774\uc158 \ud480 \uacb0\uacfc -gb.federationSets = \ud398\ub354\ub808\uc774\uc158 \uc14b -gb.message = \uba54\uc2dc\uc9c0 -gb.myUrlDescription = \uacf5\uac1c\ub418\uc5b4 \uc811\uc18d\ud560 \uc218 \uc788\ub294 Gitblit \uc778\uc2a4\ud134\uc2a4 url -gb.destinationUrl = \ub85c \ubcf4\ub0c4 -gb.destinationUrlDescription = \uc81c\uc548\uc744 \uc804\uc1a1\ud560 \ub300\uc0c1 Gitblit \uc778\uc2a4\ud134\uc2a4\uc758 url -gb.users = \uc720\uc800 -gb.federation = \ud398\ub354\ub808\uc774\uc158 -gb.error = \uc5d0\ub7ec -gb.refresh = \uc0c8\ub85c\uace0\uce68 -gb.browse = \ube0c\ub77c\uc6b0\uc988 +gb.headRef = \uB514\uD3F4\uD2B8 \uBE0C\uB79C\uCE58(HEAD) +gb.headRefDescription = \uB514\uD3F4\uD2B8 \uBE0C\uB79C\uCE58\uB97C \uC785\uB825. \uC608) refs/heads/master +gb.federationStrategy = \uD398\uB354\uB808\uC774\uC158 \uC815\uCC45 +gb.federationRegistration = \uD398\uB354\uB808\uC774\uC158 \uB4F1\uB85D +gb.federationResults = \uD398\uB354\uB808\uC774\uC158 \uD480 \uACB0\uACFC +gb.federationSets = \uD398\uB354\uB808\uC774\uC158 \uC14B +gb.message = \uBA54\uC2DC\uC9C0 +gb.myUrlDescription = \uACF5\uAC1C\uB418\uC5B4 \uC811\uC18D\uD560 \uC218 \uC788\uB294 Gitblit \uC778\uC2A4\uD134\uC2A4 url +gb.destinationUrl = \uB85C \uBCF4\uB0C4 +gb.destinationUrlDescription = \uC81C\uC548\uC744 \uC804\uC1A1\uD560 \uB300\uC0C1 Gitblit \uC778\uC2A4\uD134\uC2A4\uC758 url +gb.users = \uC720\uC800 +gb.federation = \uD398\uB354\uB808\uC774\uC158 +gb.error = \uC5D0\uB7EC +gb.refresh = \uC0C8\uB85C\uACE0\uCE68 +gb.browse = \uBE0C\uB77C\uC6B0\uC988 gb.clone = clone -gb.filter = \ud544\ud130 -gb.create = \uc0dd\uc131 -gb.servers = \uc11c\ubc84 +gb.filter = \uD544\uD130 +gb.create = \uC0DD\uC131 +gb.servers = \uC11C\uBC84 gb.recent = recent -gb.available = \uac00\ub2a5\ud55c -gb.selected = \uc120\ud0dd\ub41c -gb.size = \ud06c\uae30 -gb.downloading = \ub2e4\uc6b4\ub85c\ub4dc\uc911 -gb.loading = \ub85c\ub529\uc911 -gb.starting = \uc2dc\uc791\uc911 -gb.general = \uc77c\ubc18 -gb.settings = \uc138\ud305 -gb.manage = \uad00\ub9ac -gb.lastLogin = \ub9c8\uc9c0\ub9c9 \ub85c\uadf8\uc778 -gb.skipSizeCalculation = \ud06c\uae30 \uacc4\uc0b0 \ubb34\uc2dc -gb.skipSizeCalculationDescription = \uc800\uc7a5\uc18c \ud06c\uae30 \uacc4\uc0b0\ud558\uc9c0 \uc54a\uc74c (\ud398\uc774\uc9c0 \ub85c\ub529 \uc2dc\uac04 \ub2e8\ucd95\ub428) -gb.skipSummaryMetrics = \uba54\ud2b8\ub9ad \uc694\uc57d \ubb34\uc2dc -gb.skipSummaryMetricsDescription = \uc694\uc57d \ud398\uc9c0\uc774\uc5d0\uc11c \uba54\ud2b8\ub9ad \uacc4\uc0b0\ud558\uc9c0 \uc54a\uc74c (\ud398\uc774\uc9c0 \ub85c\ub529 \uc2dc\uac04 \ub2e8\ucd95\ub428) -gb.accessLevel = \uc811\uc18d \ub808\ubca8 -gb.default = \ub514\ud3f4\ud2b8 -gb.setDefault = \ub514\ud3f4\ud2b8 \uc124\uc815 +gb.available = \uAC00\uB2A5\uD55C +gb.selected = \uC120\uD0DD\uB41C +gb.size = \uD06C\uAE30 +gb.downloading = \uB2E4\uC6B4\uB85C\uB4DC\uC911 +gb.loading = \uB85C\uB529\uC911 +gb.starting = \uC2DC\uC791\uC911 +gb.general = \uC77C\uBC18 +gb.settings = \uC138\uD305 +gb.manage = \uAD00\uB9AC +gb.lastLogin = \uB9C8\uC9C0\uB9C9 \uB85C\uADF8\uC778 +gb.skipSizeCalculation = \uD06C\uAE30 \uACC4\uC0B0 \uBB34\uC2DC +gb.skipSizeCalculationDescription = \uC800\uC7A5\uC18C \uD06C\uAE30 \uACC4\uC0B0\uD558\uC9C0 \uC54A\uC74C (\uD398\uC774\uC9C0 \uB85C\uB529 \uC2DC\uAC04 \uB2E8\uCD95\uB428) +gb.skipSummaryMetrics = \uBA54\uD2B8\uB9AD \uC694\uC57D \uBB34\uC2DC +gb.skipSummaryMetricsDescription = \uC694\uC57D \uD398\uC9C0\uC774\uC5D0\uC11C \uBA54\uD2B8\uB9AD \uACC4\uC0B0\uD558\uC9C0 \uC54A\uC74C (\uD398\uC774\uC9C0 \uB85C\uB529 \uC2DC\uAC04 \uB2E8\uCD95\uB428) +gb.accessLevel = \uC811\uC18D \uB808\uBCA8 +gb.default = \uB514\uD3F4\uD2B8 +gb.setDefault = \uB514\uD3F4\uD2B8 \uC124\uC815 gb.since = since -gb.status = \uc0c1\ud0dc -gb.bootDate = \ubd80\ud305 \uc77c\uc790 -gb.servletContainer = \uc11c\ube14\ub9bf \ucee8\ud14c\uc774\ub108 -gb.heapMaximum = \ucd5c\ub300 \ud799 -gb.heapAllocated = \ud560\ub2f9\ub41c \ud799 -gb.heapUsed = \uc0ac\uc6a9\ub41c \ud799 -gb.free = \ud504\ub9ac -gb.version = \ubc84\uc804 -gb.releaseDate = \ub9b4\ub9ac\uc988 \ub0a0\uc9dc +gb.status = \uC0C1\uD0DC +gb.bootDate = \uBD80\uD305 \uC77C\uC790 +gb.servletContainer = \uC11C\uBE14\uB9BF \uCEE8\uD14C\uC774\uB108 +gb.heapMaximum = \uCD5C\uB300 \uD799 +gb.heapAllocated = \uD560\uB2F9\uB41C \uD799 +gb.heapUsed = \uC0AC\uC6A9\uB41C \uD799 +gb.free = \uD504\uB9AC +gb.version = \uBC84\uC804 +gb.releaseDate = \uB9B4\uB9AC\uC988 \uB0A0\uC9DC gb.date = date -gb.activity = \uc561\ud2f0\ube44\ud2f0 -gb.subscribe = \uad6c\ub3c5 -gb.branch = \ube0c\ub79c\uce58 -gb.maxHits = \ub9e5\uc2a4\ud788\ud2b8 -gb.recentActivity = \ucd5c\uadfc \uc561\ud2f0\ube44\ud2f0 -gb.recentActivityStats = \uc9c0\ub09c {0}\uc77c / \uc791\uc131\uc790 {2}\uba85\uc774 {1}\uac1c \ucee4\ubc0b\ud568 -gb.recentActivityNone = \uc9c0\ub09c {0}\uc77c / \uc5c6\uc74c -gb.dailyActivity = \uc77c\uc77c \uc561\ud2f0\ube44\ud2f0 -gb.activeRepositories = \uc0ac\uc6a9\uc911\uc778 \uc800\uc7a5\uc18c -gb.activeAuthors = \uc0ac\uc6a9\uc911\uc778 \uc791\uc131\uc790 -gb.commits = \ucee4\ubc0b -gb.teams = \ud300 -gb.teamName = \ud300 \uc774\ub984 -gb.teamMembers = \ud300 \uba64\ubc84 -gb.teamMemberships = \ud300 \ub9f4\ubc84\uc27d -gb.newTeam = \uc0c8\ub85c\uc6b4 \ud300 -gb.permittedTeams = \ud5c8\uc6a9\ub41c \ud300 -gb.emptyRepository = \ube48 \uc800\uc7a5\uc18c -gb.repositoryUrl = \uc800\uc7a5\uc18c url -gb.mailingLists = \uba54\uc77c\ub9c1 \ub9ac\uc2a4\ud2b8 -gb.preReceiveScripts = pre-receive \uc2a4\ud06c\ub9bd\ud2b8 -gb.postReceiveScripts = post-receive \uc2a4\ud06c\ub9bd\ud2b8 -gb.hookScripts = \ud6c4\ud06c \uc2a4\ud06c\ub9bd\ud2b8 -gb.customFields = \uc0ac\uc6a9\uc790 \ud544\ub4dc -gb.customFieldsDescription = \uadf8\ub8e8\ube44 \ud6c5\uc5d0 \uc0ac\uc6a9\uc790 \ud544\ub4dc \uc0ac\uc6a9 \uac00\ub2a5 -gb.accessPermissions = \uc811\uc18d \uad8c\ud55c -gb.filters = \ud544\ud130 -gb.generalDescription = \uc77c\ubc18 \uc124\uc815 -gb.accessPermissionsDescription = \uc720\uc800\uc640 \ud300\uc73c\ub85c \uc811\uc18d\uad8c\ud55c \ubd80\uc5ec -gb.accessPermissionsForUserDescription = \ud300\uc744 \uc9c0\uc815\ud558\uac70\ub098 \uc811\uc18d \uad8c\ud55c\uc744 \uc9c0\uc815\ud560 \uc800\uc7a5\uc18c \uc120\ud0dd -gb.accessPermissionsForTeamDescription = \ud300 \ub9f4\ubc84\ub97c \uc120\ud0dd\ud558\uace0, \uc811\uc18d \uad8c\ud55c\uc744 \uc9c0\uc815\ud560 \uc800\uc7a5\uc18c \uc120\ud0dd -gb.federationRepositoryDescription = \uc774 \uc800\uc7a5\uc18c\ub97c \ub2e4\ub978 Gitblit \uc11c\ubc84\uc640 \uacf5\uc720 -gb.hookScriptsDescription = \uc774 Gitblit \uc11c\ubc84\uc5d0 \ud478\uc2dc\ub418\uba74 \uadf8\ub8e8\ube44(Groovy) \uc2a4\ud06c\ub9bd\ud2b8\ub97c \uc2e4\ud589 -gb.reset = \ub9ac\uc14b -gb.pages = \ud398\uc774\uc9c0 -gb.workingCopy = \uc6cc\ud0b9 \uce74\ud53c -gb.workingCopyWarning = \uc774 \uc800\uc7a5\uc18c\ub294 \uc6cc\ud0b9\uce74\ud53c\ub97c \uac00\uc9c0\uace0 \uc788\uace0 \ud478\uc2dc\ub97c \ubc1b\uc744 \uc218 \uc5c6\uc74c -gb.query = \ucffc\ub9ac -gb.queryHelp = \ud45c\uc900 \ucffc\ub9ac \ubb38\ubc95\uc744 \uc9c0\uc6d0.

\uc790\uc138\ud55c \uac83\uc744 \uc6d0\ud55c\ub2e4\uba74 \ub8e8\uc2e0 \ucffc\ub9ac \ud30c\uc11c \ubb38\ubc95 \uc744 \ubc29\ubb38\ud574 \uc8fc\uc138\uc694. -gb.queryResults = \uac80\uc0c9\uacb0\uacfc {0} - {1} ({2}\uac1c \uac80\uc0c9\ub428) -gb.noHits = \uac80\uc0c9 \uacb0\uacfc \uc5c6\uc74c -gb.authored = \uac00 \uc791\uc131\ud568. -gb.committed = \ucee4\ubc0b\ub428 -gb.indexedBranches = \uc778\ub371\uc2f1\ud560 \ube0c\ub79c\uce58 -gb.indexedBranchesDescription = \ub8e8\uc2e0 \uc778\ub371\uc2a4\uc5d0 \ud3ec\ud568\ud560 \ube0c\ub79c\uce58 \uc120\ud0dd -gb.noIndexedRepositoriesWarning = \uc800\uc7a5\uc18c\uac00 \ub8e8\uc2e0 \uc778\ub371\uc2f1\uc5d0 \uc124\uc815\ub418\uc9c0 \uc54a\uc74c -gb.undefinedQueryWarning = \ucffc\ub9ac \uc9c0\uc815\ub418\uc9c0 \uc54a\uc74c! -gb.noSelectedRepositoriesWarning = \ud558\ub098 \ub610\ub294 \uadf8 \uc774\uc0c1\uc758 \uc800\uc7a5\uc18c\ub97c \uc120\ud0dd\ud558\uc138\uc694! -gb.luceneDisabled = \ub8e8\uc2e0 \uc778\ub371\uc2f1 \uc911\uc9c0\ub428 -gb.failedtoRead = \uc77c\uae30 \uc2e4\ud328 -gb.isNotValidFile = \uc720\ud6a8\ud55c \ud30c\uc77c\uc774 \uc544\ub2d8 -gb.failedToReadMessage = {0}\uc5d0\uc11c \ub514\ud3f4\ud2b8 \uba54\uc2dc\uc9c0 \uc77c\uae30 \uc2e4\ud328! -gb.passwordsDoNotMatch = \ud328\uc2a4\uc6cc\ub4dc\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc544\uc694! -gb.passwordTooShort = \ud328\uc2a4\uc6cc\ub4dc\uac00 \ub108\ubb34 \uc9e7\uc544\uc694. \uc801\uc5b4\ub3c4 {0} \uac1c \ubb38\uc790\uc5ec\uc57c \ud569\ub2c8\ub2e4. -gb.passwordChanged = \ud328\uc2a4\uc6cc\ub4dc\uac00 \ubcc0\uacbd \uc131\uacf5. -gb.passwordChangeAborted = \ud328\uc2a4\uc6cc\ub4dc \ubcc0\uacbd \ucde8\uc18c\ub428. -gb.pleaseSetRepositoryName = \uc800\uc7a5\uc18c \uc774\ub984\uc744 \uc785\ub825\ud558\uc138\uc694! -gb.illegalLeadingSlash = \uc800\uc7a5\uc18c \uc774\ub984 \ub610\ub294 \ud3f4\ub354\ub294 (/) \ub85c \uc2dc\uc791\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.illegalRelativeSlash = \uc0c1\ub300 \uacbd\ub85c \uc9c0\uc815 (../) \uc740 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.. -gb.illegalCharacterRepositoryName = \ubb38\uc790 ''{0}'' \uc800\uc7a5\uc18c \uc774\ub984\uc5d0 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc5b4\uc694! -gb.selectAccessRestriction = \uc811\uc18d \uad8c\ud55c\uc744 \uc120\ud0dd\ud558\uc138\uc694! -gb.selectFederationStrategy = \ud398\ub354\ub808\uc774\uc158 \uc815\ucc45\uc744 \uc120\ud0dd\ud558\uc138\uc694! -gb.pleaseSetTeamName = \ud300\uc774\ub984\uc744 \uc785\ub825\ud558\uc138\uc694! -gb.teamNameUnavailable = ''{0}'' \ud300\uc740 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc5b4\uc694. -gb.teamMustSpecifyRepository = \ud300\uc740 \uc801\uc5b4\ub3c4 \ud558\ub098\uc758 \uc800\uc7a5\uc18c\ub97c \uc9c0\uc815\ud574\uc57c \ud569\ub2c8\ub2e4. -gb.teamCreated = \uc0c8\ub85c\uc6b4 \ud300 ''{0}'' \uc0dd\uc131 \uc644\ub8cc. -gb.pleaseSetUsername = \uc720\uc800\ub124\uc784\uc744 \uc785\ub825\ud558\uc138\uc694! -gb.usernameUnavailable = ''{0}'' \uc720\uc800\ub124\uc784\uc740 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc5b4\uc694. -gb.combinedMd5Rename = Gitblit \uc740 combined-md5 \ud574\uc2f1 \ud328\uc2a4\uc6cc\ub4dc\ub85c \uc124\uc815\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uacc4\uc815 \uc774\ub984 \ubcc0\uacbd \uc2dc \uc0c8 \ud328\uc2a4\uc6cc\ub4dc\ub97c \uc785\ub825\ud574\uc57c \ud569\ub2c8\ub2e4. -gb.userCreated = \uc0c8\ub85c\uc6b4 \uc720\uc800 ''{0}'' \uc0dd\uc131 \uc644\ub8cc. -gb.couldNotFindFederationRegistration = \ud398\ub354\ub808\uc774\uc158 \ub4f1\ub85d\uc744 \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4! -gb.failedToFindGravatarProfile = {0} \uc758 Gravatar \ud504\ub85c\ud30c\uc77c \ucc3e\uae30 \uc2e4\ud328 -gb.branchStats = {2} \uc548\uc5d0 {0} \ucee4\ubc0b {1} \ud0dc\uadf8 -gb.repositoryNotSpecified = \uc800\uc7a5\uc18c\uac00 \uc9c0\uc815\ub418\uc9c0 \uc54a\uc74c! -gb.repositoryNotSpecifiedFor = {0} \ub97c \uc704\ud55c \uc800\uc7a5\uc18c\uac00 \uc9c0\uc815\ub418\uc9c0 \uc54a\uc74c! -gb.canNotLoadRepository = \uc800\uc7a5\uc18c\ub97c \ubd88\ub7ec\uc62c \uc218 \uc5c6\uc74c -gb.commitIsNull = \ub110 \ucee4\ubc0b -gb.unauthorizedAccessForRepository = \uc800\uc7a5\uc18c\uc5d0 \uc811\uadfc \ud5c8\uc6a9\ub418\uc9c0 \uc54a\uc74c -gb.failedToFindCommit = \ucee4\ubc0b\uc744 \ucc3e\uc744 \uc218 \uc5c6\uc74c \"{0}\" in {1} for {2} \ud398\uc774\uc9c0! -gb.couldNotFindFederationProposal = \ud398\ub354\ub808\uc774\uc158 \uc81c\uc548\uc744 \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4! -gb.invalidUsernameOrPassword = \uc798\ubabb\ub41c \uc720\uc800\ub124\uc784 \ub610\ub294 \ud328\uc2a4\uc6cc\ub4dc! -gb.OneProposalToReview = \uac80\ud1a0\ub97c \uae30\ub2e4\ub9ac\uace0 \uc788\ub294 1\uac1c\uc758 \ud398\ub354\ub808\uc774\uc158 \uc81c\uc548\uc774 \uc788\uc2b5\ub2c8\ub2e4. -gb.nFederationProposalsToReview = \uac80\ud1a0\ub97c \uae30\ub2e4\ub9ac\uace0 \uc788\ub294 {0} \uac1c\uc758 \ud398\ub354\ub808\uc774\uc158 \uc81c\uc548\uc774 \uc788\uc2b5\ub2c8\ub2e4. -gb.couldNotFindTag = \ud0dc\uadf8 {0} \ub97c(\uc744) \ucc3e\uc744 \uc218 \uc5c6\uc74c -gb.couldNotCreateFederationProposal = \ud398\ub354\ub808\uc774\uc158 \uc81c\uc548 \uc0dd\uc131 \uc2e4\ud328! -gb.pleaseSetGitblitUrl = Gitblit url \uc744 \uc785\ub825\ud558\uc138\uc694! -gb.pleaseSetDestinationUrl = \ub2f9\uc2e0\uc758 \uc81c\uc548\uc5d0 \ub300\ud55c \ub300\uc0c1 url \uc744 \uc785\ub825\ud558\uc138\uc694! -gb.proposalReceived = {0} \uc758 \uc81c\uc548 \uc131\uacf5\uc801 \uc218\uc2e0 -gb.noGitblitFound = \uc8c4\uc1a1\ud569\ub2c8\ub2e4, Gitblit \uc778\uc2a4\ud134\uc2a4 {1} \uc5d0\uc11c {0} \ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.noProposals = \uc8c4\uc1a1\ud569\ub2c8\ub2e4, \uc774\ubc88\uc5d0\ub294 {0} \uc758 \uc81c\uc548\uc744 \uc218\uc6a9\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. -gb.noFederation = \uc8c4\uc1a1\ud569\ub2c8\ub2e4, {0} \uc5d0\ub294 \ud398\ub354\ub808\uc774\uc158 \uc124\uc815\ub41c Gitblit \uc778\uc2a4\ud134\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.proposalFailed = \uc8c4\uc1a1\ud569\ub2c8\ub2e4, {0} \uc5d0\ub294 \uc81c\uc548 \ub370\uc774\ud130\ub97c \ubc1b\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. -gb.proposalError = \uc8c4\uc1a1\ud569\ub2c8\ub2e4, {0} \uc5d0 \ub300\ud55c \uc624\ub958 \ubc1c\uc0dd \ubcf4\uace0 -gb.failedToSendProposal = \uc81c\uc548 \ubcf4\ub0b4\uae30 \uc2e4\ud328! -gb.userServiceDoesNotPermitAddUser = {0} \uc0c8\ub85c\uc6b4 \uc720\uc800\ub97c \ucd94\uac00\ud560 \uc218 \uc5c6\uc74c! -gb.userServiceDoesNotPermitPasswordChanges = {0} \ud328\uc2a4\uc6cc\ub4dc\ub97c \ubcc0\uacbd\ud560 \uc218 \uc5c6\uc74c! -gb.displayName = \ud45c\uc2dc\ub418\ub294 \uc774\ub984 -gb.emailAddress = \uc774\uba54\uc77c \uc8fc\uc18c -gb.errorAdminLoginRequired = \uad00\ub9ac\ub97c \uc704\ud574\uc11c\ub294 \ub85c\uadf8\uc778\uc774 \ud544\uc694 -gb.errorOnlyAdminMayCreateRepository = \uad00\ub9ac\uc790\ub9cc \uc800\uc7a5\uc18c\ub97c \ub9cc\ub4e4\uc218 \uc788\uc74c -gb.errorOnlyAdminOrOwnerMayEditRepository = \uad00\ub9ac\uc790\uc640 \uc18c\uc720\uc790\ub9cc \uc800\uc7a5\uc18c\ub97c \uc218\uc815\ud560 \uc218 \uc788\uc74c -gb.errorAdministrationDisabled = \uad00\ub9ac\uae30\ub2a5 \ube44\ud65c\uc131\ud654\ub428 -gb.lastNDays = {0} \uc77c\uc804 -gb.completeGravatarProfile = Gravatar.com \uc5d0 \ud504\ub85c\ud30c\uc77c \uc0dd\uc131\ub428 +gb.activity = \uC561\uD2F0\uBE44\uD2F0 +gb.subscribe = \uAD6C\uB3C5 +gb.branch = \uBE0C\uB79C\uCE58 +gb.maxHits = \uB9E5\uC2A4\uD788\uD2B8 +gb.recentActivity = \uCD5C\uADFC \uC561\uD2F0\uBE44\uD2F0 +gb.recentActivityStats = \uC9C0\uB09C {0}\uC77C / \uC791\uC131\uC790 {2}\uBA85\uC774 {1}\uAC1C \uCEE4\uBC0B\uD568 +gb.recentActivityNone = \uC9C0\uB09C {0}\uC77C / \uC5C6\uC74C +gb.dailyActivity = \uC77C\uC77C \uC561\uD2F0\uBE44\uD2F0 +gb.activeRepositories = \uC0AC\uC6A9\uC911\uC778 \uC800\uC7A5\uC18C +gb.activeAuthors = \uC0AC\uC6A9\uC911\uC778 \uC791\uC131\uC790 +gb.commits = \uCEE4\uBC0B +gb.teams = \uD300 +gb.teamName = \uD300 \uC774\uB984 +gb.teamMembers = \uD300 \uBA64\uBC84 +gb.teamMemberships = \uD300 \uB9F4\uBC84\uC27D +gb.newTeam = \uC0C8\uB85C\uC6B4 \uD300 +gb.permittedTeams = \uD5C8\uC6A9\uB41C \uD300 +gb.emptyRepository = \uBE48 \uC800\uC7A5\uC18C +gb.repositoryUrl = \uC800\uC7A5\uC18C url +gb.mailingLists = \uBA54\uC77C\uB9C1 \uB9AC\uC2A4\uD2B8 +gb.preReceiveScripts = pre-receive \uC2A4\uD06C\uB9BD\uD2B8 +gb.postReceiveScripts = post-receive \uC2A4\uD06C\uB9BD\uD2B8 +gb.hookScripts = \uD6C4\uD06C \uC2A4\uD06C\uB9BD\uD2B8 +gb.customFields = \uC0AC\uC6A9\uC790 \uD544\uB4DC +gb.customFieldsDescription = \uADF8\uB8E8\uBE44 \uD6C5\uC5D0 \uC0AC\uC6A9\uC790 \uD544\uB4DC \uC0AC\uC6A9 \uAC00\uB2A5 +gb.accessPermissions = \uC811\uC18D \uAD8C\uD55C +gb.filters = \uD544\uD130 +gb.generalDescription = \uC77C\uBC18 \uC124\uC815 +gb.accessPermissionsDescription = \uC720\uC800\uC640 \uD300\uC73C\uB85C \uC811\uC18D\uAD8C\uD55C \uBD80\uC5EC +gb.accessPermissionsForUserDescription = \uD300\uC744 \uC9C0\uC815\uD558\uAC70\uB098 \uC811\uC18D \uAD8C\uD55C\uC744 \uC9C0\uC815\uD560 \uC800\uC7A5\uC18C \uC120\uD0DD +gb.accessPermissionsForTeamDescription = \uD300 \uB9F4\uBC84\uB97C \uC120\uD0DD\uD558\uACE0, \uC811\uC18D \uAD8C\uD55C\uC744 \uC9C0\uC815\uD560 \uC800\uC7A5\uC18C \uC120\uD0DD +gb.federationRepositoryDescription = \uC774 \uC800\uC7A5\uC18C\uB97C \uB2E4\uB978 Gitblit \uC11C\uBC84\uC640 \uACF5\uC720 +gb.hookScriptsDescription = \uC774 Gitblit \uC11C\uBC84\uC5D0 \uD478\uC2DC\uB418\uBA74 \uADF8\uB8E8\uBE44(Groovy) \uC2A4\uD06C\uB9BD\uD2B8\uB97C \uC2E4\uD589 +gb.reset = \uB9AC\uC14B +gb.pages = \uD398\uC774\uC9C0 +gb.workingCopy = \uC6CC\uD0B9 \uCE74\uD53C +gb.workingCopyWarning = \uC774 \uC800\uC7A5\uC18C\uB294 \uC6CC\uD0B9\uCE74\uD53C\uB97C \uAC00\uC9C0\uACE0 \uC788\uACE0 \uD478\uC2DC\uB97C \uBC1B\uC744 \uC218 \uC5C6\uC74C +gb.query = \uCFFC\uB9AC +gb.queryHelp = \uD45C\uC900 \uCFFC\uB9AC \uBB38\uBC95\uC744 \uC9C0\uC6D0.

\uC790\uC138\uD55C \uAC83\uC744 \uC6D0\uD55C\uB2E4\uBA74 \uB8E8\uC2E0 \uCFFC\uB9AC \uD30C\uC11C \uBB38\uBC95 \uC744 \uBC29\uBB38\uD574 \uC8FC\uC138\uC694. +gb.queryResults = \uAC80\uC0C9\uACB0\uACFC {0} - {1} ({2}\uAC1C \uAC80\uC0C9\uB428) +gb.noHits = \uAC80\uC0C9 \uACB0\uACFC \uC5C6\uC74C +gb.authored = \uAC00 \uC791\uC131\uD568. +gb.committed = \uCEE4\uBC0B\uB428 +gb.indexedBranches = \uC778\uB371\uC2F1\uD560 \uBE0C\uB79C\uCE58 +gb.indexedBranchesDescription = \uB8E8\uC2E0 \uC778\uB371\uC2A4\uC5D0 \uD3EC\uD568\uD560 \uBE0C\uB79C\uCE58 \uC120\uD0DD +gb.noIndexedRepositoriesWarning = \uC800\uC7A5\uC18C\uAC00 \uB8E8\uC2E0 \uC778\uB371\uC2F1\uC5D0 \uC124\uC815\uB418\uC9C0 \uC54A\uC74C +gb.undefinedQueryWarning = \uCFFC\uB9AC \uC9C0\uC815\uB418\uC9C0 \uC54A\uC74C! +gb.noSelectedRepositoriesWarning = \uD558\uB098 \uB610\uB294 \uADF8 \uC774\uC0C1\uC758 \uC800\uC7A5\uC18C\uB97C \uC120\uD0DD\uD558\uC138\uC694! +gb.luceneDisabled = \uB8E8\uC2E0 \uC778\uB371\uC2F1 \uC911\uC9C0\uB428 +gb.failedtoRead = \uC77C\uAE30 \uC2E4\uD328 +gb.isNotValidFile = \uC720\uD6A8\uD55C \uD30C\uC77C\uC774 \uC544\uB2D8 +gb.failedToReadMessage = {0}\uC5D0\uC11C \uB514\uD3F4\uD2B8 \uBA54\uC2DC\uC9C0 \uC77C\uAE30 \uC2E4\uD328! +gb.passwordsDoNotMatch = \uD328\uC2A4\uC6CC\uB4DC\uAC00 \uC77C\uCE58\uD558\uC9C0 \uC54A\uC544\uC694! +gb.passwordTooShort = \uD328\uC2A4\uC6CC\uB4DC\uAC00 \uB108\uBB34 \uC9E7\uC544\uC694. \uC801\uC5B4\uB3C4 {0} \uAC1C \uBB38\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4. +gb.passwordChanged = \uD328\uC2A4\uC6CC\uB4DC\uAC00 \uBCC0\uACBD \uC131\uACF5. +gb.passwordChangeAborted = \uD328\uC2A4\uC6CC\uB4DC \uBCC0\uACBD \uCDE8\uC18C\uB428. +gb.pleaseSetRepositoryName = \uC800\uC7A5\uC18C \uC774\uB984\uC744 \uC785\uB825\uD558\uC138\uC694! +gb.illegalLeadingSlash = \uC800\uC7A5\uC18C \uC774\uB984 \uB610\uB294 \uD3F4\uB354\uB294 (/) \uB85C \uC2DC\uC791\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.illegalRelativeSlash = \uC0C1\uB300 \uACBD\uB85C \uC9C0\uC815 (../) \uC740 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.. +gb.illegalCharacterRepositoryName = \uBB38\uC790 ''{0}'' \uC800\uC7A5\uC18C \uC774\uB984\uC5D0 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC5B4\uC694! +gb.selectAccessRestriction = \uC811\uC18D \uAD8C\uD55C\uC744 \uC120\uD0DD\uD558\uC138\uC694! +gb.selectFederationStrategy = \uD398\uB354\uB808\uC774\uC158 \uC815\uCC45\uC744 \uC120\uD0DD\uD558\uC138\uC694! +gb.pleaseSetTeamName = \uD300\uC774\uB984\uC744 \uC785\uB825\uD558\uC138\uC694! +gb.teamNameUnavailable = ''{0}'' \uD300\uC740 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC5B4\uC694. +gb.teamMustSpecifyRepository = \uD300\uC740 \uC801\uC5B4\uB3C4 \uD558\uB098\uC758 \uC800\uC7A5\uC18C\uB97C \uC9C0\uC815\uD574\uC57C \uD569\uB2C8\uB2E4. +gb.teamCreated = \uC0C8\uB85C\uC6B4 \uD300 ''{0}'' \uC0DD\uC131 \uC644\uB8CC. +gb.pleaseSetUsername = \uC720\uC800\uB124\uC784\uC744 \uC785\uB825\uD558\uC138\uC694! +gb.usernameUnavailable = ''{0}'' \uC720\uC800\uB124\uC784\uC740 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC5B4\uC694. +gb.combinedMd5Rename = Gitblit \uC740 combined-md5 \uD574\uC2F1 \uD328\uC2A4\uC6CC\uB4DC\uB85C \uC124\uC815\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uACC4\uC815 \uC774\uB984 \uBCC0\uACBD \uC2DC \uC0C8 \uD328\uC2A4\uC6CC\uB4DC\uB97C \uC785\uB825\uD574\uC57C \uD569\uB2C8\uB2E4. +gb.userCreated = \uC0C8\uB85C\uC6B4 \uC720\uC800 ''{0}'' \uC0DD\uC131 \uC644\uB8CC. +gb.couldNotFindFederationRegistration = \uD398\uB354\uB808\uC774\uC158 \uB4F1\uB85D\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4! +gb.failedToFindGravatarProfile = {0} \uC758 Gravatar \uD504\uB85C\uD30C\uC77C \uCC3E\uAE30 \uC2E4\uD328 +gb.branchStats = {2} \uC548\uC5D0 {0} \uCEE4\uBC0B {1} \uD0DC\uADF8 +gb.repositoryNotSpecified = \uC800\uC7A5\uC18C\uAC00 \uC9C0\uC815\uB418\uC9C0 \uC54A\uC74C! +gb.repositoryNotSpecifiedFor = {0} \uB97C \uC704\uD55C \uC800\uC7A5\uC18C\uAC00 \uC9C0\uC815\uB418\uC9C0 \uC54A\uC74C! +gb.canNotLoadRepository = \uC800\uC7A5\uC18C\uB97C \uBD88\uB7EC\uC62C \uC218 \uC5C6\uC74C +gb.commitIsNull = \uB110 \uCEE4\uBC0B +gb.unauthorizedAccessForRepository = \uC800\uC7A5\uC18C\uC5D0 \uC811\uADFC \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uC74C +gb.failedToFindCommit = \uCEE4\uBC0B\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC74C \"{0}\" in {1} for {2} \uD398\uC774\uC9C0! +gb.couldNotFindFederationProposal = \uD398\uB354\uB808\uC774\uC158 \uC81C\uC548\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4! +gb.invalidUsernameOrPassword = \uC798\uBABB\uB41C \uC720\uC800\uB124\uC784 \uB610\uB294 \uD328\uC2A4\uC6CC\uB4DC! +gb.OneProposalToReview = \uAC80\uD1A0\uB97C \uAE30\uB2E4\uB9AC\uACE0 \uC788\uB294 1\uAC1C\uC758 \uD398\uB354\uB808\uC774\uC158 \uC81C\uC548\uC774 \uC788\uC2B5\uB2C8\uB2E4. +gb.nFederationProposalsToReview = \uAC80\uD1A0\uB97C \uAE30\uB2E4\uB9AC\uACE0 \uC788\uB294 {0} \uAC1C\uC758 \uD398\uB354\uB808\uC774\uC158 \uC81C\uC548\uC774 \uC788\uC2B5\uB2C8\uB2E4. +gb.couldNotFindTag = \uD0DC\uADF8 {0} \uB97C(\uC744) \uCC3E\uC744 \uC218 \uC5C6\uC74C +gb.couldNotCreateFederationProposal = \uD398\uB354\uB808\uC774\uC158 \uC81C\uC548 \uC0DD\uC131 \uC2E4\uD328! +gb.pleaseSetGitblitUrl = Gitblit url \uC744 \uC785\uB825\uD558\uC138\uC694! +gb.pleaseSetDestinationUrl = \uB2F9\uC2E0\uC758 \uC81C\uC548\uC5D0 \uB300\uD55C \uB300\uC0C1 url \uC744 \uC785\uB825\uD558\uC138\uC694! +gb.proposalReceived = {0} \uC758 \uC81C\uC548 \uC131\uACF5\uC801 \uC218\uC2E0 +gb.noGitblitFound = \uC8C4\uC1A1\uD569\uB2C8\uB2E4, Gitblit \uC778\uC2A4\uD134\uC2A4 {1} \uC5D0\uC11C {0} \uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.noProposals = \uC8C4\uC1A1\uD569\uB2C8\uB2E4, \uC774\uBC88\uC5D0\uB294 {0} \uC758 \uC81C\uC548\uC744 \uC218\uC6A9\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. +gb.noFederation = \uC8C4\uC1A1\uD569\uB2C8\uB2E4, {0} \uC5D0\uB294 \uD398\uB354\uB808\uC774\uC158 \uC124\uC815\uB41C Gitblit \uC778\uC2A4\uD134\uC2A4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.proposalFailed = \uC8C4\uC1A1\uD569\uB2C8\uB2E4, {0} \uC5D0\uB294 \uC81C\uC548 \uB370\uC774\uD130\uB97C \uBC1B\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. +gb.proposalError = \uC8C4\uC1A1\uD569\uB2C8\uB2E4, {0} \uC5D0 \uB300\uD55C \uC624\uB958 \uBC1C\uC0DD \uBCF4\uACE0 +gb.failedToSendProposal = \uC81C\uC548 \uBCF4\uB0B4\uAE30 \uC2E4\uD328! +gb.userServiceDoesNotPermitAddUser = {0} \uC0C8\uB85C\uC6B4 \uC720\uC800\uB97C \uCD94\uAC00\uD560 \uC218 \uC5C6\uC74C! +gb.userServiceDoesNotPermitPasswordChanges = {0} \uD328\uC2A4\uC6CC\uB4DC\uB97C \uBCC0\uACBD\uD560 \uC218 \uC5C6\uC74C! +gb.displayName = \uD45C\uC2DC\uB418\uB294 \uC774\uB984 +gb.emailAddress = \uC774\uBA54\uC77C \uC8FC\uC18C +gb.errorAdminLoginRequired = \uAD00\uB9AC\uB97C \uC704\uD574\uC11C\uB294 \uB85C\uADF8\uC778\uC774 \uD544\uC694 +gb.errorOnlyAdminMayCreateRepository = \uAD00\uB9AC\uC790\uB9CC \uC800\uC7A5\uC18C\uB97C \uB9CC\uB4E4\uC218 \uC788\uC74C +gb.errorOnlyAdminOrOwnerMayEditRepository = \uAD00\uB9AC\uC790\uC640 \uC18C\uC720\uC790\uB9CC \uC800\uC7A5\uC18C\uB97C \uC218\uC815\uD560 \uC218 \uC788\uC74C +gb.errorAdministrationDisabled = \uAD00\uB9AC\uAE30\uB2A5 \uBE44\uD65C\uC131\uD654\uB428 +gb.lastNDays = {0} \uC77C\uC804 +gb.completeGravatarProfile = Gravatar.com \uC5D0 \uD504\uB85C\uD30C\uC77C \uC0DD\uC131\uB428 gb.none = none -gb.line = \ub77c\uc778 -gb.content = \ub0b4\uc6a9 +gb.line = \uB77C\uC778 +gb.content = \uB0B4\uC6A9 gb.empty = empty -gb.inherited = \uc0c1\uc18d -gb.deleteRepository = \uc800\uc7a5\uc18c \"{0}\" \ub97c \uc0ad\uc81c\ud560\uae4c\uc694? -gb.repositoryDeleted = \uc800\uc7a5\uc18c ''{0}'' \uc0ad\uc81c\ub428. -gb.repositoryDeleteFailed = \uc800\uc7a5\uc18c ''{0}'' \uc0ad\uc81c \uc2e4\ud328! -gb.deleteUser = \uc0ac\uc6a9\uc790 \"{0}\"\ub97c \uc0ad\uc81c\ud560\uae4c\uc694? -gb.userDeleted = \uc0ac\uc6a9\uc790 ''{0}'' \uc0ad\uc81c\ub428. -gb.userDeleteFailed = \uc0ac\uc6a9\uc790 ''{0}'' \uc0ad\uc81c \uc2e4\ud328! -gb.time.justNow = \uc9c0\uae08 -gb.time.today = \uc624\ub298 -gb.time.yesterday = \uc5b4\uc81c -gb.time.minsAgo = {0}\ubd84 \uc804 -gb.time.hoursAgo = {0}\uc2dc\uac04 \uc804 -gb.time.daysAgo = {0}\uc77c \uc804 -gb.time.weeksAgo = {0}\uc8fc \uc804 -gb.time.monthsAgo = {0}\ub2ec \uc804 -gb.time.oneYearAgo = 1\ub144 \uc804 -gb.time.yearsAgo = {0}\ub144 \uc804 -gb.duration.oneDay = 1\uc77c -gb.duration.days = {0}\uc77c -gb.duration.oneMonth = 1\uac1c\uc6d4 -gb.duration.months = {0}\uac1c\uc6d4 -gb.duration.oneYear = 1\ub144 -gb.duration.years = {0}\ub144 -gb.authorizationControl = \uc778\uc99d \uc81c\uc5b4 -gb.allowAuthenticatedDescription = \ubaa8\ub4e0 \uc778\uc99d\ub41c \uc720\uc800\uc5d0\uac8c \uad8c\ud55c \ubd80\uc5ec -gb.allowNamedDescription = \uc774\ub984\uc73c\ub85c \uc720\uc800\ub098 \ud300\uc5d0\uac8c \uad8c\ud55c \ubd80\uc5ec -gb.markdownFailure = \ub9c8\ud06c\ub2e4\uc6b4 \ucee8\ud150\uce20 \ud30c\uc2f1 \uc624\ub958! -gb.clearCache = \uce90\uc2dc \uc9c0\uc6b0\uae30 -gb.projects = \ud504\ub85c\uc81d\ud2b8 -gb.project = \ud504\ub85c\uc81d\ud2b8 -gb.allProjects = \ubaa8\ub4e0 \ud504\ub85c\uc81d\ud2b8 -gb.copyToClipboard = \ud074\ub9bd\ubcf4\ub4dc\uc5d0 \ubcf5\uc0ac -gb.fork = \ud3ec\ud06c -gb.forks = \ud3ec\ud06c -gb.forkRepository = {0}\ub97c \ud3ec\ud06c\ud560\uae4c\uc694? -gb.repositoryForked = {0} \ud3ec\ud06c\ub428 -gb.repositoryForkFailed= \ud3ec\ud06c\uc2e4\ud328 -gb.personalRepositories = \uac1c\uc778 \uc800\uc7a5\uc18c -gb.allowForks = \ud3ec\ud06c \ud5c8\uc6a9 -gb.allowForksDescription = \uc774 \uc800\uc7a5\uc18c\ub97c \uc778\uc99d\ub41c \uc720\uc800\uc5d0\uac70 \ud3ec\ud06c \ud5c8\uc6a9 -gb.forkedFrom = \ub85c\ubd80\ud130 \ud3ec\ud06c\ub428 -gb.canFork = \ud3ec\ud06c \uac00\ub2a5 -gb.canForkDescription = \ud5c8\uc6a9\ub41c \uc800\uc7a5\uc18c\ub97c \uac1c\uc778 \uc800\uc7a5\uc18c\uc5d0 \ud3ec\ud06c\ud560 \uc218 \uc788\uc74c -gb.myFork = \ub0b4 \ud3ec\ud06c \ubcf4\uae30 -gb.forksProhibited = \ud3ec\ud06c \ucc28\ub2e8\ub428 -gb.forksProhibitedWarning = \uc774 \uc800\uc7a5\uc18c\ub294 \ud3ec\ud06c \ucc28\ub2e8\ub418\uc5b4 \uc788\uc74c -gb.noForks = {0} \ub294 \ud3ec\ud06c \uc5c6\uc74c -gb.forkNotAuthorized = \uc8c4\uc1a1\ud569\ub2c8\ub2e4. {0} \ud3ec\ud06c\uc5d0 \uc811\uc18d\uc774 \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. -gb.forkInProgress = \ud504\ud06c \uc9c4\ud589 \uc911 -gb.preparingFork = \ud3ec\ud06c \uc900\ube44 \uc911... -gb.isFork = \ud3ec\ud06c\ud55c -gb.canCreate = \uc0dd\uc131 \uac00\ub2a5 -gb.canCreateDescription = \uac1c\uc778 \uc800\uc7a5\uc18c\ub97c \ub9cc\ub4e4 \uc218 \uc788\uc74c -gb.illegalPersonalRepositoryLocation = \uac1c\uc778 \uc800\uc7a5\uc18c\ub294 \ubc18\ub4dc\uc2dc \"{0}\" \uc5d0 \uc704\uce58\ud574\uc57c \ud569\ub2c8\ub2e4. -gb.verifyCommitter = \ucee4\ubbf8\ud130 \ud655\uc778 -gb.verifyCommitterDescription = \ucee4\ubbf8\ud130 ID \ub294 Gitblit ID \uc640 \ub9e4\uce58\ub418\uc5b4\uc57c \ud568 -gb.verifyCommitterNote = \ubaa8\ub4e0 \uba38\uc9c0\ub294 \ucee4\ubbf8\ud130 ID \ub97c \uc801\uc6a9\ud558\uae30 \uc704\ud574 "--no-ff" \uc635\uc158 \ud544\uc694 -gb.repositoryPermissions = \uc800\uc7a5\uc18c \uad8c\ud55c -gb.userPermissions = \uc720\uc800 \uad8c\ud55c -gb.teamPermissions = \ud300 \uad8c\ud55c -gb.add = \ucd94\uac00 -gb.noPermission = \uc774 \uad8c\ud55c \uc0ad\uc81c -gb.excludePermission = {0} (\uc81c\uc678) -gb.viewPermission = {0} (\ubcf4\uae30) -gb.clonePermission = {0} (\ud074\ub860) -gb.pushPermission = {0} (\ud478\uc2dc) -gb.createPermission = {0} (\ud478\uc2dc, ref \uc0dd\uc131) -gb.deletePermission = {0} (\ud478\uc2dc, ref \uc0dd\uc131+\uc0ad\uc81c) -gb.rewindPermission = {0} (\ud478\uc2dc, ref \uc0dd\uc131+\uc0ad\uc81c+\ub418\ub3cc\ub9ac\uae30) -gb.permission = \uad8c\ud55c -gb.regexPermission = \uc774 \uad8c\ud55c\uc740 \uc815\uaddc\uc2dd \"{0}\" \ub85c\ubd80\ud130 \uc124\uc815\ub428 -gb.accessDenied = \uc811\uc18d \uac70\ubd80 -gb.busyCollectingGarbage = \uc8c4\uc1a1\ud569\ub2c8\ub2e4. Gitblit \uc740 \uac00\ube44\uc9c0 \uceec\ub809\uc158 \uc911\uc785\ub2c8\ub2e4. {0} -gb.gcPeriod = GC \uc8fc\uae30 -gb.gcPeriodDescription = \uac00\ube44\uc9c0 \ud074\ub809\uc158\uac04\uc758 \uc2dc\uac04 \uac04\uaca9 -gb.gcThreshold = GC \uae30\uc900\uc810 -gb.gcThresholdDescription = \uc870\uae30 \uac00\ube44\uc9c0 \uceec\ub809\uc158\uc744 \ubc1c\uc0dd\uc2dc\ud0a4\uae30 \uc704\ud55c \uc624\ube0c\uc81d\ud2b8\ub4e4\uc758 \ucd5c\uc18c \uc804\uccb4 \ud06c\uae30 -gb.ownerPermission = \uc800\uc7a5\uc18c \uc624\ub108 -gb.administrator = \uad00\ub9ac\uc790 -gb.administratorPermission = Gitblit \uad00\ub9ac\uc790 -gb.team = \ud300 -gb.teamPermission = \"{0}\" \ud300 \uba64\ubc84\uc5d0 \uad8c\ud55c \uc124\uc815\ub428 -gb.missing = \ub204\ub77d! -gb.missingPermission = \uc774 \uad8c\ud55c\uc744 \uc704\ud55c \uc800\uc7a5\uc18c \ub204\ub77d! -gb.mutable = \uac00\ubcc0 -gb.specified = \uc9c0\uc815\ub41c -gb.effective = \ud6a8\uacfc\uc801 -gb.organizationalUnit = \uc870\uc9c1 -gb.organization = \uae30\uad00 -gb.locality = \uc704\uce58 -gb.stateProvince = \ub3c4 \ub610\ub294 \uc8fc -gb.countryCode = \uad6d\uac00\ucf54\ub4dc -gb.properties = \uc18d\uc131 -gb.issued = \ubc1c\uae09\ub428 -gb.expires = \ub9cc\ub8cc -gb.expired = \ub9cc\ub8cc\ub428 -gb.expiring = \ub9cc\ub8cc\uc911 -gb.revoked = \ud3d0\uae30\ub428 -gb.serialNumber = \uc77c\ub828\ubc88\ud638 -gb.certificates = \uc778\uc99d\uc11c -gb.newCertificate = \uc0c8 \uc778\uc99d\uc11c -gb.revokeCertificate = \uc778\uc99d\uc11c \ud3d0\uae30 -gb.sendEmail = \uba54\uc77c \ubcf4\ub0b4\uae30 -gb.passwordHint = \ud328\uc2a4\uc6cc\ub4dc \ud78c\ud2b8 +gb.inherited = \uC0C1\uC18D +gb.deleteRepository = \uC800\uC7A5\uC18C \"{0}\" \uB97C \uC0AD\uC81C\uD560\uAE4C\uC694? +gb.repositoryDeleted = \uC800\uC7A5\uC18C ''{0}'' \uC0AD\uC81C\uB428. +gb.repositoryDeleteFailed = \uC800\uC7A5\uC18C ''{0}'' \uC0AD\uC81C \uC2E4\uD328! +gb.deleteUser = \uC0AC\uC6A9\uC790 \"{0}\"\uB97C \uC0AD\uC81C\uD560\uAE4C\uC694? +gb.userDeleted = \uC0AC\uC6A9\uC790 ''{0}'' \uC0AD\uC81C\uB428. +gb.userDeleteFailed = \uC0AC\uC6A9\uC790 ''{0}'' \uC0AD\uC81C \uC2E4\uD328! +gb.time.justNow = \uC9C0\uAE08 +gb.time.today = \uC624\uB298 +gb.time.yesterday = \uC5B4\uC81C +gb.time.minsAgo = {0}\uBD84 \uC804 +gb.time.hoursAgo = {0}\uC2DC\uAC04 \uC804 +gb.time.daysAgo = {0}\uC77C \uC804 +gb.time.weeksAgo = {0}\uC8FC \uC804 +gb.time.monthsAgo = {0}\uB2EC \uC804 +gb.time.oneYearAgo = 1\uB144 \uC804 +gb.time.yearsAgo = {0}\uB144 \uC804 +gb.duration.oneDay = 1\uC77C +gb.duration.days = {0}\uC77C +gb.duration.oneMonth = 1\uAC1C\uC6D4 +gb.duration.months = {0}\uAC1C\uC6D4 +gb.duration.oneYear = 1\uB144 +gb.duration.years = {0}\uB144 +gb.authorizationControl = \uC778\uC99D \uC81C\uC5B4 +gb.allowAuthenticatedDescription = \uBAA8\uB4E0 \uC778\uC99D\uB41C \uC720\uC800\uC5D0\uAC8C \uAD8C\uD55C \uBD80\uC5EC +gb.allowNamedDescription = \uC774\uB984\uC73C\uB85C \uC720\uC800\uB098 \uD300\uC5D0\uAC8C \uAD8C\uD55C \uBD80\uC5EC +gb.markdownFailure = \uB9C8\uD06C\uB2E4\uC6B4 \uCEE8\uD150\uCE20 \uD30C\uC2F1 \uC624\uB958! +gb.clearCache = \uCE90\uC2DC \uC9C0\uC6B0\uAE30 +gb.projects = \uD504\uB85C\uC81D\uD2B8 +gb.project = \uD504\uB85C\uC81D\uD2B8 +gb.allProjects = \uBAA8\uB4E0 \uD504\uB85C\uC81D\uD2B8 +gb.copyToClipboard = \uD074\uB9BD\uBCF4\uB4DC\uC5D0 \uBCF5\uC0AC +gb.fork = \uD3EC\uD06C +gb.forks = \uD3EC\uD06C +gb.forkRepository = {0}\uB97C \uD3EC\uD06C\uD560\uAE4C\uC694? +gb.repositoryForked = {0} \uD3EC\uD06C\uB428 +gb.repositoryForkFailed= \uD3EC\uD06C\uC2E4\uD328 +gb.personalRepositories = \uAC1C\uC778 \uC800\uC7A5\uC18C +gb.allowForks = \uD3EC\uD06C \uD5C8\uC6A9 +gb.allowForksDescription = \uC774 \uC800\uC7A5\uC18C\uB97C \uC778\uC99D\uB41C \uC720\uC800\uC5D0\uAC70 \uD3EC\uD06C \uD5C8\uC6A9 +gb.forkedFrom = \uB85C\uBD80\uD130 \uD3EC\uD06C\uB428 +gb.canFork = \uD3EC\uD06C \uAC00\uB2A5 +gb.canForkDescription = \uD5C8\uC6A9\uB41C \uC800\uC7A5\uC18C\uB97C \uAC1C\uC778 \uC800\uC7A5\uC18C\uC5D0 \uD3EC\uD06C\uD560 \uC218 \uC788\uC74C +gb.myFork = \uB0B4 \uD3EC\uD06C \uBCF4\uAE30 +gb.forksProhibited = \uD3EC\uD06C \uCC28\uB2E8\uB428 +gb.forksProhibitedWarning = \uC774 \uC800\uC7A5\uC18C\uB294 \uD3EC\uD06C \uCC28\uB2E8\uB418\uC5B4 \uC788\uC74C +gb.noForks = {0} \uB294 \uD3EC\uD06C \uC5C6\uC74C +gb.forkNotAuthorized = \uC8C4\uC1A1\uD569\uB2C8\uB2E4. {0} \uD3EC\uD06C\uC5D0 \uC811\uC18D\uC774 \uC778\uC99D\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. +gb.forkInProgress = \uD504\uD06C \uC9C4\uD589 \uC911 +gb.preparingFork = \uD3EC\uD06C \uC900\uBE44 \uC911... +gb.isFork = \uD3EC\uD06C\uD55C +gb.canCreate = \uC0DD\uC131 \uAC00\uB2A5 +gb.canCreateDescription = \uAC1C\uC778 \uC800\uC7A5\uC18C\uB97C \uB9CC\uB4E4 \uC218 \uC788\uC74C +gb.illegalPersonalRepositoryLocation = \uAC1C\uC778 \uC800\uC7A5\uC18C\uB294 \uBC18\uB4DC\uC2DC \"{0}\" \uC5D0 \uC704\uCE58\uD574\uC57C \uD569\uB2C8\uB2E4. +gb.verifyCommitter = \uCEE4\uBBF8\uD130 \uD655\uC778 +gb.verifyCommitterDescription = \uCEE4\uBBF8\uD130 ID \uB294 Gitblit ID \uC640 \uB9E4\uCE58\uB418\uC5B4\uC57C \uD568 +gb.verifyCommitterNote = \uBAA8\uB4E0 \uBA38\uC9C0\uB294 \uCEE4\uBBF8\uD130 ID \uB97C \uC801\uC6A9\uD558\uAE30 \uC704\uD574 "--no-ff" \uC635\uC158 \uD544\uC694 +gb.repositoryPermissions = \uC800\uC7A5\uC18C \uAD8C\uD55C +gb.userPermissions = \uC720\uC800 \uAD8C\uD55C +gb.teamPermissions = \uD300 \uAD8C\uD55C +gb.add = \uCD94\uAC00 +gb.noPermission = \uC774 \uAD8C\uD55C \uC0AD\uC81C +gb.excludePermission = {0} (\uC81C\uC678) +gb.viewPermission = {0} (\uBCF4\uAE30) +gb.clonePermission = {0} (\uD074\uB860) +gb.pushPermission = {0} (\uD478\uC2DC) +gb.createPermission = {0} (\uD478\uC2DC, ref \uC0DD\uC131) +gb.deletePermission = {0} (\uD478\uC2DC, ref \uC0DD\uC131+\uC0AD\uC81C) +gb.rewindPermission = {0} (\uD478\uC2DC, ref \uC0DD\uC131+\uC0AD\uC81C+\uB418\uB3CC\uB9AC\uAE30) +gb.permission = \uAD8C\uD55C +gb.regexPermission = \uC774 \uAD8C\uD55C\uC740 \uC815\uADDC\uC2DD \"{0}\" \uB85C\uBD80\uD130 \uC124\uC815\uB428 +gb.accessDenied = \uC811\uC18D \uAC70\uBD80 +gb.busyCollectingGarbage = \uC8C4\uC1A1\uD569\uB2C8\uB2E4. Gitblit \uC740 \uAC00\uBE44\uC9C0 \uCEEC\uB809\uC158 \uC911\uC785\uB2C8\uB2E4. {0} +gb.gcPeriod = GC \uC8FC\uAE30 +gb.gcPeriodDescription = \uAC00\uBE44\uC9C0 \uD074\uB809\uC158\uAC04\uC758 \uC2DC\uAC04 \uAC04\uACA9 +gb.gcThreshold = GC \uAE30\uC900\uC810 +gb.gcThresholdDescription = \uC870\uAE30 \uAC00\uBE44\uC9C0 \uCEEC\uB809\uC158\uC744 \uBC1C\uC0DD\uC2DC\uD0A4\uAE30 \uC704\uD55C \uC624\uBE0C\uC81D\uD2B8\uB4E4\uC758 \uCD5C\uC18C \uC804\uCCB4 \uD06C\uAE30 +gb.ownerPermission = \uC800\uC7A5\uC18C \uC624\uB108 +gb.administrator = \uAD00\uB9AC\uC790 +gb.administratorPermission = Gitblit \uAD00\uB9AC\uC790 +gb.team = \uD300 +gb.teamPermission = \"{0}\" \uD300 \uBA64\uBC84\uC5D0 \uAD8C\uD55C \uC124\uC815\uB428 +gb.missing = \uB204\uB77D! +gb.missingPermission = \uC774 \uAD8C\uD55C\uC744 \uC704\uD55C \uC800\uC7A5\uC18C \uB204\uB77D! +gb.mutable = \uAC00\uBCC0 +gb.specified = \uC9C0\uC815\uB41C +gb.effective = \uD6A8\uACFC\uC801 +gb.organizationalUnit = \uC870\uC9C1 +gb.organization = \uAE30\uAD00 +gb.locality = \uC704\uCE58 +gb.stateProvince = \uB3C4 \uB610\uB294 \uC8FC +gb.countryCode = \uAD6D\uAC00\uCF54\uB4DC +gb.properties = \uC18D\uC131 +gb.issued = \uBC1C\uAE09\uB428 +gb.expires = \uB9CC\uB8CC +gb.expired = \uB9CC\uB8CC\uB428 +gb.expiring = \uB9CC\uB8CC\uC911 +gb.revoked = \uD3D0\uAE30\uB428 +gb.serialNumber = \uC77C\uB828\uBC88\uD638 +gb.certificates = \uC778\uC99D\uC11C +gb.newCertificate = \uC0C8 \uC778\uC99D\uC11C +gb.revokeCertificate = \uC778\uC99D\uC11C \uD3D0\uAE30 +gb.sendEmail = \uBA54\uC77C \uBCF4\uB0B4\uAE30 +gb.passwordHint = \uD328\uC2A4\uC6CC\uB4DC \uD78C\uD2B8 gb.ok = ok -gb.invalidExpirationDate = \ub9d0\ub8cc\uc77c\uc790 \uc624\ub958! -gb.passwordHintRequired = \ud328\uc2a4\uc6cc\ub4dc \ud78c\ud2b8 \ud544\uc218! -gb.viewCertificate = \uc778\uc99d\uc11c \ubcf4\uae30 -gb.subject = \uc774\ub984 -gb.issuer = \ubc1c\uae09\uc790 -gb.validFrom = \uc720\ud6a8\uae30\uac04 (\uc2dc\uc791) -gb.validUntil = \uc720\ud6a8\uae30\uac04 (\ub05d) -gb.publicKey = \uacf5\uac1c\ud0a4 -gb.signatureAlgorithm = \uc11c\uba85 \uc54c\uace0\ub9ac\uc998 -gb.sha1FingerPrint = SHA-1 \uc9c0\ubb38 \uc54c\uace0\ub9ac\uc998 -gb.md5FingerPrint = MD5 \uc9c0\ubb38 \uc54c\uace0\ub9ac\uc998 -gb.reason = \uc774\uc720 -gb.revokeCertificateReason = \uc778\uc99d\uc11c \ud574\uc9c0\uc774\uc720\ub97c \uc120\ud0dd\ud558\uc138\uc694 -gb.unspecified = \ud45c\uc2dc\ud558\uc9c0 \uc54a\uc74c -gb.keyCompromise = \ud0a4 \uc190\uc0c1 -gb.caCompromise = CA \uc190\uc0c1 -gb.affiliationChanged = \uad00\uacc4 \ubcc0\uacbd\ub428 -gb.superseded = \ub300\uccb4\ub428 -gb.cessationOfOperation = \uc6b4\uc601 \uc911\uc9c0 -gb.privilegeWithdrawn = \uad8c\ud55c \ucca0\ud68c\ub428 -gb.time.inMinutes = {0} \ubd84 -gb.time.inHours = {0} \uc2dc\uac04 -gb.time.inDays = {0} \uc77c -gb.hostname = \ud638\uc2a4\ud2b8\uba85 -gb.hostnameRequired = \ud638\uc2a4\ud2b8\uba85\uc744 \uc785\ub825\ud558\uc138\uc694 -gb.newSSLCertificate = \uc0c8 \uc11c\ubc84 SSL \uc778\uc99d\uc11c -gb.newCertificateDefaults = \uc0c8 \uc778\uc99d\uc11c \uae30\ubcf8 -gb.duration = \uae30\uac04 -gb.certificateRevoked = \uc778\uc99d\uc11c {0,number,0} \ub294 \ud3d0\uae30\ub418\uc5c8\uc2b5\ub2c8\ub2e4 -gb.clientCertificateGenerated = {0} \uc744(\ub97c) \uc704\ud55c \uc0c8\ub85c\uc6b4 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc778\uc99d\uc11c \uc0dd\uc131 \uc131\uacf5 -gb.sslCertificateGenerated = {0} \uc744(\ub97c) \uc704\ud55c \uc0c8\ub85c\uc6b4 \uc11c\ubc84 SSL \uc778\uc99d\uc11c \uc0dd\uc131 \uc131\uacf5 -gb.newClientCertificateMessage = \ub178\ud2b8:\n'\ud328\uc2a4\uc6cc\ub4dc' \ub294 \uc720\uc800\uc758 \ud328\uc2a4\uc6cc\ub4dc\uac00 \uc544\ub2c8\ub77c \uc720\uc800\uc758 \ud0a4\uc2a4\ud1a0\uc5b4 \ub97c \ubcf4\ud638\ud558\uae30 \uc704\ud55c \uac83\uc785\ub2c8\ub2e4. \uc774 \ud328\uc2a4\uc6cc\ub4dc\ub294 \uc800\uc7a5\ub418\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uc0ac\uc6a9\uc790 README \uc9c0\uce68\uc5d0 \ud3ec\ud568\ub420 '\ud78c\ud2b8' \ub97c \ubc18\ub4dc\uc2dc \uc785\ub825\ud574\uc57c \ud569\ub2c8\ub2e4. -gb.certificate = \uc778\uc99d\uc11c -gb.emailCertificateBundle = \uc774\uba54\uc77c \ud074\ub77c\uc774\uc5b8\ud2b8 \uc778\uc99d\uc11c \ubc88\ub4e4 -gb.pleaseGenerateClientCertificate = {0} \uc744(\ub97c) \uc704\ud55c \ud074\ub77c\uc774\uc5b8\ud2b8 \uc778\uc99d\uc11c\ub97c \uc0dd\uc131\ud558\uc138\uc694 -gb.clientCertificateBundleSent = {0} \uc744(\ub97c) \uc704\ud55c \ud074\ub77c\uc774\uc5b8\ud2b8 \uc778\uc99d\uc11c \ubc88\ub4e4 \ubc1c\uc1a1\ub428 -gb.enterKeystorePassword = Gitblit \ud0a4\uc2a4\ud1a0\uc5b4 \ud328\uc2a4\uc6cc\ub4dc\ub97c \uc785\ub825\ud558\uc138\uc694 -gb.warning = \uacbd\uace0 -gb.jceWarning = \uc790\ubc14 \uc2e4\ud589\ud658\uacbd\uc5d0 \"JCE Unlimited Strength Jurisdiction Policy\" \ud30c\uc77c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.\n\uc774\uac83\uc740 \ud0a4\uc800\uc7a5\uc18c \uc554\ud638\ud654\uc5d0 \uc0ac\uc6a9\ub418\ub294 \ud328\uc2a4\uc6cc\ub4dc\uc758 \uae38\uc774\ub294 7\uc790\ub85c \uc81c\ud55c\ud569\ub2c8\ub2e4.\n\uc774 \uc815\ucc45 \ud30c\uc77c\uc740 Oracle \uc5d0\uc11c \uc120\ud0dd\uc801\uc73c\ub85c \ub2e4\uc6b4\ub85c\ub4dc\ud574\uc57c \ud569\ub2c8\ub2e4.\n\n\ubb34\uc2dc\ud558\uace0 \uc778\uc99d\uc11c \uc778\ud504\ub77c\ub97c \uc0dd\uc131\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?\n\n\uc544\ub2c8\uc624(No) \ub77c\uace0 \ub2f5\ud558\uba74 \uc815\ucc45\ud30c\uc77c\uc744 \ub2e4\uc6b4\ubc1b\uc744 \uc218 \uc788\ub294 Oracle \ub2e4\uc6b4\ub85c\ub4dc \ud398\uc774\uc9c0\ub97c \ube0c\ub77c\uc6b0\uc800\ub85c \uc548\ub0b4\ud560 \uac83\uc785\ub2c8\ub2e4. -gb.maxActivityCommits = \ucd5c\ub300 \uc561\ud2f0\ube44\ud2f0 \ucee4\ubc0b -gb.maxActivityCommitsDescription = \uc561\ud2f0\ube44\ud2f0 \ud398\uc774\uc9c0\uc5d0 \ud45c\uc2dc\ud560 \ucd5c\ub300 \ucee4\ubc0b \uc218 -gb.noMaximum = \ubb34\uc81c\ud55c -gb.attributes = \uc18d\uc131 -gb.serveCertificate = \uc774 \uc778\uc99d\uc11c\ub85c https \uc81c\uacf5 -gb.sslCertificateGeneratedRestart = {0} \uc744(\ub97c) \uc704\ud55c \uc0c8 \uc11c\ubc84 SSL \uc778\uc99d\uc11c\ub97c \uc131\uacf5\uc801\uc73c\ub85c \uc0dd\uc131\ud558\uc600\uc2b5\ub2c8\ub2e4. \n\uc0c8 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 Gitblit \uc744 \uc7ac\uc2dc\uc791 \ud574\uc57c \ud569\ub2c8\ub2e4.\n\n'--alias' \ud30c\ub77c\ubbf8\ud130\ub85c \uc2e4\ud589\ud55c\ub2e4\uba74 ''--alias {0}'' \ub85c \uc124\uc815\ud574\uc57c \ud569\ub2c8\ub2e4. -gb.validity = \uc720\ud6a8\uc131 -gb.siteName = \uc0ac\uc774\ud2b8 \uc774\ub984 -gb.siteNameDescription = \uc11c\ubc84\uc758 \uc9e6\uc740 \uc124\uba85\uc774 \ud3ec\ud568\ub41c \uc774\ub984 -gb.excludeFromActivity = \uc561\ud2f0\ube44\ud2f0 \ud398\uc774\uc9c0\uc5d0\uc11c \uc81c\uc678 -gb.isSparkleshared = \uc800\uc7a5\uc18c\ub294 Sparkleshare \ub428 -gb.owners = \uc624\ub108 -gb.sessionEnded = \uc138\uc158\uc774 \uc885\ub8cc\ub428 -gb.closeBrowser = \uc815\ud655\ud788 \uc138\uc158\uc744 \uc885\ub8cc\ud558\ub824\uba74 \ube0c\ub77c\uc6b0\uc800\ub97c \ub2eb\uc544 \uc8fc\uc138\uc694. -gb.doesNotExistInTree = {1} \ud2b8\ub9ac\uc5d0 {0} \uac00 \uc5c6\uc74c -gb.enableIncrementalPushTags = \uc99d\uac00\ud558\ub294 \ud478\uc2dc \ud0dc\uadf8 \uac00\ub2a5 -gb.useIncrementalPushTagsDescription = \ud478\uc2dc\ud560 \ub54c, \uc99d\uac00\ud558\ub294 \ub9ac\ube44\uc804 \ubc88\ud638\uac00 \uc790\ub3d9\uc73c\ub85c \ud0dc\uadf8\ub428 -gb.incrementalPushTagMessage = \ud478\uc2dc\ud560 \ub54c [{0}] \ube0c\ub79c\uce58\uc5d0 \uc790\ub3d9\uc73c\ub85c \ud0dc\uadf8\ub428 -gb.externalPermissions = {0} \uc811\uc18d \uad8c\ud55c\uc740 \uc678\ubd80\uc5d0\uc11c \uad00\ub9ac\ub428 -gb.viewAccess = Gitblit \uc5d0 \uc77d\uae30 \ub610\ub294 \uc4f0\uae30 \uad8c\ud55c\uc774 \uc5c6\uc74c -gb.overview = \uac1c\uc694 -gb.dashboard = \ub300\uc2dc\ubcf4\ub4dc -gb.monthlyActivity = \uc6d4\ubcc4 \uc561\ud2f0\ube44 -gb.myProfile = \ub0b4 \ud504\ub85c\ud544 -gb.compare = \ube44\uad50 -gb.manual = \uc124\uba85\uc11c +gb.invalidExpirationDate = \uB9D0\uB8CC\uC77C\uC790 \uC624\uB958! +gb.passwordHintRequired = \uD328\uC2A4\uC6CC\uB4DC \uD78C\uD2B8 \uD544\uC218! +gb.viewCertificate = \uC778\uC99D\uC11C \uBCF4\uAE30 +gb.subject = \uC774\uB984 +gb.issuer = \uBC1C\uAE09\uC790 +gb.validFrom = \uC720\uD6A8\uAE30\uAC04 (\uC2DC\uC791) +gb.validUntil = \uC720\uD6A8\uAE30\uAC04 (\uB05D) +gb.publicKey = \uACF5\uAC1C\uD0A4 +gb.signatureAlgorithm = \uC11C\uBA85 \uC54C\uACE0\uB9AC\uC998 +gb.sha1FingerPrint = SHA-1 \uC9C0\uBB38 \uC54C\uACE0\uB9AC\uC998 +gb.md5FingerPrint = MD5 \uC9C0\uBB38 \uC54C\uACE0\uB9AC\uC998 +gb.reason = \uC774\uC720 +gb.revokeCertificateReason = \uC778\uC99D\uC11C \uD574\uC9C0\uC774\uC720\uB97C \uC120\uD0DD\uD558\uC138\uC694 +gb.unspecified = \uD45C\uC2DC\uD558\uC9C0 \uC54A\uC74C +gb.keyCompromise = \uD0A4 \uC190\uC0C1 +gb.caCompromise = CA \uC190\uC0C1 +gb.affiliationChanged = \uAD00\uACC4 \uBCC0\uACBD\uB428 +gb.superseded = \uB300\uCCB4\uB428 +gb.cessationOfOperation = \uC6B4\uC601 \uC911\uC9C0 +gb.privilegeWithdrawn = \uAD8C\uD55C \uCCA0\uD68C\uB428 +gb.time.inMinutes = {0} \uBD84 +gb.time.inHours = {0} \uC2DC\uAC04 +gb.time.inDays = {0} \uC77C +gb.hostname = \uD638\uC2A4\uD2B8\uBA85 +gb.hostnameRequired = \uD638\uC2A4\uD2B8\uBA85\uC744 \uC785\uB825\uD558\uC138\uC694 +gb.newSSLCertificate = \uC0C8 \uC11C\uBC84 SSL \uC778\uC99D\uC11C +gb.newCertificateDefaults = \uC0C8 \uC778\uC99D\uC11C \uAE30\uBCF8 +gb.duration = \uAE30\uAC04 +gb.certificateRevoked = \uC778\uC99D\uC11C {0,number,0} \uB294 \uD3D0\uAE30\uB418\uC5C8\uC2B5\uB2C8\uB2E4 +gb.clientCertificateGenerated = {0} \uC744(\uB97C) \uC704\uD55C \uC0C8\uB85C\uC6B4 \uD074\uB77C\uC774\uC5B8\uD2B8 \uC778\uC99D\uC11C \uC0DD\uC131 \uC131\uACF5 +gb.sslCertificateGenerated = {0} \uC744(\uB97C) \uC704\uD55C \uC0C8\uB85C\uC6B4 \uC11C\uBC84 SSL \uC778\uC99D\uC11C \uC0DD\uC131 \uC131\uACF5 +gb.newClientCertificateMessage = \uB178\uD2B8:\n'\uD328\uC2A4\uC6CC\uB4DC' \uB294 \uC720\uC800\uC758 \uD328\uC2A4\uC6CC\uB4DC\uAC00 \uC544\uB2C8\uB77C \uC720\uC800\uC758 \uD0A4\uC2A4\uD1A0\uC5B4 \uB97C \uBCF4\uD638\uD558\uAE30 \uC704\uD55C \uAC83\uC785\uB2C8\uB2E4. \uC774 \uD328\uC2A4\uC6CC\uB4DC\uB294 \uC800\uC7A5\uB418\uC9C0 \uC54A\uC73C\uBBC0\uB85C \uC0AC\uC6A9\uC790 README \uC9C0\uCE68\uC5D0 \uD3EC\uD568\uB420 '\uD78C\uD2B8' \uB97C \uBC18\uB4DC\uC2DC \uC785\uB825\uD574\uC57C \uD569\uB2C8\uB2E4. +gb.certificate = \uC778\uC99D\uC11C +gb.emailCertificateBundle = \uC774\uBA54\uC77C \uD074\uB77C\uC774\uC5B8\uD2B8 \uC778\uC99D\uC11C \uBC88\uB4E4 +gb.pleaseGenerateClientCertificate = {0} \uC744(\uB97C) \uC704\uD55C \uD074\uB77C\uC774\uC5B8\uD2B8 \uC778\uC99D\uC11C\uB97C \uC0DD\uC131\uD558\uC138\uC694 +gb.clientCertificateBundleSent = {0} \uC744(\uB97C) \uC704\uD55C \uD074\uB77C\uC774\uC5B8\uD2B8 \uC778\uC99D\uC11C \uBC88\uB4E4 \uBC1C\uC1A1\uB428 +gb.enterKeystorePassword = Gitblit \uD0A4\uC2A4\uD1A0\uC5B4 \uD328\uC2A4\uC6CC\uB4DC\uB97C \uC785\uB825\uD558\uC138\uC694 +gb.warning = \uACBD\uACE0 +gb.jceWarning = \uC790\uBC14 \uC2E4\uD589\uD658\uACBD\uC5D0 \"JCE Unlimited Strength Jurisdiction Policy\" \uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n\uC774\uAC83\uC740 \uD0A4\uC800\uC7A5\uC18C \uC554\uD638\uD654\uC5D0 \uC0AC\uC6A9\uB418\uB294 \uD328\uC2A4\uC6CC\uB4DC\uC758 \uAE38\uC774\uB294 7\uC790\uB85C \uC81C\uD55C\uD569\uB2C8\uB2E4.\n\uC774 \uC815\uCC45 \uD30C\uC77C\uC740 Oracle \uC5D0\uC11C \uC120\uD0DD\uC801\uC73C\uB85C \uB2E4\uC6B4\uB85C\uB4DC\uD574\uC57C \uD569\uB2C8\uB2E4.\n\n\uBB34\uC2DC\uD558\uACE0 \uC778\uC99D\uC11C \uC778\uD504\uB77C\uB97C \uC0DD\uC131\uD558\uC2DC\uACA0\uC2B5\uB2C8\uAE4C?\n\n\uC544\uB2C8\uC624(No) \uB77C\uACE0 \uB2F5\uD558\uBA74 \uC815\uCC45\uD30C\uC77C\uC744 \uB2E4\uC6B4\uBC1B\uC744 \uC218 \uC788\uB294 Oracle \uB2E4\uC6B4\uB85C\uB4DC \uD398\uC774\uC9C0\uB97C \uBE0C\uB77C\uC6B0\uC800\uB85C \uC548\uB0B4\uD560 \uAC83\uC785\uB2C8\uB2E4. +gb.maxActivityCommits = \uCD5C\uB300 \uC561\uD2F0\uBE44\uD2F0 \uCEE4\uBC0B +gb.maxActivityCommitsDescription = \uC561\uD2F0\uBE44\uD2F0 \uD398\uC774\uC9C0\uC5D0 \uD45C\uC2DC\uD560 \uCD5C\uB300 \uCEE4\uBC0B \uC218 +gb.noMaximum = \uBB34\uC81C\uD55C +gb.attributes = \uC18D\uC131 +gb.serveCertificate = \uC774 \uC778\uC99D\uC11C\uB85C https \uC81C\uACF5 +gb.sslCertificateGeneratedRestart = {0} \uC744(\uB97C) \uC704\uD55C \uC0C8 \uC11C\uBC84 SSL \uC778\uC99D\uC11C\uB97C \uC131\uACF5\uC801\uC73C\uB85C \uC0DD\uC131\uD558\uC600\uC2B5\uB2C8\uB2E4. \n\uC0C8 \uC778\uC99D\uC11C\uB97C \uC0AC\uC6A9\uD558\uB824\uBA74 Gitblit \uC744 \uC7AC\uC2DC\uC791 \uD574\uC57C \uD569\uB2C8\uB2E4.\n\n'--alias' \uD30C\uB77C\uBBF8\uD130\uB85C \uC2E4\uD589\uD55C\uB2E4\uBA74 ''--alias {0}'' \uB85C \uC124\uC815\uD574\uC57C \uD569\uB2C8\uB2E4. +gb.validity = \uC720\uD6A8\uC131 +gb.siteName = \uC0AC\uC774\uD2B8 \uC774\uB984 +gb.siteNameDescription = \uC11C\uBC84\uC758 \uC9E6\uC740 \uC124\uBA85\uC774 \uD3EC\uD568\uB41C \uC774\uB984 +gb.excludeFromActivity = \uC561\uD2F0\uBE44\uD2F0 \uD398\uC774\uC9C0\uC5D0\uC11C \uC81C\uC678 +gb.isSparkleshared = \uC800\uC7A5\uC18C\uB294 Sparkleshare \uB428 +gb.owners = \uC624\uB108 +gb.sessionEnded = \uC138\uC158\uC774 \uC885\uB8CC\uB428 +gb.closeBrowser = \uC815\uD655\uD788 \uC138\uC158\uC744 \uC885\uB8CC\uD558\uB824\uBA74 \uBE0C\uB77C\uC6B0\uC800\uB97C \uB2EB\uC544 \uC8FC\uC138\uC694. +gb.doesNotExistInTree = {1} \uD2B8\uB9AC\uC5D0 {0} \uAC00 \uC5C6\uC74C +gb.enableIncrementalPushTags = \uC99D\uAC00\uD558\uB294 \uD478\uC2DC \uD0DC\uADF8 \uAC00\uB2A5 +gb.useIncrementalPushTagsDescription = \uD478\uC2DC\uD560 \uB54C, \uC99D\uAC00\uD558\uB294 \uB9AC\uBE44\uC804 \uBC88\uD638\uAC00 \uC790\uB3D9\uC73C\uB85C \uD0DC\uADF8\uB428 +gb.incrementalPushTagMessage = \uD478\uC2DC\uD560 \uB54C [{0}] \uBE0C\uB79C\uCE58\uC5D0 \uC790\uB3D9\uC73C\uB85C \uD0DC\uADF8\uB428 +gb.externalPermissions = {0} \uC811\uC18D \uAD8C\uD55C\uC740 \uC678\uBD80\uC5D0\uC11C \uAD00\uB9AC\uB428 +gb.viewAccess = Gitblit \uC5D0 \uC77D\uAE30 \uB610\uB294 \uC4F0\uAE30 \uAD8C\uD55C\uC774 \uC5C6\uC74C +gb.overview = \uAC1C\uC694 +gb.dashboard = \uB300\uC2DC\uBCF4\uB4DC +gb.monthlyActivity = \uC6D4\uBCC4 \uC561\uD2F0\uBE44 +gb.myProfile = \uB0B4 \uD504\uB85C\uD544 +gb.compare = \uBE44\uAD50 +gb.manual = \uC124\uBA85\uC11C gb.from = from gb.to = to gb.at = at gb.of = of gb.in = in -gb.moreChanges = \ubaa8\ub4e0 \ubcc0\uacbd... -gb.pushedNCommitsTo = {0} \uac1c \ucee4\ubc0b\uc774 \ud478\uc2dc\ub428 -gb.pushedOneCommitTo = 1 \uac1c \ucee4\ubc0b\uc774 \ud478\uc2dc\ub428 -gb.commitsTo = {0} \uac1c \ucee4\ubc0b -gb.oneCommitTo = 1 \uac1c \ucee4\ubc0b -gb.byNAuthors = {0} \uba85\uc758 \uc791\uc131\uc790 -gb.byOneAuthor = {0} \uc5d0 \uc758\ud574 -gb.viewComparison = {0} \ucee4\ubc0b\uc758 \ube44\uad50 \ubcf4\uae30 \u00bb -gb.nMoreCommits = {0} \uac1c \ub354 \u00bb -gb.oneMoreCommit = 1 \uac1c \ub354 \u00bb -gb.pushedNewTag = \uc0c8 \ud0dc\uadf8\uac00 \ud478\uc2dc\ub428 -gb.createdNewTag = \uc0c8 \ud0dc\uadf8\uac00 \uc0dd\uc131\ub428 -gb.deletedTag = \ud0dc\uadf8\uac00 \uc0ad\uc81c\ub428 -gb.pushedNewBranch = \uc0c8 \ube0c\ub79c\uce58\uac00 \ud478\uc2dc\ub428 -gb.createdNewBranch = \uc0c8 \ube0c\ub79c\uce58\uac00 \uc0dd\uc131\ub428 -gb.deletedBranch = \ube0c\ub79c\uce58\uac00 \uc0ad\uc81c\ub428 -gb.createdNewPullRequest = \ud480 \ub9ac\ud018\uc2a4\ud2b8\uac00 \uc0dd\uc131\ub428 -gb.mergedPullRequest = \ud480 \ub9ac\ud018\uc2a4\ud2b8\uac00 \uba38\uc9c0\ub428 +gb.moreChanges = \uBAA8\uB4E0 \uBCC0\uACBD... +gb.pushedNCommitsTo = {0} \uAC1C \uCEE4\uBC0B\uC774 \uD478\uC2DC\uB428 +gb.pushedOneCommitTo = 1 \uAC1C \uCEE4\uBC0B\uC774 \uD478\uC2DC\uB428 +gb.commitsTo = {0} \uAC1C \uCEE4\uBC0B +gb.oneCommitTo = 1 \uAC1C \uCEE4\uBC0B +gb.byNAuthors = {0} \uBA85\uC758 \uC791\uC131\uC790 +gb.byOneAuthor = {0} \uC5D0 \uC758\uD574 +gb.viewComparison = {0} \uCEE4\uBC0B\uC758 \uBE44\uAD50 \uBCF4\uAE30 \u00BB +gb.nMoreCommits = {0} \uAC1C \uB354 \u00BB +gb.oneMoreCommit = 1 \uAC1C \uB354 \u00BB +gb.pushedNewTag = \uC0C8 \uD0DC\uADF8\uAC00 \uD478\uC2DC\uB428 +gb.createdNewTag = \uC0C8 \uD0DC\uADF8\uAC00 \uC0DD\uC131\uB428 +gb.deletedTag = \uD0DC\uADF8\uAC00 \uC0AD\uC81C\uB428 +gb.pushedNewBranch = \uC0C8 \uBE0C\uB79C\uCE58\uAC00 \uD478\uC2DC\uB428 +gb.createdNewBranch = \uC0C8 \uBE0C\uB79C\uCE58\uAC00 \uC0DD\uC131\uB428 +gb.deletedBranch = \uBE0C\uB79C\uCE58\uAC00 \uC0AD\uC81C\uB428 +gb.createdNewPullRequest = \uD480 \uB9AC\uD018\uC2A4\uD2B8\uAC00 \uC0DD\uC131\uB428 +gb.mergedPullRequest = \uD480 \uB9AC\uD018\uC2A4\uD2B8\uAC00 \uBA38\uC9C0\uB428 gb.rewind = REWIND -gb.star = \ubcc4 -gb.unstar = \ubcc4\uc81c\uac70 -gb.stargazers = \uad00\uce21\uc790 -gb.starredRepositories = \ubcc4 \ud45c\uc2dc\ub41c \uc800\uc7a5\uc18c -gb.failedToUpdateUser = \uacc4\uc815 \uc5c5\ub370\uc774\ud2b8 \uc2e4\ud328! -gb.myRepositories = \ub0b4 \uc800\uc7a5\uc18c -gb.noActivity = \uc9c0\ub09c {0} \uc77c\uac04 \uc561\ud2f0\ube44\ud2f0 \uc5c6\uc74c -gb.findSomeRepositories = \uc800\uc7a5\uc18c \ucc3e\uae30 -gb.metricAuthorExclusions = \uc791\uc131\uc790 \uba54\ud2b8\ub9ad \uc81c\uc678 -gb.myDashboard = \ub0b4 \ub300\uc2dc\ubcf4\ub4dc -gb.failedToFindAccount = ''{0}'' \uacc4\uc815 \ucc3e\uae30 \uc2e4\ud328 +gb.star = \uBCC4 +gb.unstar = \uBCC4\uC81C\uAC70 +gb.stargazers = \uAD00\uCE21\uC790 +gb.starredRepositories = \uBCC4 \uD45C\uC2DC\uB41C \uC800\uC7A5\uC18C +gb.failedToUpdateUser = \uACC4\uC815 \uC5C5\uB370\uC774\uD2B8 \uC2E4\uD328! +gb.myRepositories = \uB0B4 \uC800\uC7A5\uC18C +gb.noActivity = \uC9C0\uB09C {0} \uC77C\uAC04 \uC561\uD2F0\uBE44\uD2F0 \uC5C6\uC74C +gb.findSomeRepositories = \uC800\uC7A5\uC18C \uCC3E\uAE30 +gb.metricAuthorExclusions = \uC791\uC131\uC790 \uBA54\uD2B8\uB9AD \uC81C\uC678 +gb.myDashboard = \uB0B4 \uB300\uC2DC\uBCF4\uB4DC +gb.failedToFindAccount = ''{0}'' \uACC4\uC815 \uCC3E\uAE30 \uC2E4\uD328 gb.reflog = reflog -gb.active = \ud65c\uc131 -gb.starred = \ubcc4\ud45c -gb.owned = \uc18c\uc720\ud568 -gb.starredAndOwned = \ubcc4\ud45c & \uc18c\uc720\ud568 -gb.reviewPatchset = \ub9ac\ubdf0 {0} \ud328\uce58\uc14b {1} -gb.todaysActivityStats = \uc624\ub298 / {2} \uc791\uc131\uc790\uac00 {1} \ucee4\ubc0b\uc0dd\uc131 -gb.todaysActivityNone = \uc624\ub298 / \uc5c6\uc74c -gb.noActivityToday = \uc624\ub298\uc740 \uc561\ud2f0\ube44\ud2f0\uac00 \uc5c6\uc74c -gb.anonymousUser= \uc775\uba85 -gb.commitMessageRenderer = \ucee4\ubc0b \uba54\uc2dc\uc9c0 \ub79c\ub354\ub7ec -gb.diffStat = {0}\uac1c \ucd94\uac00 & {1}\uac1c \uc0ad\uc81c -gb.home = \ud648 -gb.isMirror = \ubbf8\ub7ec \uc800\uc7a5\uc18c -gb.mirrorOf = {0} \uc758 \ubbf8\ub7ec -gb.mirrorWarning = \uc774 \uc800\uc7a5\uc18c\ub294 \ubbf8\ub7ec\uc774\uace0 \ud478\uc2dc\ub97c \ubc1b\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.docsWelcome1 = \uc800\uc7a5\uc18c\ub97c \ubb38\uc11c\ud654\ud558\uae30 \uc704\ud574 \ubb38\uc11c\ub4e4\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.docsWelcome2 = \uc2dc\uc791\ud558\uae30 \uc704\ud574 README.md \ub610\ub294 HOME.md \ud30c\uc77c\uc744 \ucee4\ubc0b\ud558\uc138\uc694. -gb.createReadme = README \uc0dd\uc131 -gb.responsible = \ub2f4\ub2f9\uc790 -gb.createdThisTicket = \uc774 \ud2f0\ucf13 \uc0dd\uc131 -gb.proposedThisChange = \uc774 \ubcc0\uacbd \uc81c\uc548 -gb.uploadedPatchsetN = {0} \ud328\uce58\uc14b \uc5c5\ub85c\ub4dc -gb.uploadedPatchsetNRevisionN = \ub9ac\ube44\uc804 {1} \ud328\uce58\uc14b {0} \uc5c5\ub85c\ub4dc -gb.mergedPatchset = \ud328\uce58\uc14b \uba38\uc9c0 -gb.commented = \ucf54\uba58\ud2b8 -gb.noDescriptionGiven = \uc124\uba85 \uc5c6\uc74c -gb.toBranch = {0}\uc5d0\uac8c -gb.createdBy = \uc0dd\uc131\uc790 -gb.oneParticipant = \ucc38\uac00\uc790 {0} -gb.nParticipants = \ucc38\uac00\uc790 {0} -gb.noComments = \ucf54\uba58\ud2b8 \uc5c6\uc74c -gb.oneComment = \ucf54\uba58\ud2b8 {0} -gb.nComments = \ucf54\uba58\ud2b8 {0} -gb.oneAttachment = \ucca8\ubd80\ud30c\uc77c {0} -gb.nAttachments = \ucca8\ubd80\ud30c\uc77c {0} -gb.milestone = \ub9c8\uc77c\uc2a4\ud1a4 -gb.compareToMergeBase = \uba38\uc9c0\ubca0\uc774\uc2a4\uc640 \ube44\uad50 -gb.compareToN = {0}\uc640 \ube44\uad50 -gb.open = \uc5f4\ub9bc -gb.closed = \ub2eb\ud798 -gb.merged = \uba38\uc9c0\ud568 -gb.ticketPatchset = {0} \ud2f0\ucf13, {1} \ud328\uce58\uc14b -gb.patchsetMergeable = \uc774 \ud328\uce58\uc14b\uc740 {0}\uc5d0 \uc790\ub3d9\uc73c\ub85c \uba38\uc9c0\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.patchsetMergeableMore = \uc774 \ud328\uce58\uc14b\uc740 \ucee4\ub9e8\ub4dc \ub77c\uc778\uc5d0\uc11c {0}\uc5d0 \uba38\uc9c0\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.patchsetAlreadyMerged = \uc774 \ud328\uce58\uc14b\uc740 {0}\uc5d0 \uba38\uc9c0\ub428. -gb.patchsetNotMergeable = \uc774 \ud328\uce58\uc14b\uc740 {0}\uc5d0 \uc790\ub3d9\uc73c\ub85c \uba38\uc9c0\ub420 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.patchsetNotMergeableMore = \ucda9\ub3cc\uc744 \ud574\uacb0\ud558\uae30 \uc704\ud574 \uc774 \ud328\uce58\uc14b\uc740 \ub9ac\ubca0\uc774\uc2a4 \ud558\uac70\ub098 {0}\uc5d0 \uba38\uc9c0\ub418\uc5b4\uc57c \ud569\ub2c8\ub2e4. -gb.patchsetNotApproved = \uc774 \ud328\uce58\uc14b \ub9ac\ube44\uc804\uc740 {0}\uc5d0 \uba38\uc9c0\ub418\ub294\uac83\uc774 \uc2b9\uc778\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. -gb.patchsetNotApprovedMore = \ub9ac\ubdf0\uc5b4\uac00 \uc774 \ud328\uce58\uc14b\uc744 \uc2b9\uc778\ud574\uc57c \ud569\ub2c8\ub2e4. -gb.patchsetVetoedMore = \ub9ac\ubdf0\uc5b4\uac00 \uc774 \ud328\uce58\uc14b\uc744 \uac70\ubd80\ud558\uc600\uc2b5\ub2c8\ub2e4. -gb.write = \uc4f0\uae30 -gb.comment = \ucf54\uba58\ud2b8 -gb.preview = \ubbf8\ub9ac\ubcf4\uae30 -gb.leaveComment = \ucf54\uba58\ud2b8 \ub0a8\uae30\uae30... -gb.showHideDetails = \uc0c1\uc138 \ubcf4\uae30/\uc228\uae30\uae30 -gb.acceptNewPatchsets = \ud328\uce58\uc14b \uc2b9\uc778 -gb.acceptNewPatchsetsDescription = \uc774 \uc800\uc7a5\uc18c\uc5d0 \ud328\uce58\uc14b\uc744 \ud478\uc2dc\ud558\ub294\uac83\uc744 \uc2b9\uc778 -gb.acceptNewTickets = \uc0c8 \ud2f0\ucf13 \ud5c8\uc6a9 -gb.acceptNewTicketsDescription = \ubc84\uadf8, \uac1c\uc120, \ud0c0\uc2a4\ud06c, \ub4f1\uc758 \ud2f0\ucf13 \uc0dd\uc131 \ud5c8\uc6a9 -gb.requireApproval = \uc2b9\uc778 \ud544\uc694 -gb.requireApprovalDescription = \uba38\uc9c0\ubc84\ud2bc \ud65c\uc131\ud654 \uc804 \ud328\uce58\uc14b\uc774 \uc2b9\uc778\ub418\uc5b4\uc57c \ud568 -gb.topic = \ud1a0\ud53d -gb.proposalTickets = \ubcc0\uacbd \uc81c\uc548 -gb.bugTickets = \ubc84\uadf8 -gb.enhancementTickets = \uac1c\uc120 -gb.taskTickets = \ud0c0\uc2a4\ud06c -gb.questionTickets = \uc9c8\ubb38 -gb.requestTickets = \uac1c\uc120 & \ud0c0\uc2a4\ud06c -gb.yourCreatedTickets = \ub0b4\uac00 \uc0dd\uc131\ud568 -gb.yourWatchedTickets = \ub0b4\uac00 \uc9c0\ucf1c\ubd04 -gb.mentionsMeTickets = \ub098\ub97c \ub9e8\uc158 \uc911 -gb.updatedBy = \uc5c5\ub370\uc774\ud2b8 -gb.sort = \uc815\ub82c -gb.sortNewest = \ucd5c\uc2e0 \uc21c -gb.sortOldest = \uc624\ub798\ub41c \uc21c -gb.sortMostRecentlyUpdated = \ucd5c\uadfc \uc5c5\ub370\uc774\ud2b8 \uc21c -gb.sortLeastRecentlyUpdated = \uc624\ub798\ub41c \uc5c5\ub370\uc774\ud2b8\ub41c \uc21c -gb.sortMostComments = \ucf54\uba58\ud2b8 \ub9ce\uc740 \uc21c -gb.sortLeastComments = \ucf54\uba58\ud2b8 \uc801\uc740 \uc21c -gb.sortMostPatchsetRevisions = \ud328\uce58\uc14b \ub9ac\ube44\uc804 \ub9ce\uc740 \uc21c -gb.sortLeastPatchsetRevisions = \ud328\uce58\uc14b \ub9ac\ube44\uc804 \uc801\uc740 \uc21c -gb.sortMostVotes = \uac70\ubd80 \ub9ce\uc740 \uc21c -gb.sortLeastVotes = \uac70\ubd80 \uc801\uc740 \uc21c -gb.topicsAndLabels = \ud1a0\ud53d & \ub77c\ubca8 -gb.milestones = \ub9c8\uc77c\uc2a4\ud1a4 -gb.noMilestoneSelected = \uc120\ud0dd\ub41c \ub9c8\uc77c\uc2a4\ud1a4 \uc5c6\uc74c -gb.notSpecified = \uc9c0\uc815\ub418\uc9c0 \uc54a\uc74c +gb.active = \uD65C\uC131 +gb.starred = \uBCC4\uD45C +gb.owned = \uC18C\uC720\uD568 +gb.starredAndOwned = \uBCC4\uD45C & \uC18C\uC720\uD568 +gb.reviewPatchset = \uB9AC\uBDF0 {0} \uD328\uCE58\uC14B {1} +gb.todaysActivityStats = \uC624\uB298 / {2} \uC791\uC131\uC790\uAC00 {1} \uCEE4\uBC0B\uC0DD\uC131 +gb.todaysActivityNone = \uC624\uB298 / \uC5C6\uC74C +gb.noActivityToday = \uC624\uB298\uC740 \uC561\uD2F0\uBE44\uD2F0\uAC00 \uC5C6\uC74C +gb.anonymousUser= \uC775\uBA85 +gb.commitMessageRenderer = \uCEE4\uBC0B \uBA54\uC2DC\uC9C0 \uB79C\uB354\uB7EC +gb.diffStat = {0}\uAC1C \uCD94\uAC00 & {1}\uAC1C \uC0AD\uC81C +gb.home = \uD648 +gb.isMirror = \uBBF8\uB7EC \uC800\uC7A5\uC18C +gb.mirrorOf = {0} \uC758 \uBBF8\uB7EC +gb.mirrorWarning = \uC774 \uC800\uC7A5\uC18C\uB294 \uBBF8\uB7EC\uC774\uACE0 \uD478\uC2DC\uB97C \uBC1B\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.docsWelcome1 = \uC800\uC7A5\uC18C\uB97C \uBB38\uC11C\uD654\uD558\uAE30 \uC704\uD574 \uBB38\uC11C\uB4E4\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.docsWelcome2 = \uC2DC\uC791\uD558\uAE30 \uC704\uD574 README.md \uB610\uB294 HOME.md \uD30C\uC77C\uC744 \uCEE4\uBC0B\uD558\uC138\uC694. +gb.createReadme = README \uC0DD\uC131 +gb.responsible = \uB2F4\uB2F9\uC790 +gb.createdThisTicket = \uC774 \uD2F0\uCF13 \uC0DD\uC131 +gb.proposedThisChange = \uC774 \uBCC0\uACBD \uC81C\uC548 +gb.uploadedPatchsetN = {0} \uD328\uCE58\uC14B \uC5C5\uB85C\uB4DC +gb.uploadedPatchsetNRevisionN = \uB9AC\uBE44\uC804 {1} \uD328\uCE58\uC14B {0} \uC5C5\uB85C\uB4DC +gb.mergedPatchset = \uD328\uCE58\uC14B \uBA38\uC9C0 +gb.commented = \uCF54\uBA58\uD2B8 +gb.noDescriptionGiven = \uC124\uBA85 \uC5C6\uC74C +gb.toBranch = {0}\uC5D0\uAC8C +gb.createdBy = \uC0DD\uC131\uC790 +gb.oneParticipant = \uCC38\uAC00\uC790 {0} +gb.nParticipants = \uCC38\uAC00\uC790 {0} +gb.noComments = \uCF54\uBA58\uD2B8 \uC5C6\uC74C +gb.oneComment = \uCF54\uBA58\uD2B8 {0} +gb.nComments = \uCF54\uBA58\uD2B8 {0} +gb.oneAttachment = \uCCA8\uBD80\uD30C\uC77C {0} +gb.nAttachments = \uCCA8\uBD80\uD30C\uC77C {0} +gb.milestone = \uB9C8\uC77C\uC2A4\uD1A4 +gb.compareToMergeBase = \uBA38\uC9C0\uBCA0\uC774\uC2A4\uC640 \uBE44\uAD50 +gb.compareToN = {0}\uC640 \uBE44\uAD50 +gb.open = \uC5F4\uB9BC +gb.closed = \uB2EB\uD798 +gb.merged = \uBA38\uC9C0\uD568 +gb.ticketPatchset = {0} \uD2F0\uCF13, {1} \uD328\uCE58\uC14B +gb.patchsetMergeable = \uC774 \uD328\uCE58\uC14B\uC740 {0}\uC5D0 \uC790\uB3D9\uC73C\uB85C \uBA38\uC9C0\uB420 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.patchsetMergeableMore = \uC774 \uD328\uCE58\uC14B\uC740 \uCEE4\uB9E8\uB4DC \uB77C\uC778\uC5D0\uC11C {0}\uC5D0 \uBA38\uC9C0\uB420 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.patchsetAlreadyMerged = \uC774 \uD328\uCE58\uC14B\uC740 {0}\uC5D0 \uBA38\uC9C0\uB428. +gb.patchsetNotMergeable = \uC774 \uD328\uCE58\uC14B\uC740 {0}\uC5D0 \uC790\uB3D9\uC73C\uB85C \uBA38\uC9C0\uB420 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.patchsetNotMergeableMore = \uCDA9\uB3CC\uC744 \uD574\uACB0\uD558\uAE30 \uC704\uD574 \uC774 \uD328\uCE58\uC14B\uC740 \uB9AC\uBCA0\uC774\uC2A4 \uD558\uAC70\uB098 {0}\uC5D0 \uBA38\uC9C0\uB418\uC5B4\uC57C \uD569\uB2C8\uB2E4. +gb.patchsetNotApproved = \uC774 \uD328\uCE58\uC14B \uB9AC\uBE44\uC804\uC740 {0}\uC5D0 \uBA38\uC9C0\uB418\uB294\uAC83\uC774 \uC2B9\uC778\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. +gb.patchsetNotApprovedMore = \uB9AC\uBDF0\uC5B4\uAC00 \uC774 \uD328\uCE58\uC14B\uC744 \uC2B9\uC778\uD574\uC57C \uD569\uB2C8\uB2E4. +gb.patchsetVetoedMore = \uB9AC\uBDF0\uC5B4\uAC00 \uC774 \uD328\uCE58\uC14B\uC744 \uAC70\uBD80\uD558\uC600\uC2B5\uB2C8\uB2E4. +gb.write = \uC4F0\uAE30 +gb.comment = \uCF54\uBA58\uD2B8 +gb.preview = \uBBF8\uB9AC\uBCF4\uAE30 +gb.leaveComment = \uCF54\uBA58\uD2B8 \uB0A8\uAE30\uAE30... +gb.showHideDetails = \uC0C1\uC138 \uBCF4\uAE30/\uC228\uAE30\uAE30 +gb.acceptNewPatchsets = \uD328\uCE58\uC14B \uC2B9\uC778 +gb.acceptNewPatchsetsDescription = \uC774 \uC800\uC7A5\uC18C\uC5D0 \uD328\uCE58\uC14B\uC744 \uD478\uC2DC\uD558\uB294\uAC83\uC744 \uC2B9\uC778 +gb.acceptNewTickets = \uC0C8 \uD2F0\uCF13 \uD5C8\uC6A9 +gb.acceptNewTicketsDescription = \uBC84\uADF8, \uAC1C\uC120, \uD0C0\uC2A4\uD06C, \uB4F1\uC758 \uD2F0\uCF13 \uC0DD\uC131 \uD5C8\uC6A9 +gb.requireApproval = \uC2B9\uC778 \uD544\uC694 +gb.requireApprovalDescription = \uBA38\uC9C0\uBC84\uD2BC \uD65C\uC131\uD654 \uC804 \uD328\uCE58\uC14B\uC774 \uC2B9\uC778\uB418\uC5B4\uC57C \uD568 +gb.topic = \uD1A0\uD53D +gb.proposalTickets = \uBCC0\uACBD \uC81C\uC548 +gb.bugTickets = \uBC84\uADF8 +gb.enhancementTickets = \uAC1C\uC120 +gb.taskTickets = \uD0C0\uC2A4\uD06C +gb.questionTickets = \uC9C8\uBB38 +gb.maintenanceTickets = \uC720\uC9C0\uBCF4\uC218 +gb.requestTickets = \uAC1C\uC120 & \uD0C0\uC2A4\uD06C +gb.yourCreatedTickets = \uB0B4\uAC00 \uC0DD\uC131\uD568 +gb.yourWatchedTickets = \uB0B4\uAC00 \uC9C0\uCF1C\uBD04 +gb.mentionsMeTickets = \uB098\uB97C \uB9E8\uC158 \uC911 +gb.updatedBy = \uC5C5\uB370\uC774\uD2B8 +gb.sort = \uC815\uB82C +gb.sortNewest = \uCD5C\uC2E0 \uC21C +gb.sortOldest = \uC624\uB798\uB41C \uC21C +gb.sortMostRecentlyUpdated = \uCD5C\uADFC \uC5C5\uB370\uC774\uD2B8 \uC21C +gb.sortLeastRecentlyUpdated = \uC624\uB798\uB41C \uC5C5\uB370\uC774\uD2B8\uB41C \uC21C +gb.sortMostComments = \uCF54\uBA58\uD2B8 \uB9CE\uC740 \uC21C +gb.sortLeastComments = \uCF54\uBA58\uD2B8 \uC801\uC740 \uC21C +gb.sortMostPatchsetRevisions = \uD328\uCE58\uC14B \uB9AC\uBE44\uC804 \uB9CE\uC740 \uC21C +gb.sortLeastPatchsetRevisions = \uD328\uCE58\uC14B \uB9AC\uBE44\uC804 \uC801\uC740 \uC21C +gb.sortMostVotes = \uAC70\uBD80 \uB9CE\uC740 \uC21C +gb.sortLeastVotes = \uAC70\uBD80 \uC801\uC740 \uC21C +gb.topicsAndLabels = \uD1A0\uD53D & \uB77C\uBCA8 +gb.milestones = \uB9C8\uC77C\uC2A4\uD1A4 +gb.noMilestoneSelected = \uC120\uD0DD\uB41C \uB9C8\uC77C\uC2A4\uD1A4 \uC5C6\uC74C +gb.notSpecified = \uC9C0\uC815\uB418\uC9C0 \uC54A\uC74C gb.due = due -gb.queries = \ucffc\ub9ac -gb.searchTicketsTooltip = {0} \ud2f0\ucf13 \uac80\uc0c9 -gb.searchTickets = \ud2f0\ucf13 \uac80\uc0c9 -gb.new = \uc0c8 -gb.newTicket = \uc0c8 \ud2f0\ucf13 -gb.editTicket = \ud2f0\ucf13 \uc218\uc815 -gb.ticketsWelcome = \ud560\uc77c \ubaa9\ub85d, \ubc84\uadf8 \ud1a0\ub860\uc744 \uc815\ub9ac\ud558\uace0 \ud328\uce58\uc14b\uc73c\ub85c \ud611\uc5c5\ud558\uae30 \uc704\ud574 \ud2f0\ucf13\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.createFirstTicket = \uccab\ubc88\uc9f8 \ud2f0\ucf13\uc744 \ub9cc\ub4dc\uc138\uc694. -gb.title = \uc81c\ubaa9 -gb.changedStatus = \uc0c1\ud0dc \ubcc0\uacbd\ub428 -gb.discussion = \ud1a0\ub860 -gb.updated = \uc5c5\ub370\uc774\ud2b8\ub428 -gb.proposePatchset = \ud328\uce58\uc14b\uc758 \uc81c\uc548 -gb.proposePatchsetNote = \uc774 \ud2f0\ucf13\uc5d0 \ub300\ud55c \ud328\uce58\uc14b \uc81c\uc548\uc744 \ud658\uc601\ud569\ub2c8\ub2e4. -gb.proposeInstructions = \ub9e8 \uba3c\uc800, \ud328\uce58\uc14b\uc744 \ub9cc\ub4e4\uace0 Git\uc73c\ub85c \uc5c5\ub85c\ub4dc \ud558\uc138\uc694. Gitblit \uc774 id \ub85c \uc774 \ud2f0\ucf13\uacfc \uc5f0\uacb0\ud560 \uac83\uc785\ub2c8\ub2e4. -gb.proposeWith = \uc774\uac83\uc73c\ub85c \ud328\uce58\uc14b \uc81c\uc548 - {0} -gb.revisionHistory = \ub9ac\ube44\uc804 \ud788\uc2a4\ud1a0\ub9ac -gb.merge = \uba38\uc9c0 -gb.action = \uc561\uc158 -gb.patchset = \ud328\uce58\uc14b -gb.all = \ubaa8\ub450 -gb.mergeBase = \uba38\uc9c0 \ubca0\uc774\uc2a4 -gb.checkout = \uccb4\ud06c\uc544\uc6c3 -gb.checkoutViaCommandLine = \ucee4\ub9e8\ub4dc \ub77c\uc778\uc73c\ub85c \uccb4\ud06c\uc544\uc6c3 -gb.checkoutViaCommandLineNote = \uc774 \uc800\uc7a5\uc18c\uc758 \ud074\ub860\uc5d0\uc11c \ub85c\uceec \ubcc0\uacbd\uc0ac\ud56d\uc744 \uccb4\ud06c\uc544\uc6c3\ud558\uace0 \ud14c\uc2a4\ud2b8\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.checkoutStep1 = \ud604\uc7ac \ud328\uce58\uc14b\uc744 \uac00\uc838\uc624\uc138\uc694. - \ud504\ub85c\uc81d\ud2b8 \ub514\ub809\ud1a0\ub9ac\uc5d0\uc11c \uc774 \uc791\uc5c5\uc744 \uc2e4\ud589\ud558\uc138\uc694. -gb.checkoutStep2 = \uc0c8 \ube0c\ub79c\uce58\uc640 \ub9ac\ubdf0\uc5d0 \ud328\uce58\uc14b\uc744 \uccb4\ud06c\uc544\uc6c3 \ud558\uc138\uc694. -gb.mergingViaCommandLine = \ucee4\ub9e8\ub4dc \ub77c\uc778\uc5d0\uc11c \uba38\uc9d5 -gb.mergingViaCommandLineNote = \uba38\uc9c0 \ubc84\ud2bc \uc0ac\uc6a9\uc744 \uc6d0\ud558\uc9c0 \uc54a\uac70\ub098 \uc790\ub3d9 \uba38\uc9c0\uac00 \ub3d9\uc791\ud558\uc9c0 \ub418\uc9c0 \uc54a\ub294\ub2e4\uba74, \ucee4\ub9e8\ub4dc \ub77c\uc778\uc5d0\uc11c \uc218\ub3d9\uc73c\ub85c \uba38\uc9c0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.mergeStep1 = \ubcc0\uacbd\uc0ac\ud56d\uc744 \ub9ac\ubdf0\ud558\uae30\uc704\ud574 \uc0c8 \ube0c\ub79c\uce58\ub97c \uccb4\ud06c\uc544\uc6c3 \ud558\uc138\uc694. \u2014 \ud504\ub85c\uc81d\ud2b8 \ub514\ub809\ud1a0\ub9ac\uc5d0\uc11c \uc774 \uc791\uc5c5\uc744 \uc2e4\ud589\ud558\uc138\uc694. -gb.mergeStep2 = \uc81c\uc548\ub41c \ubcc0\uacbd\uc0ac\ud56d\uacfc \ub9ac\ubdf0\ub97c \uac00\uc838\uc624\uc138\uc694. -gb.mergeStep3 = \uc81c\uc548\ub41c \ubcc0\uacbd\uc0ac\ud56d\uc744 \uba38\uc9c0\ud558\uace0 \uc11c\ubc84\uc5d0 \uc5c5\ub370\uc774\ud2b8 \ud558\uc138\uc694. -gb.download = \ub2e4\uc6b4\ub85c\ub4dc -gb.ptDescription = Gitblit \ud328\uce58\uc14b \ub3c4\uad6c -gb.ptCheckout = \ub9ac\ubdf0 \ube0c\ub79c\uce58\uc5d0 \ud604\uc7ac \ud328\uce58\uc14b\uc744 \uac00\uc838\uc624\uace0 \uccb4\ud06c\uc544\uc6c3\ud558\uc138\uc694. -gb.ptMerge = \ub85c\uceec \ube0c\ub79c\uce58\uc5d0 \ud604\uc7ac \ud328\uce58\uc14b\uc744 \uac00\uc838\uc624\uace0 \uba38\uc9c0\ud558\uc138\uc694. -gb.ptDescription1 = Barnum \uc740 Gitblit \ud2f0\ucf13\uacfc \ud328\uce58\uc14b\uc744 \uc0ac\uc6a9\ud558\uae30 \uc704\ud55c \uad6c\ubb38\uc744 \ub2e8\uc21c\ud558\uac8c \ud574\uc904 Git \ucee4\ub9e8\ub4dc \ub77c\uc778 \ub3c4\uad6c \uc785\ub2c8\ub2e4. -gb.ptSimplifiedCollaboration = \ub2e8\uc21c\ud654\ub41c \uacf5\ub3d9\uc791\uc5c5 \uad6c\ubb38 -gb.ptSimplifiedMerge = \ub2e8\uc21c\ud654\ub41c \uba38\uc9c0 \uad6c\ubb38 -gb.ptDescription2 = Barnum \uc740 \ud30c\uc774\uc36c 3 \uc640 \ub124\uc774\ud2f0\ube0c Git \uc774 \ud544\uc694\ud569\ub2c8\ub2e4. Windows, Linux, and Mac OS X \uc5d0\uc11c \ub3d9\uc791\ud569\ub2c8\ub2e4. -gb.stepN = {0} \ub2e8\uacc4 -gb.watchers = \uc9c0\ucf1c\ubcf4\ub294 \uc774 -gb.votes = \ud22c\ud45c -gb.vote = {0} \uc5d0 \ud22c\ud45c\ud558\uae30 -gb.watch = {0} \uc9c0\ucf1c\ubcf4\uae30 -gb.removeVote = \ud22c\ud45c \uc81c\uac70 -gb.stopWatching = \uc9c0\ucf1c\ubcf4\uae30 \uc911\uc9c0 -gb.watching = \uc9c0\ucf1c\ubcf4\ub294 \uc911 -gb.comments = \ucf54\uba58\ud2b8 -gb.addComment = \ucf54\uba58\ud2b8 \ucd94\uac00 -gb.export = \ub0b4\ubcf4\ub0b4\uae30 -gb.oneCommit = 1\uac1c \ucee4\ubc0b -gb.nCommits = {0}\uac1c \ucee4\ubc0b -gb.addedOneCommit = 1\uac1c \ucee4\ubc0b \ucd94\uac00 -gb.addedNCommits = {0}\uac1c \ucee4\ubc0b \ucd94\uac00 -gb.commitsInPatchsetN = {0} \ud328\uce58\uc14b\uc758 \ucee4\ubc0b -gb.patchsetN = \ud328\uce58\uc14b {0} -gb.reviewedPatchsetRev = \ud328\uce58\uc14b {0} \ub9ac\ube44\uc804 {1}: {2} \ub9ac\ubdf0 -gb.review = \ub9ac\ubdf0 -gb.reviews = \ub9ac\ubdf0 -gb.veto = \uac70\ubd80 -gb.needsImprovement = \uac1c\uc120 \ud544\uc694 -gb.looksGood = \uc88b\uc544 \ubcf4\uc784 -gb.approve = \uc2b9\uc778 -gb.hasNotReviewed = \ub9ac\ubdf0\ub418\uc9c0 \uc54a\uc74c +gb.queries = \uCFFC\uB9AC +gb.searchTicketsTooltip = {0} \uD2F0\uCF13 \uAC80\uC0C9 +gb.searchTickets = \uD2F0\uCF13 \uAC80\uC0C9 +gb.new = \uC0C8 +gb.newTicket = \uC0C8 \uD2F0\uCF13 +gb.editTicket = \uD2F0\uCF13 \uC218\uC815 +gb.ticketsWelcome = \uD560\uC77C \uBAA9\uB85D, \uBC84\uADF8 \uD1A0\uB860\uC744 \uC815\uB9AC\uD558\uACE0 \uD328\uCE58\uC14B\uC73C\uB85C \uD611\uC5C5\uD558\uAE30 \uC704\uD574 \uD2F0\uCF13\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.createFirstTicket = \uCCAB\uBC88\uC9F8 \uD2F0\uCF13\uC744 \uB9CC\uB4DC\uC138\uC694. +gb.title = \uC81C\uBAA9 +gb.changedStatus = \uC0C1\uD0DC \uBCC0\uACBD\uB428 +gb.discussion = \uD1A0\uB860 +gb.updated = \uC5C5\uB370\uC774\uD2B8\uB428 +gb.proposePatchset = \uD328\uCE58\uC14B\uC758 \uC81C\uC548 +gb.proposePatchsetNote = \uC774 \uD2F0\uCF13\uC5D0 \uB300\uD55C \uD328\uCE58\uC14B \uC81C\uC548\uC744 \uD658\uC601\uD569\uB2C8\uB2E4. +gb.proposeInstructions = \uB9E8 \uBA3C\uC800, \uD328\uCE58\uC14B\uC744 \uB9CC\uB4E4\uACE0 Git\uC73C\uB85C \uC5C5\uB85C\uB4DC \uD558\uC138\uC694. Gitblit \uC774 id \uB85C \uC774 \uD2F0\uCF13\uACFC \uC5F0\uACB0\uD560 \uAC83\uC785\uB2C8\uB2E4. +gb.proposeWith = \uC774\uAC83\uC73C\uB85C \uD328\uCE58\uC14B \uC81C\uC548 - {0} +gb.revisionHistory = \uB9AC\uBE44\uC804 \uD788\uC2A4\uD1A0\uB9AC +gb.merge = \uBA38\uC9C0 +gb.action = \uC561\uC158 +gb.patchset = \uD328\uCE58\uC14B +gb.all = \uBAA8\uB450 +gb.mergeBase = \uBA38\uC9C0 \uBCA0\uC774\uC2A4 +gb.checkout = \uCCB4\uD06C\uC544\uC6C3 +gb.checkoutViaCommandLine = \uCEE4\uB9E8\uB4DC \uB77C\uC778\uC73C\uB85C \uCCB4\uD06C\uC544\uC6C3 +gb.checkoutViaCommandLineNote = \uC774 \uC800\uC7A5\uC18C\uC758 \uD074\uB860\uC5D0\uC11C \uB85C\uCEEC \uBCC0\uACBD\uC0AC\uD56D\uC744 \uCCB4\uD06C\uC544\uC6C3\uD558\uACE0 \uD14C\uC2A4\uD2B8\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.checkoutStep1 = \uD604\uC7AC \uD328\uCE58\uC14B\uC744 \uAC00\uC838\uC624\uC138\uC694. - \uD504\uB85C\uC81D\uD2B8 \uB514\uB809\uD1A0\uB9AC\uC5D0\uC11C \uC774 \uC791\uC5C5\uC744 \uC2E4\uD589\uD558\uC138\uC694. +gb.checkoutStep2 = \uC0C8 \uBE0C\uB79C\uCE58\uC640 \uB9AC\uBDF0\uC5D0 \uD328\uCE58\uC14B\uC744 \uCCB4\uD06C\uC544\uC6C3 \uD558\uC138\uC694. +gb.mergingViaCommandLine = \uCEE4\uB9E8\uB4DC \uB77C\uC778\uC5D0\uC11C \uBA38\uC9D5 +gb.mergingViaCommandLineNote = \uBA38\uC9C0 \uBC84\uD2BC \uC0AC\uC6A9\uC744 \uC6D0\uD558\uC9C0 \uC54A\uAC70\uB098 \uC790\uB3D9 \uBA38\uC9C0\uAC00 \uB3D9\uC791\uD558\uC9C0 \uB418\uC9C0 \uC54A\uB294\uB2E4\uBA74, \uCEE4\uB9E8\uB4DC \uB77C\uC778\uC5D0\uC11C \uC218\uB3D9\uC73C\uB85C \uBA38\uC9C0\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.mergeStep1 = \uBCC0\uACBD\uC0AC\uD56D\uC744 \uB9AC\uBDF0\uD558\uAE30\uC704\uD574 \uC0C8 \uBE0C\uB79C\uCE58\uB97C \uCCB4\uD06C\uC544\uC6C3 \uD558\uC138\uC694. \u2014 \uD504\uB85C\uC81D\uD2B8 \uB514\uB809\uD1A0\uB9AC\uC5D0\uC11C \uC774 \uC791\uC5C5\uC744 \uC2E4\uD589\uD558\uC138\uC694. +gb.mergeStep2 = \uC81C\uC548\uB41C \uBCC0\uACBD\uC0AC\uD56D\uACFC \uB9AC\uBDF0\uB97C \uAC00\uC838\uC624\uC138\uC694. +gb.mergeStep3 = \uC81C\uC548\uB41C \uBCC0\uACBD\uC0AC\uD56D\uC744 \uBA38\uC9C0\uD558\uACE0 \uC11C\uBC84\uC5D0 \uC5C5\uB370\uC774\uD2B8 \uD558\uC138\uC694. +gb.download = \uB2E4\uC6B4\uB85C\uB4DC +gb.ptDescription = Gitblit \uD328\uCE58\uC14B \uB3C4\uAD6C +gb.ptCheckout = \uB9AC\uBDF0 \uBE0C\uB79C\uCE58\uC5D0 \uD604\uC7AC \uD328\uCE58\uC14B\uC744 \uAC00\uC838\uC624\uACE0 \uCCB4\uD06C\uC544\uC6C3\uD558\uC138\uC694. +gb.ptMerge = \uB85C\uCEEC \uBE0C\uB79C\uCE58\uC5D0 \uD604\uC7AC \uD328\uCE58\uC14B\uC744 \uAC00\uC838\uC624\uACE0 \uBA38\uC9C0\uD558\uC138\uC694. +gb.ptDescription1 = Barnum \uC740 Gitblit \uD2F0\uCF13\uACFC \uD328\uCE58\uC14B\uC744 \uC0AC\uC6A9\uD558\uAE30 \uC704\uD55C \uAD6C\uBB38\uC744 \uB2E8\uC21C\uD558\uAC8C \uD574\uC904 Git \uCEE4\uB9E8\uB4DC \uB77C\uC778 \uB3C4\uAD6C \uC785\uB2C8\uB2E4. +gb.ptSimplifiedCollaboration = \uB2E8\uC21C\uD654\uB41C \uACF5\uB3D9\uC791\uC5C5 \uAD6C\uBB38 +gb.ptSimplifiedMerge = \uB2E8\uC21C\uD654\uB41C \uBA38\uC9C0 \uAD6C\uBB38 +gb.ptDescription2 = Barnum \uC740 \uD30C\uC774\uC36C 3 \uC640 \uB124\uC774\uD2F0\uBE0C Git \uC774 \uD544\uC694\uD569\uB2C8\uB2E4. Windows, Linux, and Mac OS X \uC5D0\uC11C \uB3D9\uC791\uD569\uB2C8\uB2E4. +gb.stepN = {0} \uB2E8\uACC4 +gb.watchers = \uC9C0\uCF1C\uBCF4\uB294 \uC774 +gb.votes = \uD22C\uD45C +gb.vote = {0} \uC5D0 \uD22C\uD45C\uD558\uAE30 +gb.watch = {0} \uC9C0\uCF1C\uBCF4\uAE30 +gb.removeVote = \uD22C\uD45C \uC81C\uAC70 +gb.stopWatching = \uC9C0\uCF1C\uBCF4\uAE30 \uC911\uC9C0 +gb.watching = \uC9C0\uCF1C\uBCF4\uB294 \uC911 +gb.comments = \uCF54\uBA58\uD2B8 +gb.addComment = \uCF54\uBA58\uD2B8 \uCD94\uAC00 +gb.export = \uB0B4\uBCF4\uB0B4\uAE30 +gb.oneCommit = 1\uAC1C \uCEE4\uBC0B +gb.nCommits = {0}\uAC1C \uCEE4\uBC0B +gb.addedOneCommit = 1\uAC1C \uCEE4\uBC0B \uCD94\uAC00 +gb.addedNCommits = {0}\uAC1C \uCEE4\uBC0B \uCD94\uAC00 +gb.commitsInPatchsetN = {0} \uD328\uCE58\uC14B\uC758 \uCEE4\uBC0B +gb.patchsetN = \uD328\uCE58\uC14B {0} +gb.reviewedPatchsetRev = \uD328\uCE58\uC14B {0} \uB9AC\uBE44\uC804 {1}: {2} \uB9AC\uBDF0 +gb.review = \uB9AC\uBDF0 +gb.reviews = \uB9AC\uBDF0 +gb.veto = \uAC70\uBD80 +gb.needsImprovement = \uAC1C\uC120 \uD544\uC694 +gb.looksGood = \uC88B\uC544 \uBCF4\uC784 +gb.approve = \uC2B9\uC778 +gb.hasNotReviewed = \uB9AC\uBDF0\uB418\uC9C0 \uC54A\uC74C gb.about = about -gb.ticketN = \ud2f0\ucf13 #{0} -gb.disableUser = \uc0ac\uc6a9\uc790 \ube44\ud65c\uc131\ud654 -gb.disableUserDescription = \uc778\uc99d\uc5d0\uc11c \uc774 \uacc4\uc815 \ucc28\ub2e8 -gb.any = \ubaa8\ub450 -gb.milestoneProgress = {0}\uac1c \uc5f4\ub9bc, {1}\uac1c \ub2eb\ud798 -gb.nOpenTickets = {0}\uac1c \uc5f4\ub9bc -gb.nClosedTickets = {0}\uac1c \ub2eb\ud798 -gb.nTotalTickets = \ubaa8\ub450 {0}\uac1c -gb.body = \ub0b4\uc6a9 +gb.ticketN = \uD2F0\uCF13 #{0} +gb.disableUser = \uC0AC\uC6A9\uC790 \uBE44\uD65C\uC131\uD654 +gb.disableUserDescription = \uC778\uC99D\uC5D0\uC11C \uC774 \uACC4\uC815 \uCC28\uB2E8 +gb.any = \uBAA8\uB450 +gb.milestoneProgress = {0}\uAC1C \uC5F4\uB9BC, {1}\uAC1C \uB2EB\uD798 +gb.nOpenTickets = {0}\uAC1C \uC5F4\uB9BC +gb.nClosedTickets = {0}\uAC1C \uB2EB\uD798 +gb.nTotalTickets = \uBAA8\uB450 {0}\uAC1C +gb.body = \uB0B4\uC6A9 gb.mergeSha = mergeSha -gb.mergeTo = \uba38\uc9c0\ub300\uc0c1 -gb.labels = \ub77c\ubca8 -gb.reviewers = \uac80\ud1a0\uc790 -gb.voters = \ud22c\ud45c\uc790 -gb.mentions = \ub9e8\uc158 -gb.canNotProposePatchset = \ud328\uce58\uc14b\uc744 \uc81c\uc548\ud560 \uc218 \uc5c6\uc74c -gb.repositoryIsMirror = \uc774 \uc800\uc7a5\uc18c\ub294 \uc77d\uae30\uc804\uc6a9 \ubbf8\ub7ec\uc785\ub2c8\ub2e4. -gb.repositoryIsFrozen = \uc774 \uc800\uc7a5\uc18c\ub294 \ud504\ub85c\uc98c \uc0c1\ud0dc\uc785\ub2c8\ub2e4. -gb.repositoryDoesNotAcceptPatchsets = \uc774 \uc800\uc7a5\uc18c\ub294 \ud328\uce58\uc14b\uc744 \uc218\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. -gb.serverDoesNotAcceptPatchsets = \uc774 \uc11c\ubc84\ub294 \ud328\uce58\uc14b\uc744 \uc218\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. -gb.ticketIsClosed = \uc774 \ud2f0\ucf13\uc740 \ub2eb\ud600 \uc788\uc2b5\ub2c8\ub2e4. -gb.mergeToDescription = \ud2f0\ucf13 \ud328\uce58\uc14b\uc744 \uba38\uc9c0\ud560 \uae30\ubcf8 \ud1b5\ud569 \ube0c\ub79c\uce58 -gb.anonymousCanNotPropose = \uc775\uba85 \uc0ac\uc6a9\uc790\ub294 \ud328\uce58\uc14b\uc744 \uc81c\uc548\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.youDoNotHaveClonePermission = \ub2f9\uc2e0\uc740 \uc774 \uc800\uc7a5\uc18c\ub97c \ud074\ub860\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.myTickets = \ub0b4 \ud2f0\ucf13 -gb.yourAssignedTickets = \ub098\uc5d0\uac8c \ud560\ub2f9\ub41c -gb.newMilestone = \uc0c8 \ub9c8\uc77c\uc2a4\ud1a4 -gb.editMilestone = \ub9c8\uc77c\uc2a4\ud1a4 \uc218\uc815 -gb.deleteMilestone = \ub9c8\uc77c\uc2a4\ud1a4 \"{0}\"\uc744(\ub97c) \uc0ad\uc81c\ud560\uae4c\uc694? -gb.milestoneDeleteFailed = \ub9c8\uc77c\uc2a4\ud1a4 ''{0}'' \uc0ad\uc81c \uc2e4\ud328! -gb.notifyChangedOpenTickets = \uc5f0 \ud2f0\ucf13\uc758 \ubcc0\uacbd \uc54c\ub9bc \uc804\uc1a1 -gb.overdue = \uc9c0\uc5f0 -gb.openMilestones = \ub9c8\uc77c\uc2a4\ud1a4 \uc5f4\uae30 -gb.closedMilestones = \ub2eb\ud78c \ub9c8\uc77c\uc2a4\ud1a4 -gb.administration = \uad00\ub9ac -gb.plugins = \ud50c\ub7ec\uadf8\uc778 -gb.extensions = \ud655\uc7a5\uae30\ub2a5 -gb.pleaseSelectProject = \ud504\ub85c\uc81d\ud2b8\ub97c \uc120\ud0dd\ud574 \uc8fc\uc138\uc694! -gb.accessPolicy = \uc811\uadfc \uc815\ucc45 -gb.accessPolicyDescription = \uc800\uc7a5\uc18c \ubcf4\uae30\uc640 git \uad8c\ud55c\uc744 \uc81c\uc5b4\ud558\uae30 \uc704\ud574 \uc811\uadfc \uc815\ucc45\uc744 \uc120\ud0dd\ud558\uc138\uc694. -gb.anonymousPolicy = \uc775\uba85 \ubcf4\uae30, \ud074\ub860 \uadf8\ub9ac\uace0 \ud478\uc2dc -gb.anonymousPolicyDescription = \ub204\uad6c\ub098 \uc774 \uc800\uc7a5\uc18c\ub97c \ubcf4\uae30, \ud074\ub860, \uadf8\ub9ac\uace0 \ud478\uc2dc\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.authenticatedPushPolicy = \uc81c\ud55c\ub41c \ud478\uc2dc (\uc778\uc99d\ub41c) -gb.authenticatedPushPolicyDescription = \ub204\uad6c\ub098 \uc774 \uc800\uc7a5\uc18c\ub97c \ubcf4\uac70\ub098 \ud074\ub860\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubaa8\ub4e0 \uc778\uc99d\ub41c \uc720\uc800\ub294 RW+ \ud478\uc2dc \uad8c\ud55c\uc744 \uac00\uc9d1\ub2c8\ub2e4. -gb.namedPushPolicy = \uc774\ub984\uc73c\ub85c \ud478\uc2dc \uc81c\ud55c -gb.namedPushPolicyDescription = \ub204\uad6c\ub098 \uc774 \uc800\uc7a5\uc18c\ub97c \ubcf4\uac70\ub098 \ud074\ub860\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc120\ud0dd\ud55c \uc720\uc800\ub9cc \ud478\uc2dc\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.clonePolicy = \uc81c\ud55c\ub41c \ud074\ub860 & \ud478\uc2dc -gb.clonePolicyDescription = \ub204\uad6c\ub098 \uc774 \uc800\uc7a5\uc18c\ub97c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc120\ud0dd\ud55c \uc720\uc800\ub9cc \ud074\ub860\uacfc \ud478\uc2dc\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.viewPolicy = \uc81c\ud55c\ub41c \ubcf4\uae30, \ud074\ub860 & \ud478\uc2dc -gb.viewPolicyDescription = \uc120\ud0dd\ud55c \uc720\uc800\ub9cc \uc774 \uc800\uc7a5\uc18c\uc5d0 \ub300\ud574 \ubcf4\uae30, \ud074\ub860 \uadf8\ub9ac\uace0 \ud478\uc2dc \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.initialCommit = \ucd5c\ucd08 \ucee4\ubc0b -gb.initialCommitDescription = \uc774 \uc800\uc7a5\uc18c\ub97c \uc989\uc2dc git clone \ud560 \uc218 \uc788\ub3c4\ub85d \ud569\ub2c8\ub2e4. \ub85c\uceec\uc5d0\uc11c git init \ud588\ub2e4\uba74 \uc774 \ub2e8\uacc4\ub97c \uac74\ub108\ub6f0\uc138\uc694. -gb.initWithReadme = README \ud3ec\ud568 -gb.initWithReadmeDescription = \uc800\uc7a5\uc18c\uc758 \uac04\ub2e8\ud55c README \ubb38\uc11c\ub97c \uc0dd\uc131\ud569\ub2c8\ub2e4. -gb.initWithGitignore = .gitignore \ud30c\uc77c \ud3ec\ud568 -gb.initWithGitignoreDescription = Git \ud074\ub77c\uc774\uc5b8\ud2b8\uac00 \uc815\uc758\ub41c \ud328\ud134\uc5d0 \ub530\ub77c \ud30c\uc77c\uc774\ub098 \ub514\ub809\ud1a0\ub9ac\ub97c \ubb34\uc2dc\ud558\ub3c4\ub85d \uc9c0\uc815\ud55c \uc124\uc815\ud30c\uc77c\uc744 \ucd94\uac00\ud569\ub2c8\ub2e4. -gb.pleaseSelectGitIgnore = .gitignore \ud30c\uc77c\uc744 \uc120\ud0dd\ud558\uc138\uc694. -gb.receive = \uc218\uc2e0 -gb.permissions = \uad8c\ud55c -gb.ownersDescription = \uc18c\uc720\uc790\ub294 \uc800\uc7a5\uc18c\uc758 \ubaa8\ub4e0 \uc124\uc815\uc744 \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\ub098, \uac1c\uc778 \uc800\uc7a5\uc18c\ub97c \uc81c\uc678\ud558\uace0\ub294 \uc800\uc7a5\uc18c \uc774\ub984\uc744 \ubcc0\uacbd\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. -gb.userPermissionsDescription = \uac1c\ubcc4 \uc0ac\uc6a9\uc790 \uad8c\ud55c\uc744 \uc9c0\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774 \uc124\uc815\uc740 \ud300\uc774\ub098 \uc815\uaddc\uc2dd \uad8c\ud55c\uc744 \ubb34\uc2dc\ud569\ub2c8\ub2e4. -gb.teamPermissionsDescription = \uac1c\ubcc4 \ud300 \uad8c\ud55c\uc744 \uc9c0\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774 \uc124\uc815\uc740 \uc815\uaddc\uc2dd \uad8c\ud55c\uc744 \ubb34\uc2dc\ud569\ub2c8\ub2e4. -gb.ticketSettings = \ud2f0\ucf13 \uc124\uc815 -gb.receiveSettings = \uc218\uc2e0 \uc124\uc815 -gb.receiveSettingsDescription = \uc218\uc2e0 \uc124\uc815\uc740 \uc800\uc7a5\uc18c\uc5d0 \ud478\uc2dc\ud558\ub294 \uac83\uc744 \uc81c\uc5b4\ud569\ub2c8\ub2e4. -gb.preReceiveDescription = Pre-receive \ud6c5\uc740 \ucee4\ubc0b\uc744 \uc218\uc2e0\ud588\uc9c0\ub9cc, refs \uac00 \uc5c5\ub370\uc774\ud2b8 \ub418\uae30 \uc804 \uc5d0 \uc2e4\ud589\ub429\ub2c8\ub2e4.

\uc774\uac83\uc740 \ud478\uc2dc\ub97c \uac70\ubd80\ud558\uae30\uc5d0 \uc801\uc808\ud55c \ud6c5 \uc785\ub2c8\ub2e4.

-gb.postReceiveDescription = Post-receive \ud639\uc740 \ucee4\ubc0b\uc744 \uc218\uc2e0\ud558\uace0, refs \uac00 \uc5c5\ub370\uc774\ud2b8 \ub41c \ud6c4 \uc5d0 \uc2e4\ud589\ub429\ub2c8\ub2e4.

\uc774\uac83\uc740 \uc54c\ub9bc, \ube4c\ub4dc \ud2b8\ub9ac\uac70 \ub4f1\uc744 \ud558\uae30\uc5d0 \uc801\uc808\ud55c \ud6c5 \uc785\ub2c8\ub2e4.

-gb.federationStrategyDescription = \ub2e4\ub978 Gitblit \uacfc \ud398\ub354\ub808\uc774\uc158 \ud558\ub294 \ubc29\ubc95\uc744 \uc81c\uc5b4\ud569\ub2c8\ub2e4. -gb.federationSetsDescription = \uc774 \uc800\uc7a5\uc18c\ub294 \uc120\ud0dd\ub41c \ud398\ub354\ub808\uc774\uc158 \uc14b\uc5d0 \ud3ec\ud568\ub429\ub2c8\ub2e4. -gb.miscellaneous = \uae30\ud0c0 -gb.originDescription = \uc774 \uc800\uc7a5\uc18c\uac00 \ud074\ub860\ub41c \uacf3\uc758 url +gb.mergeTo = \uBA38\uC9C0\uB300\uC0C1 +gb.mergeType = \uBA38\uC9C0\uD0C0\uC785 +gb.labels = \uB77C\uBCA8 +gb.reviewers = \uAC80\uD1A0\uC790 +gb.voters = \uD22C\uD45C\uC790 +gb.mentions = \uB9E8\uC158 +gb.canNotProposePatchset = \uD328\uCE58\uC14B\uC744 \uC81C\uC548\uD560 \uC218 \uC5C6\uC74C +gb.repositoryIsMirror = \uC774 \uC800\uC7A5\uC18C\uB294 \uC77D\uAE30\uC804\uC6A9 \uBBF8\uB7EC\uC785\uB2C8\uB2E4. +gb.repositoryIsFrozen = \uC774 \uC800\uC7A5\uC18C\uB294 \uD504\uB85C\uC98C \uC0C1\uD0DC\uC785\uB2C8\uB2E4. +gb.repositoryDoesNotAcceptPatchsets = \uC774 \uC800\uC7A5\uC18C\uB294 \uD328\uCE58\uC14B\uC744 \uC218\uC6A9\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. +gb.serverDoesNotAcceptPatchsets = \uC774 \uC11C\uBC84\uB294 \uD328\uCE58\uC14B\uC744 \uC218\uC6A9\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. +gb.ticketIsClosed = \uC774 \uD2F0\uCF13\uC740 \uB2EB\uD600 \uC788\uC2B5\uB2C8\uB2E4. +gb.mergeToDescription = \uD2F0\uCF13 \uD328\uCE58\uC14B\uC744 \uBA38\uC9C0\uD560 \uAE30\uBCF8 \uD1B5\uD569 \uBE0C\uB79C\uCE58 +gb.mergeTypeDescription = fast-forward \uB9CC \uBA38\uC9C0, \uD544\uC694\uD558\uBA74, \uB610\uB294 \uD56D\uC0C1 \uD1B5\uD569\uBE0C\uB79C\uCE58\uC5D0 \uCEE4\uBC0B \uBA38\uC9C0 +gb.anonymousCanNotPropose = \uC775\uBA85 \uC0AC\uC6A9\uC790\uB294 \uD328\uCE58\uC14B\uC744 \uC81C\uC548\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.youDoNotHaveClonePermission = \uB2F9\uC2E0\uC740 \uC774 \uC800\uC7A5\uC18C\uB97C \uD074\uB860\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.myTickets = \uB0B4 \uD2F0\uCF13 +gb.yourAssignedTickets = \uB098\uC5D0\uAC8C \uD560\uB2F9\uB41C +gb.newMilestone = \uC0C8 \uB9C8\uC77C\uC2A4\uD1A4 +gb.editMilestone = \uB9C8\uC77C\uC2A4\uD1A4 \uC218\uC815 +gb.deleteMilestone = \uB9C8\uC77C\uC2A4\uD1A4 \"{0}\"\uC744(\uB97C) \uC0AD\uC81C\uD560\uAE4C\uC694? +gb.milestoneDeleteFailed = \uB9C8\uC77C\uC2A4\uD1A4 ''{0}'' \uC0AD\uC81C \uC2E4\uD328! +gb.notifyChangedOpenTickets = \uC5F0 \uD2F0\uCF13\uC758 \uBCC0\uACBD \uC54C\uB9BC \uC804\uC1A1 +gb.overdue = \uC9C0\uC5F0 +gb.openMilestones = \uB9C8\uC77C\uC2A4\uD1A4 \uC5F4\uAE30 +gb.closedMilestones = \uB2EB\uD78C \uB9C8\uC77C\uC2A4\uD1A4 +gb.administration = \uAD00\uB9AC +gb.plugins = \uD50C\uB7EC\uADF8\uC778 +gb.extensions = \uD655\uC7A5\uAE30\uB2A5 +gb.pleaseSelectProject = \uD504\uB85C\uC81D\uD2B8\uB97C \uC120\uD0DD\uD574 \uC8FC\uC138\uC694! +gb.accessPolicy = \uC811\uADFC \uC815\uCC45 +gb.accessPolicyDescription = \uC800\uC7A5\uC18C \uBCF4\uAE30\uC640 git \uAD8C\uD55C\uC744 \uC81C\uC5B4\uD558\uAE30 \uC704\uD574 \uC811\uADFC \uC815\uCC45\uC744 \uC120\uD0DD\uD558\uC138\uC694. +gb.anonymousPolicy = \uC775\uBA85 \uBCF4\uAE30, \uD074\uB860 \uADF8\uB9AC\uACE0 \uD478\uC2DC +gb.anonymousPolicyDescription = \uB204\uAD6C\uB098 \uC774 \uC800\uC7A5\uC18C\uB97C \uBCF4\uAE30, \uD074\uB860, \uADF8\uB9AC\uACE0 \uD478\uC2DC\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.authenticatedPushPolicy = \uC81C\uD55C\uB41C \uD478\uC2DC (\uC778\uC99D\uB41C) +gb.authenticatedPushPolicyDescription = \uB204\uAD6C\uB098 \uC774 \uC800\uC7A5\uC18C\uB97C \uBCF4\uAC70\uB098 \uD074\uB860\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uBAA8\uB4E0 \uC778\uC99D\uB41C \uC720\uC800\uB294 RW+ \uD478\uC2DC \uAD8C\uD55C\uC744 \uAC00\uC9D1\uB2C8\uB2E4. +gb.namedPushPolicy = \uC774\uB984\uC73C\uB85C \uD478\uC2DC \uC81C\uD55C +gb.namedPushPolicyDescription = \uB204\uAD6C\uB098 \uC774 \uC800\uC7A5\uC18C\uB97C \uBCF4\uAC70\uB098 \uD074\uB860\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uC120\uD0DD\uD55C \uC720\uC800\uB9CC \uD478\uC2DC\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.clonePolicy = \uC81C\uD55C\uB41C \uD074\uB860 & \uD478\uC2DC +gb.clonePolicyDescription = \uB204\uAD6C\uB098 \uC774 \uC800\uC7A5\uC18C\uB97C \uBCFC \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uC120\uD0DD\uD55C \uC720\uC800\uB9CC \uD074\uB860\uACFC \uD478\uC2DC\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.viewPolicy = \uC81C\uD55C\uB41C \uBCF4\uAE30, \uD074\uB860 & \uD478\uC2DC +gb.viewPolicyDescription = \uC120\uD0DD\uD55C \uC720\uC800\uB9CC \uC774 \uC800\uC7A5\uC18C\uC5D0 \uB300\uD574 \uBCF4\uAE30, \uD074\uB860 \uADF8\uB9AC\uACE0 \uD478\uC2DC \uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.initialCommit = \uCD5C\uCD08 \uCEE4\uBC0B +gb.initialCommitDescription = \uC774 \uC800\uC7A5\uC18C\uB97C \uC989\uC2DC git clone \uD560 \uC218 \uC788\uB3C4\uB85D \uD569\uB2C8\uB2E4. \uB85C\uCEEC\uC5D0\uC11C git init \uD588\uB2E4\uBA74 \uC774 \uB2E8\uACC4\uB97C \uAC74\uB108\uB6F0\uC138\uC694. +gb.initWithReadme = README \uD3EC\uD568 +gb.initWithReadmeDescription = \uC800\uC7A5\uC18C\uC758 \uAC04\uB2E8\uD55C README \uBB38\uC11C\uB97C \uC0DD\uC131\uD569\uB2C8\uB2E4. +gb.initWithGitignore = .gitignore \uD30C\uC77C \uD3EC\uD568 +gb.initWithGitignoreDescription = Git \uD074\uB77C\uC774\uC5B8\uD2B8\uAC00 \uC815\uC758\uB41C \uD328\uD134\uC5D0 \uB530\uB77C \uD30C\uC77C\uC774\uB098 \uB514\uB809\uD1A0\uB9AC\uB97C \uBB34\uC2DC\uD558\uB3C4\uB85D \uC9C0\uC815\uD55C \uC124\uC815\uD30C\uC77C\uC744 \uCD94\uAC00\uD569\uB2C8\uB2E4. +gb.pleaseSelectGitIgnore = .gitignore \uD30C\uC77C\uC744 \uC120\uD0DD\uD558\uC138\uC694. +gb.receive = \uC218\uC2E0 +gb.permissions = \uAD8C\uD55C +gb.ownersDescription = \uC18C\uC720\uC790\uB294 \uC800\uC7A5\uC18C\uC758 \uBAA8\uB4E0 \uC124\uC815\uC744 \uAD00\uB9AC\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uADF8\uB7EC\uB098, \uAC1C\uC778 \uC800\uC7A5\uC18C\uB97C \uC81C\uC678\uD558\uACE0\uB294 \uC800\uC7A5\uC18C \uC774\uB984\uC744 \uBCC0\uACBD\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.userPermissionsDescription = \uAC1C\uBCC4 \uC0AC\uC6A9\uC790 \uAD8C\uD55C\uC744 \uC9C0\uC815\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uC774 \uC124\uC815\uC740 \uD300\uC774\uB098 \uC815\uADDC\uC2DD \uAD8C\uD55C\uC744 \uBB34\uC2DC\uD569\uB2C8\uB2E4. +gb.teamPermissionsDescription = \uAC1C\uBCC4 \uD300 \uAD8C\uD55C\uC744 \uC9C0\uC815\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uC774 \uC124\uC815\uC740 \uC815\uADDC\uC2DD \uAD8C\uD55C\uC744 \uBB34\uC2DC\uD569\uB2C8\uB2E4. +gb.ticketSettings = \uD2F0\uCF13 \uC124\uC815 +gb.receiveSettings = \uC218\uC2E0 \uC124\uC815 +gb.receiveSettingsDescription = \uC218\uC2E0 \uC124\uC815\uC740 \uC800\uC7A5\uC18C\uC5D0 \uD478\uC2DC\uD558\uB294 \uAC83\uC744 \uC81C\uC5B4\uD569\uB2C8\uB2E4. +gb.preReceiveDescription = Pre-receive \uD6C5\uC740 \uCEE4\uBC0B\uC744 \uC218\uC2E0\uD588\uC9C0\uB9CC, refs \uAC00 \uC5C5\uB370\uC774\uD2B8 \uB418\uAE30 \uC804 \uC5D0 \uC2E4\uD589\uB429\uB2C8\uB2E4.

\uC774\uAC83\uC740 \uD478\uC2DC\uB97C \uAC70\uBD80\uD558\uAE30\uC5D0 \uC801\uC808\uD55C \uD6C5 \uC785\uB2C8\uB2E4.

+gb.postReceiveDescription = Post-receive \uD639\uC740 \uCEE4\uBC0B\uC744 \uC218\uC2E0\uD558\uACE0, refs \uAC00 \uC5C5\uB370\uC774\uD2B8 \uB41C \uD6C4 \uC5D0 \uC2E4\uD589\uB429\uB2C8\uB2E4.

\uC774\uAC83\uC740 \uC54C\uB9BC, \uBE4C\uB4DC \uD2B8\uB9AC\uAC70 \uB4F1\uC744 \uD558\uAE30\uC5D0 \uC801\uC808\uD55C \uD6C5 \uC785\uB2C8\uB2E4.

+gb.federationStrategyDescription = \uB2E4\uB978 Gitblit \uACFC \uD398\uB354\uB808\uC774\uC158 \uD558\uB294 \uBC29\uBC95\uC744 \uC81C\uC5B4\uD569\uB2C8\uB2E4. +gb.federationSetsDescription = \uC774 \uC800\uC7A5\uC18C\uB294 \uC120\uD0DD\uB41C \uD398\uB354\uB808\uC774\uC158 \uC14B\uC5D0 \uD3EC\uD568\uB429\uB2C8\uB2E4. +gb.miscellaneous = \uAE30\uD0C0 +gb.originDescription = \uC774 \uC800\uC7A5\uC18C\uAC00 \uD074\uB860\uB41C \uACF3\uC758 url gb.gc = GC -gb.garbageCollection = \uac00\ube44\uc9c0 \uceec\ub809\uc158 -gb.garbageCollectionDescription = \uac00\ube44\uc9c0 \uceec\ub809\ud130\ub294 \ud074\ub77c\uc774\uc5b8\ud2b8\uc5d0\uc11c \ud478\uc2dc\ud55c \ub290\uc2a8\ud55c \uc624\ube0c\uc81d\ud2b8\ub97c \ud328\ud0b9\ud558\uace0, \uc800\uc7a5\uc18c\uc5d0\uc11c \ucc38\uc870\ud558\uc9c0 \uc54a\ub294 \uc624\ube0c\uc81d\ud2b8\ub97c \uc0ad\uc81c\ud569\ub2c8\ub2e4. -gb.commitMessageRendererDescription = \ucee4\ubc0b \uba54\uc2dc\uc9c0\ub294 \ud3c9\ubb38 \ub610\ub294 \ub9c8\ud06c\uc5c5\uc73c\ub85c \ub80c\ub354\ub9c1\ud558\uc5ec \ud45c\uc2dc\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4. -gb.preferences = \uc124\uc815 -gb.accountPreferences = \uacc4\uc815 \uc124\uc815 -gb.accountPreferencesDescription = \uacc4\uc815 \uc124\uc815\uc744 \uc9c0\uc815\ud569\ub2c8\ub2e4. -gb.languagePreference = \uc5b8\uc5b4 \uc124\uc815 -gb.languagePreferenceDescription = \uc120\ud638\ud558\ub294 \uc5b8\uc5b4\ub97c \uc120\ud0dd\ud558\uc138\uc694. -gb.emailMeOnMyTicketChanges = \ub0b4 \ud2f0\ucf13\uc774 \ubcc0\uacbd\ub418\uba74 \uc774\uba54\uc77c\ub85c \uc54c\ub9bc -gb.emailMeOnMyTicketChangesDescription = \ub0b4\uac00 \ub9cc\ub4e0 \ud2f0\ucf13\uc758 \ubcc0\uacbd\ub418\uba74 \ubcc0\uacbd\uc0ac\ud56d\uc744 \ub098\uc758 \uc774\uba54\uc77c\ub85c \uc54c\ub824\uc90c -gb.displayNameDescription = \ud45c\uc2dc\ub420 \uc774\ub984 -gb.emailAddressDescription = \uc54c\ub9bc\uc744 \ubc1b\uae30\uc704\ud55c \uc8fc \uc774\uba54\uc77c -gb.sshKeys = SSH \ud0a4 -gb.sshKeysDescription = SSH \uacf5\uac1c\ud0a4 \uc778\uc99d\uc740 \ud328\uc2a4\uc6cc\ub4dc \uc778\uc99d\uc744 \ub300\uccb4\ud558\ub294 \uc548\uc804\ud55c \ub300\uc548\uc785\ub2c8\ub2e4. -gb.addSshKey = SSH \ud0a4 \ucd94\uac00 -gb.key = \ud0a4 -gb.comment = \uc124\uba85 -gb.sshKeyCommentDescription = \uc0ac\uc6a9\uc790 \uc120\ud0dd\uc778 \uc124\uba85\uc744 \ucd94\uac00\ud558\uc138\uc694. \ube44\uc6cc \ub450\uba74 \ud0a4 \ub370\uc774\ud130\uc5d0\uc11c \ucd94\ucd9c\ud558\uc5ec \ucc44\uc6cc\uc9d1\ub2c8\ub2e4. -gb.permission = \uad8c\ud55c -gb.sshKeyPermissionDescription = SSH \ud0a4\uc758 \uc811\uc18d \uad8c\ud55c\uc744 \uc9c0\uc815\ud558\uc138\uc694. -gb.transportPreference = \uc804\uc1a1 \uc124\uc815 -gb.transportPreferenceDescription = \ud074\ub860\uc2dc \uc0ac\uc6a9\ud560 \uc124\uc815\uc744 \uc9c0\uc815\ud558\uc138\uc694. +gb.garbageCollection = \uAC00\uBE44\uC9C0 \uCEEC\uB809\uC158 +gb.garbageCollectionDescription = \uAC00\uBE44\uC9C0 \uCEEC\uB809\uD130\uB294 \uD074\uB77C\uC774\uC5B8\uD2B8\uC5D0\uC11C \uD478\uC2DC\uD55C \uB290\uC2A8\uD55C \uC624\uBE0C\uC81D\uD2B8\uB97C \uD328\uD0B9\uD558\uACE0, \uC800\uC7A5\uC18C\uC5D0\uC11C \uCC38\uC870\uD558\uC9C0 \uC54A\uB294 \uC624\uBE0C\uC81D\uD2B8\uB97C \uC0AD\uC81C\uD569\uB2C8\uB2E4. +gb.commitMessageRendererDescription = \uCEE4\uBC0B \uBA54\uC2DC\uC9C0\uB294 \uD3C9\uBB38 \uB610\uB294 \uB9C8\uD06C\uC5C5\uC73C\uB85C \uB80C\uB354\uB9C1\uD558\uC5EC \uD45C\uC2DC\uB420 \uC218 \uC788\uC2B5\uB2C8\uB2E4. +gb.preferences = \uC124\uC815 +gb.accountPreferences = \uACC4\uC815 \uC124\uC815 +gb.accountPreferencesDescription = \uACC4\uC815 \uC124\uC815\uC744 \uC9C0\uC815\uD569\uB2C8\uB2E4. +gb.languagePreference = \uC5B8\uC5B4 \uC124\uC815 +gb.languagePreferenceDescription = \uC120\uD638\uD558\uB294 \uC5B8\uC5B4\uB97C \uC120\uD0DD\uD558\uC138\uC694. +gb.emailMeOnMyTicketChanges = \uB0B4 \uD2F0\uCF13\uC774 \uBCC0\uACBD\uB418\uBA74 \uC774\uBA54\uC77C\uB85C \uC54C\uB9BC +gb.emailMeOnMyTicketChangesDescription = \uB0B4\uAC00 \uB9CC\uB4E0 \uD2F0\uCF13\uC758 \uBCC0\uACBD\uB418\uBA74 \uBCC0\uACBD\uC0AC\uD56D\uC744 \uB098\uC758 \uC774\uBA54\uC77C\uB85C \uC54C\uB824\uC90C +gb.displayNameDescription = \uD45C\uC2DC\uB420 \uC774\uB984 +gb.emailAddressDescription = \uC54C\uB9BC\uC744 \uBC1B\uAE30\uC704\uD55C \uC8FC \uC774\uBA54\uC77C +gb.sshKeys = SSH \uD0A4 +gb.sshKeysDescription = SSH \uACF5\uAC1C\uD0A4 \uC778\uC99D\uC740 \uD328\uC2A4\uC6CC\uB4DC \uC778\uC99D\uC744 \uB300\uCCB4\uD558\uB294 \uC548\uC804\uD55C \uB300\uC548\uC785\uB2C8\uB2E4. +gb.addSshKey = SSH \uD0A4 \uCD94\uAC00 +gb.key = \uD0A4 +gb.comment = \uC124\uBA85 +gb.sshKeyCommentDescription = \uC0AC\uC6A9\uC790 \uC120\uD0DD\uC778 \uC124\uBA85\uC744 \uCD94\uAC00\uD558\uC138\uC694. \uBE44\uC6CC \uB450\uBA74 \uD0A4 \uB370\uC774\uD130\uC5D0\uC11C \uCD94\uCD9C\uD558\uC5EC \uCC44\uC6CC\uC9D1\uB2C8\uB2E4. +gb.permission = \uAD8C\uD55C +gb.sshKeyPermissionDescription = SSH \uD0A4\uC758 \uC811\uC18D \uAD8C\uD55C\uC744 \uC9C0\uC815\uD558\uC138\uC694. +gb.transportPreference = \uC804\uC1A1 \uC124\uC815 +gb.transportPreferenceDescription = \uD074\uB860\uC2DC \uC0AC\uC6A9\uD560 \uC124\uC815\uC744 \uC9C0\uC815\uD558\uC138\uC694. +gb.priority = \uC6B0\uC120\uC21C\uC704 +gb.severity = \uC911\uC694\uB3C4 +gb.sortHighestPriority = \uCD5C\uACE0 \uC6B0\uC120\uC21C\uC704 +gb.sortLowestPriority = \uCD5C\uC800 \uC6B0\uC120\uC21C\uC704 +gb.sortHighestSeverity = \uCD5C\uACE0 \uC911\uC694\uB3C4 +gb.sortLowestSeverity = \uCD5C\uC800 \uC911\uC694\uB3C4 +gb.missingIntegrationBranchMore = \uD1B5\uD569\uB300\uC0C1 \uBE0C\uB79C\uCE58\uAC00 \uC800\uC7A5\uC18C\uC5D0 \uC5C6\uC5B4\uC694! +gb.diffDeletedFileSkipped = (\uC0AD\uC81C\uB428) +gb.diffFileDiffTooLarge = \uBE44\uAD50\uD558\uAE30\uC5D0 \uB108\uBB34 \uD07C +gb.diffNewFile = \uC0C8 \uD30C\uC77C +gb.diffDeletedFile = \uD30C\uC77C\uC774 \uC0AD\uC81C\uB428 +gb.diffRenamedFile = {0} \uC5D0\uC11C \uC774\uB984\uC774 \uBCC0\uACBD\uB428 +gb.diffCopiedFile = {0} \uC5D0\uC11C \uBCF5\uC0AC\uB428 +gb.diffTruncated = \uC704 \uD30C\uC77C\uC774\uD6C4 \uCC28\uC774 \uC81C\uAC70\uB428 +gb.opacityAdjust = \uBD88\uD22C\uBA85\uB3C4 \uC870\uC815 +gb.blinkComparator = \uC810\uBA78 \uBE44\uAD50\uAE30 +gb.imgdiffSubtract = Subtract (black = identical) +gb.deleteRepositoryHeader = \uC800\uC7A5\uC18C \uC0AD\uC81C +gb.deleteRepositoryDescription = \uC0AD\uC81C\uB41C \uC800\uC7A5\uC18C\uB294 \uBCF5\uAD6C\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.show_whitespace = \uACF5\uBC31 \uBCF4\uAE30 +gb.ignore_whitespace = \uACF5\uBC31 \uBB34\uC2DC +gb.allRepositories = \uBAA8\uB4E0 \uC800\uC7A5\uC18C +gb.oid = \uC624\uBE0C\uC81D\uD2B8 id +gb.filestore = \uD30C\uC77C\uC2A4\uD1A0\uC5B4 +gb.filestoreStats = \uD30C\uC77C\uC2A4\uD1A0\uC5B4\uC5D0 {1} \uC6A9\uB7C9\uC73C\uB85C {0} \uAC1C \uD30C\uC77C\uC774 \uC788\uC74C. ({2} \uB0A8\uC74C) +gb.statusChangedOn = \uC0C1\uD0DC \uBCC0\uACBD\uC77C +gb.statusChangedBy = \uC0C1\uD0DC \uBCC0\uACBD\uC790 +gb.filestoreHelp = \uD30C\uC77C\uC2A4\uD1A0\uC5B4 \uC0AC\uC6A9\uBC95? +gb.editFile = \uD30C\uC77C \uC218\uC815 +gb.continueEditing = \uACC4\uC18D \uC218\uC815 +gb.commitChanges = \uCEE4\uBC0B \uBCC0\uD654 +gb.fileNotMergeable = {0} \uC744(\uB97C) \uCEE4\uBC0B\uD560 \uC218 \uC5C6\uC74C. \uC774 \uD30C\uC77C\uC740 \uC790\uB3D9\uC73C\uB85C \uBA38\uC9C0 \uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +gb.fileCommitted = {0} \uAC00 \uC131\uACF5\uC801\uC73C\uB85C \uCEE4\uBC0B\uB418\uC5C8\uC5B4\uC694. +gb.deletePatchset = {0} \uD328\uCE58\uC14B \uC0AD\uC81C +gb.deletePatchsetSuccess = {0} \uD328\uCE58\uC14B\uC774 \uC0AD\uC81C\uB418\uC5C8\uC5B4\uC694. +gb.deletePatchsetFailure = {0} \uD328\uCE58\uC14B \uC0AD\uC81C \uC624\uB958. +gb.referencedByCommit = \uCEE4\uBC0B\uC5D0 \uCC38\uC870\uB428. +gb.referencedByTicket = \uD2F0\uCF13\uC5D0 \uCC38\uC870\uB428. \ No newline at end of file From d79f5630c82a0d89ec5b2d3a1f0365bf72668a78 Mon Sep 17 00:00:00 2001 From: Martin Spielmann Date: Fri, 6 Jan 2017 01:09:37 +0100 Subject: [PATCH 29/66] extracted method --- .../java/com/gitblit/manager/UserManager.java | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/gitblit/manager/UserManager.java b/src/main/java/com/gitblit/manager/UserManager.java index 2e68e40db..d661c9b42 100644 --- a/src/main/java/com/gitblit/manager/UserManager.java +++ b/src/main/java/com/gitblit/manager/UserManager.java @@ -121,15 +121,10 @@ public UserManager start() { // typical file path configuration File realmFile = runtimeManager.getFileOrFolder(Keys.realm.userService, "${baseFolder}/users.conf"); service = createUserService(realmFile); - } catch (InstantiationException | IllegalAccessException e1) { - logger.error("failed to instantiate user service {}: {}. Trying once again with IRuntimeManager constructor", realm, e1.getMessage()); - //try once again with IRuntimeManager constructor. This adds support for subclasses of ConfigUserService and other custom IUserServices - try { - Constructor constructor = Class.forName(realm).getConstructor(IRuntimeManager.class); - service = (IUserService) constructor.newInstance(runtimeManager); - } catch (NoSuchMethodException | SecurityException | ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e2) { - logger.error("failed to instantiate user service {}: {}", realm, e2.getMessage()); - } + } catch (InstantiationException | IllegalAccessException e) { + logger.error("failed to instantiate user service {}: {}. Trying once again with IRuntimeManager constructor", realm, e.getMessage()); + //try once again with IRuntimeManager constructor. This adds support for subclasses of ConfigUserService and other custom IUserServices + service = createIRuntimeManagerAwareUserService(realm); } } setUserService(service); @@ -137,6 +132,22 @@ public UserManager start() { return this; } + /** + * Tries to create an {@link IUserService} with {@link #runtimeManager} as a constructor parameter + * + * @param realm the class name of the {@link IUserService} to be instantiated + * @return the {@link IUserService} or {@code null} if instantiation fails + */ + private IUserService createIRuntimeManagerAwareUserService(String realm) { + try { + Constructor constructor = Class.forName(realm).getConstructor(IRuntimeManager.class); + return (IUserService) constructor.newInstance(runtimeManager); + } catch (NoSuchMethodException | SecurityException | ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + logger.error("failed to instantiate user service {}: {}", realm, e.getMessage()); + return null; + } + } + protected IUserService createUserService(File realmFile) { IUserService service = null; if (realmFile.getName().toLowerCase().endsWith(".conf")) { From 3de2d87a9720cde8dffdc3cb1d292e307862aff4 Mon Sep 17 00:00:00 2001 From: Martin Spielmann Date: Sat, 7 Jan 2017 14:09:42 +0100 Subject: [PATCH 30/66] updated to wicket 8.0.0-M3 --- build.moxie | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.moxie b/build.moxie index 422b62c16..50ff5e736 100644 --- a/build.moxie +++ b/build.moxie @@ -105,7 +105,7 @@ repositories: central, eclipse-snapshots, eclipse, gitblit properties: { jetty.version : 9.2.13.v20150730 slf4j.version : 1.7.12 - wicket.version : 8.0.0-M2 + wicket.version : 8.0.0-M3 lucene.version : 4.10.4 jgit.version : 4.1.1.201511131810-r groovy.version : 2.4.4 From e6e1dbfb3b1018f8136a9ddc9e54a5097f8d0cf8 Mon Sep 17 00:00:00 2001 From: Bala Raman Date: Sun, 15 Jan 2017 19:05:48 -0500 Subject: [PATCH 31/66] Update to web.xml, fix to #1132 Update to web.xml, fix to #1132 Fixes to namespace to fix xml parse error, where strict validation required --- src/main/java/WEB-INF/web.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/WEB-INF/web.xml b/src/main/java/WEB-INF/web.xml index db7721539..c648dcdef 100644 --- a/src/main/java/WEB-INF/web.xml +++ b/src/main/java/WEB-INF/web.xml @@ -1,7 +1,7 @@ - +