gerrit-server: use hashed passwords for HTTP.

Consequences:
* Removes the GET endpoint for the HTTP password
* Removes digest authentication
* Removes auth.gitBasicAuth config option.

With the move to NoteDB, the per-account data (including the HTTP
password) will be stored in a branch in the All-Users repo, where
it is subject to Gerrit ACLs.  Since these are notoriously hard to
setup correctly, we want to avoid storing the password in plaintext.

With this change, we support hashed passwords, and a schema upgrade
populates the existing 'password' field using previous passwords.

Tested migration manually:

  * ran schema upgrade
  * verified that schema upgrade inserts hashed passwords with gsql.
  * verified that the password still works with the new code.

Tested passwords manually:
  * verified that correct passwords get accepted when using curl --user.
  * verified that wrong passwords get rejected when using curl --user.

Change-Id: I26f5bcd7848040107e3721eeabf75baeb79c1724
This commit is contained in:
Han-Wen Nienhuys 2017-02-15 16:36:04 +01:00 committed by Edwin Kempin
parent 64f54cce18
commit 84d830b5b3
32 changed files with 327 additions and 530 deletions

View File

@ -141,6 +141,14 @@ account's full DN, which is discovered by first querying the
directory using either an anonymous request, or the configured
<<ldap.username,ldap.username>> identity. Gerrit can also use kerberos if
<<ldap.authentication,ldap.authentication>> is set to `GSSAPI`.
+
If link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
the randomly generated HTTP password is used for authentication. On the other hand,
if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
the password in the request is first checked against the HTTP password and, if
it does not match, it is then validated against the LDAP password.
Service users that only exist in the Gerrit database are authenticated by their
HTTP passwords.
* `LDAP_BIND`
+
@ -164,6 +172,12 @@ types of data, and can be revoked by users at any time.
Site owners have to register their application before getting started. Note
that provider specific plugins must be used with this authentication scheme.
+
Git clients may send OAuth 2 access tokens instead of passwords in the Basic
authentication header. Note that provider specific plugins must be installed to
facilitate this authentication scheme. If multiple OAuth 2 provider plugins are
installed one of them must be selected as default with the
`auth.gitOAuthProvider` option.
+
* `DEVELOPMENT_BECOME_ANY_ACCOUNT`
+
*DO NOT USE*. Only for use in a development environment.
@ -279,7 +293,7 @@ The "Sign In" link will send users directly to this URL.
[[auth.httpHeader]]auth.httpHeader::
+
HTTP header to trust the username from, or unset to select HTTP basic
or digest authentication. Only used if `auth.type` is set to `HTTP`.
authentication. Only used if `auth.type` is set to `HTTP`.
[[auth.httpDisplaynameHeader]]auth.httpDisplaynameHeader::
+
@ -445,45 +459,16 @@ Gerrit to authenticate users. In this case Gerrit will blindly trust
the container.
+
This parameter only affects git over http traffic. If set to false
then Gerrit will do the authentication (using DIGEST authentication).
then Gerrit will do the authentication (using Basic authentication).
+
By default this is set to false.
[[auth.gitBasicAuth]]auth.gitBasicAuth::
+
If true then Git over HTTP and HTTP/S traffic is authenticated using
standard BasicAuth. Depending on the configured `auth.type`, credentials
are validated against the randomly generated HTTP password, against LDAP
(`auth.type = LDAP`) or against an OAuth 2 provider (`auth.type = OAUTH`).
+
This parameter affects git over HTTP traffic and access to the REST
API. If set to false then Gerrit will authenticate through DIGEST
authentication and the randomly generated HTTP password in the Gerrit
database.
+
When `auth.type` is `LDAP`, users should authenticate using their LDAP passwords.
However, if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
the randomly generated HTTP password is used exclusively. In the other hand,
if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
the password in the request is first checked against the HTTP password and, if
it does not match, it is then validated against the LDAP password.
Service users that only exist in the Gerrit database are authenticated by their
HTTP passwords.
+
When `auth.type` is `OAUTH`, Git clients may send OAuth 2 access tokens
instead of passwords in the Basic authentication header. Note that provider
specific plugins must be installed to facilitate this authentication scheme.
If multiple OAuth 2 provider plugins are installed one of them must be
selected as default with the `auth.gitOAuthProvider` option.
+
By default this is set to false.
[[auth.gitBasicAuthPolicy]]auth.gitBasicAuthPolicy::
+
When `auth.type` is `LDAP` and BasicAuth (i.e., link:#auth.gitBasicAuth[`auth.gitBasicAuth`]
is set to true), it allows using either the generated HTTP password, the LDAP
password or both to authenticate Git over HTTP and REST API requests. The
supported values are:
When `auth.type` is `LDAP`, it allows using either the generated HTTP password,
the LDAP password, or both, to authenticate Git over HTTP and REST API
requests. The supported values are:
+
*`HTTP`
+

View File

@ -88,7 +88,7 @@ To link another identity to an existing account:
Login using the other identity can only be performed after the linking is
successful.
== HTTP Basic/Digest Authentication
== HTTP Basic Authentication
When using HTTP authentication, Gerrit assumes that the servlet
container or the frontend web server has performed all user

View File

@ -1443,7 +1443,7 @@ can be accessed from any REST client, i. e.:
----
curl -X POST -H "Content-Type: application/json" \
-d '{message: "François", french: true}' \
--digest --user joe:secret \
--user joe:secret \
http://host:port/a/changes/1/revisions/1/cookbook~say-hello
"Bonjour François from change 1, patch set 1!"
----
@ -2451,18 +2451,18 @@ is an error. Errors are always handled by the Gerrit core UI which
shows the error dialog. This means currently plugins cannot do any
error handling and e.g. ignore expected errors.
In the following example the REST endpoint would return '404 Not Found'
if there is no HTTP password and the Gerrit core UI would display an
error dialog for this. However having no HTTP password is not an error
and the plugin may like to handle this case.
In the following example the REST endpoint would return '404 Not
Found' if the user has no username and the Gerrit core UI would
display an error dialog for this. However having no username is
not an error and the plugin may like to handle this case.
[source,java]
----
new RestApi("accounts").id("self").view("password.http")
new RestApi("accounts").id("self").view("username")
.get(new AsyncCallback<NativeString>() {
@Override
public void onSuccess(NativeString httpPassword) {
public void onSuccess(NativeString username) {
// TODO
}

View File

@ -47,7 +47,7 @@ option instead:
Example to set a Gerrit project's link:rest-api-projects.html#set-project-description[description]:
----
curl -X PUT --digest --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
curl -X PUT --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
----
=== Authentication
@ -56,7 +56,7 @@ To test APIs that require authentication, the username and password must be spec
the command line:
----
curl --digest --user username:password http://localhost:8080/a/path/to/api/
curl --user username:password http://localhost:8080/a/path/to/api/
----
This makes it easy to switch users for testing of permissions.
@ -65,7 +65,7 @@ It is also possible to test with a username and password from the `.netrc`
file (on Windows, `_netrc`):
----
curl --digest -n http://localhost:8080/a/path/to/api/
curl -n http://localhost:8080/a/path/to/api/
----
In both cases, the password should be the user's link:user-upload.html#http[HTTP password].
@ -75,7 +75,7 @@ In both cases, the password should be the user's link:user-upload.html#http[HTTP
To verify the headers returned from a REST API call, use `curl` in verbose mode:
----
curl -v -n --digest -X DELETE http://localhost:8080/a/path/to/api/
curl -v -n -X DELETE http://localhost:8080/a/path/to/api/
----
The headers on both the request and the response will be printed.

View File

@ -458,31 +458,6 @@ Sets the account state to inactive.
If the account was already inactive the response is "`409 Conflict`".
[[get-http-password]]
=== Get HTTP Password
--
'GET /accounts/link:#account-id[\{account-id\}]/password.http'
--
Retrieves the HTTP password of an account.
.Request
----
GET /accounts/john.doe@example.com/password.http HTTP/1.0
----
.Response
----
HTTP/1.1 200 OK
Content-Disposition: attachment
Content-Type: application/json; charset=UTF-8
)]}'
"Qmxlc21ydCB1YmVyIGFsbGVzIGluIGRlciBXZWx0IQ"
----
If the account does not have an HTTP password the response is "`404 Not Found`".
[[set-http-password]]
=== Set/Generate HTTP Password
--
@ -1028,12 +1003,12 @@ link:#capability-info[CapabilityInfo] entity.
}
----
Administrator that has authenticated with digest authentication:
Administrator that has authenticated with basic authentication:
.Request
----
GET /a/accounts/self/capabilities HTTP/1.0
Authorization: Digest username="admin", realm="Gerrit Code Review", nonce="...
Authorization: Basic ABCDECF..
----
.Response
@ -1075,7 +1050,7 @@ possible alternative for the caller.
.Request
----
GET /a/accounts/self/capabilities?q=createAccount&q=createGroup HTTP/1.0
Authorization: Digest username="admin", realm="Gerrit Code Review", nonce="...
Authorization: Basic ABCDEF...
----
.Response

View File

@ -470,9 +470,9 @@ The cache names are lexicographically sorted.
E.g. this could be used to flush all caches:
+
----
for c in $(curl --digest --user jdoe:TNAuLkXsIV7w http://gerrit/a/config/server/caches/?format=TEXT_LIST | base64 -D)
for c in $(curl --user jdoe:TNAuLkXsIV7w http://gerrit/a/config/server/caches/?format=TEXT_LIST | base64 -D)
do
curl --digest --user jdoe:TNAuLkXsIV7w -X POST http://gerrit/a/config/server/caches/$c/flush
curl --user jdoe:TNAuLkXsIV7w -X POST http://gerrit/a/config/server/caches/$c/flush
done
----
@ -1270,11 +1270,6 @@ type] is `LDAP`, `LDAP_BIND` or `CUSTOM_EXTENSION`.
The link:config-gerrit.html#auth.httpPasswordUrl[URL to obtain an HTTP
password]. Only set if link:config-gerrit.html#auth.type[authentication
type] is `CUSTOM_EXTENSION`.
|`is_git_basic_auth` |optional, not set if `false`|
Whether link:config-gerrit.html#auth.gitBasicAuth[basic authentication
is used for Git over HTTP/HTTPS]. Only set if
link:config-gerrit.html#auth.type[authentication type] is is `LDAP` or
`LDAP_BIND`.
|`git_basic_auth_policy` |optional|
The link:config-gerrit.html#auth.gitBasicAuthPolicy[policy] to authenticate
Git over HTTP and REST API requests when

View File

@ -87,7 +87,7 @@ To provide the plugin jar as binary data in the request body the
following curl command can be used:
----
curl --digest --user admin:TNNuLkWsIV8w -X PUT --data-binary @delete-project-2.8.jar 'http://gerrit:8080/a/plugins/delete-project'
curl --user admin:TNNuLkWsIV8w -X PUT --data-binary @delete-project-2.8.jar 'http://gerrit:8080/a/plugins/delete-project'
----
As response a link:#plugin-info[PluginInfo] entity is returned that

View File

@ -36,10 +36,8 @@ Users (and programs) may authenticate by prefixing the endpoint URL with
`/a/`. For example to authenticate to `/projects/`, request the URL
`/a/projects/`.
By default Gerrit uses HTTP digest authentication with the HTTP password
from the user's account settings page. HTTP basic authentication is used
if link:config-gerrit.html#auth.gitBasicAuth[`auth.gitBasicAuth`] is set
to true in the Gerrit configuration.
Gerrit uses HTTP basic authentication with the HTTP password from the
user's account settings page.
[[preconditions]]
=== Preconditions

View File

@ -18,10 +18,9 @@ public key, and HTTP/HTTPS.
On Gerrit installations that do not support SSH authentication, the
user must authenticate via HTTP/HTTPS.
When link:config-gerrit.html#auth.gitBasicAuth[gitBasicAuth] is enabled,
the user is authenticated using standard BasicAuth. Depending on the value of
link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy], credentials are
validated using:
The user is authenticated using standard BasicAuth. Depending on the
value of link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy],
credentials are validated using:
* The randomly generated HTTP password on the `HTTP Password` tab
in the user settings page if `gitBasicAuthPolicy` is `HTTP`.
@ -29,9 +28,10 @@ validated using:
* Both, the HTTP and the LDAP passwords (in this order) if `gitBasicAuthPolicy`
is `HTTP_LDAP`.
When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can be
accessed within Gerrit by going to `Settings`, and then accessing the `HTTP
Password` tab.
When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can
be regenerated by going to `Settings`, and then accessing the `HTTP
Password` tab. Revocation can effectively be done by regenerating the
password and then forgetting it.
For Gerrit installations where an link:config-gerrit.html#auth.httpPasswordUrl[HTTP password URL]
is configured, the password can be obtained by clicking on `Obtain Password`

View File

@ -27,6 +27,7 @@ import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.account.AccountByEmailCache;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.GroupCache;
import com.google.gerrit.server.account.HashedPassword;
import com.google.gerrit.server.account.VersionedAuthorizedKeys;
import com.google.gerrit.server.index.account.AccountIndexer;
import com.google.gerrit.server.ssh.SshKeyCache;
@ -87,7 +88,8 @@ public class AccountCreator {
new AccountExternalId(
id, new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, username));
String httpPass = "http-pass";
extUser.setPassword(httpPass);
extUser.setPassword(HashedPassword.fromPassword(httpPass).encode());
db.accountExternalIds().insert(Collections.singleton(extUser));
if (email != null) {

View File

@ -88,7 +88,6 @@ public class ServerInfoIT extends AbstractDaemonTest {
assertThat(i.auth.registerText).isNull();
assertThat(i.auth.editFullNameUrl).isNull();
assertThat(i.auth.httpPasswordUrl).isNull();
assertThat(i.auth.isGitBasicAuth).isNull();
// change
assertThat(i.change.allowDrafts).isNull();
@ -163,7 +162,6 @@ public class ServerInfoIT extends AbstractDaemonTest {
assertThat(i.auth.registerText).isNull();
assertThat(i.auth.editFullNameUrl).isNull();
assertThat(i.auth.httpPasswordUrl).isNull();
assertThat(i.auth.isGitBasicAuth).isNull();
// change
assertThat(i.change.allowDrafts).isTrue();

View File

@ -31,6 +31,5 @@ public class AuthInfo {
public String registerText;
public String editFullNameUrl;
public String httpPasswordUrl;
public Boolean isGitBasicAuth;
public GitBasicAuthPolicy gitBasicAuthPolicy;
}

View File

@ -42,14 +42,10 @@ public class GitOverHttpModule extends ServletModule {
Class<? extends Filter> authFilter;
if (authConfig.isTrustContainerAuth()) {
authFilter = ContainerAuthFilter.class;
} else if (authConfig.isGitBasicAuth()) {
if (authConfig.getAuthType() == OAUTH) {
authFilter = ProjectOAuthFilter.class;
} else {
authFilter = ProjectBasicAuthFilter.class;
}
} else if (authConfig.getAuthType() == OAUTH) {
authFilter = ProjectOAuthFilter.class;
} else {
authFilter = ProjectDigestFilter.class;
authFilter = ProjectBasicAuthFilter.class;
}
if (isHttpEnabled()) {

View File

@ -140,7 +140,7 @@ class ProjectBasicAuthFilter implements Filter {
GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
|| gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
if (passwordMatchesTheUserGeneratedOne(who, username, password)) {
if (who.checkPassword(password, username)) {
return succeedAuthentication(who);
}
}
@ -157,7 +157,7 @@ class ProjectBasicAuthFilter implements Filter {
setUserIdentified(whoAuthResult.getAccountId());
return true;
} catch (NoSuchUserException e) {
if (password.equals(who.getPassword(who.getUserName()))) {
if (who.checkPassword(password, who.getUserName())) {
return succeedAuthentication(who);
}
log.warn("Authentication failed for " + username, e);
@ -193,12 +193,6 @@ class ProjectBasicAuthFilter implements Filter {
ws.setAccessPathOk(AccessPath.REST_API, true);
}
private boolean passwordMatchesTheUserGeneratedOne(
AccountState who, String username, String password) {
String accountPassword = who.getPassword(username);
return accountPassword != null && password != null && accountPassword.equals(password);
}
private String encoding(HttpServletRequest req) {
return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
}

View File

@ -1,337 +0,0 @@
// Copyright (C) 2010 The Android Open Source Project
//
// 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.google.gerrit.httpd;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.HOURS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.server.AccessPath;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gwtjsonrpc.server.SignedToken;
import com.google.gwtjsonrpc.server.XsrfException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import org.eclipse.jgit.lib.Config;
/**
* Authenticates the current user by HTTP digest authentication.
*
* <p>The current HTTP request is authenticated by looking up the username from the Authorization
* header and checking the digest response against the stored password. This filter is intended only
* to protect the {@link GitOverHttpServlet} and its handled URLs, which provide remote repository
* access over HTTP.
*
* @see <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a>
*/
@Singleton
class ProjectDigestFilter implements Filter {
public static final String REALM_NAME = "Gerrit Code Review";
private static final String AUTHORIZATION = "Authorization";
private final Provider<String> urlProvider;
private final DynamicItem<WebSession> session;
private final AccountCache accountCache;
private final Config config;
private final SignedToken tokens;
private ServletContext context;
@Inject
ProjectDigestFilter(
@CanonicalWebUrl @Nullable Provider<String> urlProvider,
DynamicItem<WebSession> session,
AccountCache accountCache,
@GerritServerConfig Config config)
throws XsrfException {
this.urlProvider = urlProvider;
this.session = session;
this.accountCache = accountCache;
this.config = config;
this.tokens = new SignedToken((int) SECONDS.convert(1, HOURS));
}
@Override
public void init(FilterConfig config) {
context = config.getServletContext();
}
@Override
public void destroy() {}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
Response rsp = new Response(req, (HttpServletResponse) response);
if (verify(req, rsp)) {
chain.doFilter(req, rsp);
}
}
private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
final String hdr = req.getHeader(AUTHORIZATION);
if (hdr == null || !hdr.startsWith("Digest ")) {
// Allow an anonymous connection through, or it might be using a
// session cookie instead of digest authentication.
return true;
}
final Map<String, String> p = parseAuthorization(hdr);
final String user = p.get("username");
final String realm = p.get("realm");
final String nonce = p.get("nonce");
final String uri = p.get("uri");
final String response = p.get("response");
final String qop = p.get("qop");
final String nc = p.get("nc");
final String cnonce = p.get("cnonce");
final String method = req.getMethod();
if (user == null //
|| realm == null //
|| nonce == null //
|| uri == null //
|| response == null //
|| !"auth".equals(qop) //
|| !REALM_NAME.equals(realm)) {
context.log("Invalid header: " + AUTHORIZATION + ": " + hdr);
rsp.sendError(SC_FORBIDDEN);
return false;
}
String username = user;
if (config.getBoolean("auth", "userNameToLowerCase", false)) {
username = username.toLowerCase(Locale.US);
}
final AccountState who = accountCache.getByUsername(username);
if (who == null || !who.getAccount().isActive()) {
rsp.sendError(SC_UNAUTHORIZED);
return false;
}
final String passwd = who.getPassword(username);
if (passwd == null) {
rsp.sendError(SC_UNAUTHORIZED);
return false;
}
final String A1 = user + ":" + realm + ":" + passwd;
final String A2 = method + ":" + uri;
final String expect = KD(H(A1), nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + H(A2));
if (expect.equals(response)) {
try {
if (tokens.checkToken(nonce, "") != null) {
WebSession ws = session.get();
ws.setUserAccountId(who.getAccount().getId());
ws.setAccessPathOk(AccessPath.GIT, true);
ws.setAccessPathOk(AccessPath.REST_API, true);
return true;
}
rsp.stale = true;
rsp.sendError(SC_UNAUTHORIZED);
return false;
} catch (XsrfException e) {
context.log("Error validating nonce for digest authentication", e);
rsp.sendError(SC_INTERNAL_SERVER_ERROR);
return false;
}
}
rsp.sendError(SC_UNAUTHORIZED);
return false;
}
private static String H(String data) {
MessageDigest md = newMD5();
md.update(data.getBytes(UTF_8));
return LHEX(md.digest());
}
private static String KD(String secret, String data) {
MessageDigest md = newMD5();
md.update(secret.getBytes(UTF_8));
md.update((byte) ':');
md.update(data.getBytes(UTF_8));
return LHEX(md.digest());
}
private static MessageDigest newMD5() {
try {
return MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("No MD5 available", e);
}
}
private static final char[] LHEX = {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', //
'a', 'b', 'c', 'd', 'e', 'f',
};
private static String LHEX(byte[] bin) {
StringBuilder r = new StringBuilder(bin.length * 2);
for (byte b : bin) {
r.append(LHEX[(b >>> 4) & 0x0f]);
r.append(LHEX[b & 0x0f]);
}
return r.toString();
}
private Map<String, String> parseAuthorization(String auth) {
Map<String, String> p = new HashMap<>();
int next = "Digest ".length();
while (next < auth.length()) {
if (next < auth.length() && auth.charAt(next) == ',') {
next++;
}
while (next < auth.length() && Character.isWhitespace(auth.charAt(next))) {
next++;
}
int eq = auth.indexOf('=', next);
if (eq < 0 || eq + 1 == auth.length()) {
return Collections.emptyMap();
}
final String name = auth.substring(next, eq);
final String value;
if (auth.charAt(eq + 1) == '"') {
int dq = auth.indexOf('"', eq + 2);
if (dq < 0) {
return Collections.emptyMap();
}
value = auth.substring(eq + 2, dq);
next = dq + 1;
} else {
int space = auth.indexOf(' ', eq + 1);
int comma = auth.indexOf(',', eq + 1);
if (space < 0) {
space = auth.length();
}
if (comma < 0) {
comma = auth.length();
}
final int e = Math.min(space, comma);
value = auth.substring(eq + 1, e);
next = e + 1;
}
p.put(name, value);
}
return p;
}
private String newNonce() {
try {
return tokens.newToken("");
} catch (XsrfException e) {
throw new RuntimeException("Cannot generate new nonce", e);
}
}
class Response extends HttpServletResponseWrapper {
private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
private final HttpServletRequest req;
Boolean stale;
Response(HttpServletRequest req, HttpServletResponse rsp) {
super(rsp);
this.req = req;
}
private void status(int sc) {
if (sc == SC_UNAUTHORIZED) {
StringBuilder v = new StringBuilder();
v.append("Digest");
v.append(" realm=\"").append(REALM_NAME).append("\"");
String url = urlProvider.get();
if (url == null) {
url = req.getContextPath();
if (url != null && !url.isEmpty() && !url.endsWith("/")) {
url += "/";
}
}
if (url != null && !url.isEmpty()) {
v.append(", domain=\"").append(url).append("\"");
}
v.append(", qop=\"auth\"");
if (stale != null) {
v.append(", stale=").append(stale);
}
v.append(", nonce=\"").append(newNonce()).append("\"");
setHeader(WWW_AUTHENTICATE, v.toString());
} else if (containsHeader(WWW_AUTHENTICATE)) {
setHeader(WWW_AUTHENTICATE, null);
}
}
@Override
public void sendError(int sc, String msg) throws IOException {
status(sc);
super.sendError(sc, msg);
}
@Override
public void sendError(int sc) throws IOException {
status(sc);
super.sendError(sc);
}
@Override
@Deprecated
public void setStatus(int sc, String sm) {
status(sc);
super.setStatus(sc, sm);
}
@Override
public void setStatus(int sc) {
status(sc);
super.setStatus(sc);
}
}
}

View File

@ -30,6 +30,7 @@ import com.google.gerrit.reviewdb.client.AccountGroupName;
import com.google.gerrit.reviewdb.client.AccountSshKey;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.account.HashedPassword;
import com.google.gerrit.server.index.account.AccountIndex;
import com.google.gerrit.server.index.account.AccountIndexCollection;
import com.google.gwtorm.server.SchemaFactory;
@ -95,7 +96,7 @@ public class InitAdminUser implements InitStep {
new AccountExternalId(
id, new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, username));
if (!Strings.isNullOrEmpty(httpPassword)) {
extUser.setPassword(httpPassword);
extUser.setPassword(HashedPassword.fromPassword(httpPassword).encode());
}
extIds.add(extUser);
db.accountExternalIds().insert(Collections.singleton(extUser));

View File

@ -87,6 +87,8 @@ public final class AccountExternalId {
@Column(id = 3, notNull = false)
protected String emailAddress;
// Encoded version of the hashed and salted password, to be interpreted by the
// {@link HashedPassword} class.
@Column(id = 4, notNull = false)
protected String password;
@ -140,12 +142,12 @@ public final class AccountExternalId {
return null != scheme ? getExternalId().substring(scheme.length() + 1) : null;
}
public String getPassword() {
return password;
public void setPassword(String hashed) {
password = hashed;
}
public void setPassword(String p) {
password = p;
public String getPassword() {
return password;
}
public boolean isTrusted() {

View File

@ -230,9 +230,11 @@ junit_tests(
"//lib:guava",
"//lib:guava-retrying",
"//lib:protobuf",
"//lib/bouncycastle:bcprov",
"//lib/dropwizard:dropwizard-core",
"//lib/guice:guice-assistedinject",
"//lib/prolog:runtime",
"//lib/commons:codec",
],
)

View File

@ -18,6 +18,7 @@ import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
import com.google.common.base.Function;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.gerrit.common.Nullable;
@ -32,8 +33,13 @@ import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.apache.commons.codec.DecoderException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class AccountState {
private static final Logger logger = LoggerFactory.getLogger(AccountState.class);
public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
a -> a.getAccount().getId();
@ -70,14 +76,28 @@ public class AccountState {
return account.getUserName();
}
/** @return the password matching the requested username; or null. */
public String getPassword(String username) {
public boolean checkPassword(String password, String username) {
if (password == null) {
return false;
}
for (AccountExternalId id : getExternalIds()) {
if (id.isScheme(AccountExternalId.SCHEME_USERNAME) && username.equals(id.getSchemeRest())) {
return id.getPassword();
// Only process the "username:$USER" entry, which is unique.
if (!id.isScheme(AccountExternalId.SCHEME_USERNAME) || !username.equals(id.getSchemeRest())) {
continue;
}
String hashedStr = id.getPassword();
if (!Strings.isNullOrEmpty(hashedStr)) {
try {
return HashedPassword.decode(hashedStr).checkPassword(password);
} catch (DecoderException e) {
logger.error(
String.format("DecoderException for user %s: %s ", username, e.getMessage()));
return false;
}
}
}
return null;
return false;
}
/** The external identities that identify the account holder. */

View File

@ -125,7 +125,7 @@ public class CreateAccount implements RestModifyView<TopLevelResource, AccountIn
id, new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, username));
if (input.httpPassword != null) {
extUser.setPassword(input.httpPassword);
extUser.setPassword(HashedPassword.fromPassword(input.httpPassword).encode());
}
if (db.accountExternalIds().get(extUser.getKey()) != null) {

View File

@ -1,50 +0,0 @@
// Copyright (C) 2013 The Android Open Source Project
//
// 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.google.gerrit.server.account;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestReadView;
import com.google.gerrit.server.CurrentUser;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@Singleton
public class GetHttpPassword implements RestReadView<AccountResource> {
private final Provider<CurrentUser> self;
@Inject
GetHttpPassword(Provider<CurrentUser> self) {
this.self = self;
}
@Override
public String apply(AccountResource rsrc) throws AuthException, ResourceNotFoundException {
if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
throw new AuthException("not allowed to get http password");
}
AccountState s = rsrc.getUser().state();
if (s.getUserName() == null) {
throw new ResourceNotFoundException();
}
String p = s.getPassword(s.getUserName());
if (p == null) {
throw new ResourceNotFoundException();
}
return p;
}
}

View File

@ -0,0 +1,116 @@
// Copyright (C) 2017 The Android Open Source Project
//
// 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.google.gerrit.server.account;
import com.google.common.base.Preconditions;
import com.google.common.io.BaseEncoding;
import com.google.common.primitives.Ints;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import org.apache.commons.codec.DecoderException;
import org.bouncycastle.crypto.generators.BCrypt;
import org.bouncycastle.util.Arrays;
/**
* Holds logic for salted, hashed passwords. It uses BCrypt from BouncyCastle, which truncates
* passwords at 72 bytes.
*/
public class HashedPassword {
private static final String ALGORITHM_PREFIX = "bcrypt:";
private static final SecureRandom secureRandom = new SecureRandom();
private static final BaseEncoding codec = BaseEncoding.base64();
// bcrypt uses 2^cost rounds. Since we use a generated random password, no need
// for a high cost.
private static final int DEFAULT_COST = 4;
/**
* decodes a hashed password encoded with {@link #encode}.
*
* @throws DecoderException if input is malformed.
*/
public static HashedPassword decode(String encoded) throws DecoderException {
if (!encoded.startsWith(ALGORITHM_PREFIX)) {
throw new DecoderException("unrecognized algorithm");
}
String[] fields = encoded.split(":");
if (fields.length != 4) {
throw new DecoderException("want 4 fields");
}
Integer cost = Ints.tryParse(fields[1]);
if (cost == null) {
throw new DecoderException("cost parse failed");
}
if (!(cost >= 4 && cost < 32)) {
throw new DecoderException("cost should be 4..31 inclusive, got " + cost);
}
byte[] salt = codec.decode(fields[2]);
if (salt.length != 16) {
throw new DecoderException("salt should be 16 bytes, got " + salt.length);
}
return new HashedPassword(codec.decode(fields[3]), salt, cost);
}
private static byte[] hashPassword(String password, byte[] salt, int cost) {
byte[] pwBytes = password.getBytes(StandardCharsets.UTF_8);
return BCrypt.generate(pwBytes, salt, cost);
}
public static HashedPassword fromPassword(String password) {
byte[] salt = newSalt();
return new HashedPassword(hashPassword(password, salt, DEFAULT_COST), salt, DEFAULT_COST);
}
private static byte[] newSalt() {
byte[] bytes = new byte[16];
secureRandom.nextBytes(bytes);
return bytes;
}
private byte[] salt;
private byte[] hashed;
private int cost;
private HashedPassword(byte[] hashed, byte[] salt, int cost) {
this.salt = salt;
this.hashed = hashed;
this.cost = cost;
Preconditions.checkState(cost >= 4 && cost < 32);
// salt must be 128 bit.
Preconditions.checkState(salt.length == 16);
}
/**
* Serialize the hashed password and its parameters for persistent storage.
*
* @return one-line string encoding the hash and salt.
*/
public String encode() {
return ALGORITHM_PREFIX + cost + ":" + codec.encode(salt) + ":" + codec.encode(hashed);
}
public boolean checkPassword(String password) {
// Constant-time comparison, because we're paranoid.
return Arrays.areEqual(hashPassword(password, salt, cost), hashed);
}
}

View File

@ -56,7 +56,6 @@ public class Module extends RestApiModule {
put(EMAIL_KIND).to(PutEmail.class);
delete(EMAIL_KIND).to(DeleteEmail.class);
put(EMAIL_KIND, "preferred").to(PutPreferred.class);
get(ACCOUNT_KIND, "password.http").to(GetHttpPassword.class);
put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);

View File

@ -113,7 +113,8 @@ public class PutHttpPassword implements RestModifyView<AccountResource, Input> {
if (id == null) {
throw new ResourceNotFoundException();
}
id.setPassword(newPassword);
id.setPassword(HashedPassword.fromPassword(newPassword).encode());
dbProvider.get().accountExternalIds().update(Collections.singleton(id));
accountCache.evict(user.getAccountId());

View File

@ -15,7 +15,6 @@
package com.google.gerrit.server.auth;
import com.google.gerrit.common.Nullable;
import java.util.Objects;
/** Defines an abstract request for user authentication to Gerrit. */
public abstract class AuthRequest {
@ -46,10 +45,4 @@ public abstract class AuthRequest {
public final String getPassword() {
return password;
}
public void checkPassword(String pwd) throws AuthException {
if (!Objects.equals(getPassword(), pwd)) {
throw new InvalidCredentialsException();
}
}
}

View File

@ -38,6 +38,7 @@ public class InternalAuthBackend implements AuthBackend {
return "gerrit";
}
// TODO(gerritcodereview-team): This function has no coverage.
@Override
public AuthUser authenticate(AuthRequest req)
throws MissingCredentialsException, InvalidCredentialsException, UnknownUserException,
@ -63,7 +64,9 @@ public class InternalAuthBackend implements AuthBackend {
+ ": account inactive or not provisioned in Gerrit");
}
req.checkPassword(who.getPassword(username));
if (!who.checkPassword(req.getPassword(), username)) {
throw new InvalidCredentialsException();
}
return new AuthUser(AuthUser.UUID.create(username), username);
}
}

View File

@ -44,7 +44,6 @@ public class AuthConfig {
private final boolean trustContainerAuth;
private final boolean enableRunAs;
private final boolean userNameToLowerCase;
private final boolean gitBasicAuth;
private final boolean useContributorAgreements;
private final String loginUrl;
private final String loginText;
@ -88,7 +87,6 @@ public class AuthConfig {
cookieSecure = cfg.getBoolean("auth", "cookiesecure", false);
trustContainerAuth = cfg.getBoolean("auth", "trustContainerAuth", false);
enableRunAs = cfg.getBoolean("auth", null, "enableRunAs", true);
gitBasicAuth = cfg.getBoolean("auth", "gitBasicAuth", false);
gitBasicAuthPolicy = getBasicAuthPolicy(cfg);
useContributorAgreements = cfg.getBoolean("auth", "contributoragreements", false);
userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
@ -223,11 +221,6 @@ public class AuthConfig {
return userNameToLowerCase;
}
/** Whether git-over-http should use Gerrit basic authentication scheme. */
public boolean isGitBasicAuth() {
return gitBasicAuth;
}
public GitBasicAuthPolicy getGitBasicAuthPolicy() {
return gitBasicAuthPolicy;
}

View File

@ -156,7 +156,6 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
info.switchAccountUrl = cfg.getSwitchAccountUrl();
info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
if (info.useContributorAgreements != null) {

View File

@ -35,7 +35,7 @@ import java.util.concurrent.TimeUnit;
/** A version of the database schema. */
public abstract class SchemaVersion {
/** The current schema version. */
public static final Class<Schema_141> C = Schema_141.class;
public static final Class<Schema_142> C = Schema_142.class;
public static int getBinaryVersion() {
return guessVersion(C);

View File

@ -0,0 +1,49 @@
// Copyright (C) 2017 The Android Open Source Project
//
// 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.google.gerrit.server.schema;
import com.google.gerrit.reviewdb.client.AccountExternalId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.account.HashedPassword;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.sql.SQLException;
import java.util.List;
public class Schema_142 extends SchemaVersion {
@Inject
Schema_142(Provider<Schema_141> prior) {
super(prior);
}
@Override
protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
List<AccountExternalId> newIds = db.accountExternalIds().all().toList();
for (AccountExternalId id : newIds) {
if (!id.isScheme(AccountExternalId.SCHEME_USERNAME)) {
continue;
}
String password = id.getPassword();
if (password != null) {
HashedPassword hashed = HashedPassword.fromPassword(password);
id.setPassword(hashed.encode());
}
}
db.accountExternalIds().upsert(newIds);
}
}

View File

@ -36,7 +36,7 @@ then
exit 1
fi
curl --digest -u $gerrituser -w '%{http_code}' -o preview \
curl -u $gerrituser -w '%{http_code}' -o preview \
$server/a/changes/$changeId/revisions/current/preview_submit?format=tgz >http_code
if ! grep 200 http_code >/dev/null
then

View File

@ -0,0 +1,64 @@
// Copyright (C) 2017 The Android Open Source Project
//
// 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.google.gerrit.server.account;
import static com.google.common.truth.Truth.assertThat;
import com.google.common.base.Strings;
import org.apache.commons.codec.DecoderException;
import org.junit.Test;
public class HashedPasswordTest {
@Test
public void encodeOneLine() throws Exception {
String password = "secret";
HashedPassword hashed = HashedPassword.fromPassword(password);
assertThat(hashed.encode()).doesNotContain("\n");
assertThat(hashed.encode()).doesNotContain("\r");
}
@Test
public void encodeDecode() throws Exception {
String password = "secret";
HashedPassword hashed = HashedPassword.fromPassword(password);
HashedPassword roundtrip = HashedPassword.decode(hashed.encode());
assertThat(hashed.encode()).isEqualTo(roundtrip.encode());
assertThat(roundtrip.checkPassword(password)).isTrue();
assertThat(roundtrip.checkPassword("not the password")).isFalse();
}
@Test(expected = DecoderException.class)
public void invalidDecode() throws Exception {
HashedPassword.decode("invalid");
}
@Test
public void lengthLimit() throws Exception {
String password = Strings.repeat("1", 72);
// make sure it fits in varchar(255).
assertThat(HashedPassword.fromPassword(password).encode().length()).isLessThan(255);
}
@Test
public void basicFunctionality() throws Exception {
String password = "secret";
HashedPassword hashed = HashedPassword.fromPassword(password);
assertThat(hashed.checkPassword("false")).isFalse();
assertThat(hashed.checkPassword(password)).isTrue();
}
}