331 lines
9.9 KiB
Java
331 lines
9.9 KiB
Java
// 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.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.server.account.AccountCache;
|
|
import com.google.gerrit.server.account.AccountState;
|
|
import com.google.gerrit.server.config.CanonicalWebUrl;
|
|
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.io.UnsupportedEncodingException;
|
|
import java.security.MessageDigest;
|
|
import java.security.NoSuchAlgorithmException;
|
|
import java.util.Collections;
|
|
import java.util.HashMap;
|
|
import java.util.Map;
|
|
|
|
import javax.annotation.Nullable;
|
|
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;
|
|
|
|
/**
|
|
* 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 ProjectServlet}
|
|
* 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 Provider<WebSession> session;
|
|
private final AccountCache accountCache;
|
|
private final SignedToken tokens;
|
|
private ServletContext context;
|
|
|
|
@Inject
|
|
ProjectDigestFilter(@CanonicalWebUrl @Nullable Provider<String> urlProvider,
|
|
Provider<WebSession> session, AccountCache accountCache)
|
|
throws XsrfException {
|
|
this.urlProvider = urlProvider;
|
|
this.session = session;
|
|
this.accountCache = accountCache;
|
|
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((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) {
|
|
// 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 username = 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 (username == 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;
|
|
}
|
|
|
|
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 = username + ":" + 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) {
|
|
session.get().setUserAccountId(who.getAccount().getId());
|
|
return true;
|
|
|
|
} else {
|
|
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;
|
|
}
|
|
|
|
} else {
|
|
rsp.sendError(SC_UNAUTHORIZED);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static String H(String data) {
|
|
try {
|
|
MessageDigest md = newMD5();
|
|
md.update(data.getBytes("UTF-8"));
|
|
return LHEX(md.digest());
|
|
} catch (UnsupportedEncodingException e) {
|
|
throw new RuntimeException("UTF-8 encoding not available", e);
|
|
}
|
|
}
|
|
|
|
private static String KD(String secret, String data) {
|
|
try {
|
|
MessageDigest md = newMD5();
|
|
md.update(secret.getBytes("UTF-8"));
|
|
md.update((byte) ':');
|
|
md.update(data.getBytes("UTF-8"));
|
|
return LHEX(md.digest());
|
|
} catch (UnsupportedEncodingException e) {
|
|
throw new RuntimeException("UTF-8 encoding not available", e);
|
|
}
|
|
}
|
|
|
|
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 (int i = 0; i < bin.length; i++) {
|
|
byte b = bin[i];
|
|
r.append(LHEX[(b >>> 4) & 0x0f]);
|
|
r.append(LHEX[b & 0x0f]);
|
|
}
|
|
return r.toString();
|
|
}
|
|
|
|
private Map<String, String> parseAuthorization(String auth) {
|
|
if (!auth.startsWith("Digest ")) {
|
|
// We only support Digest authentication scheme, deny the rest.
|
|
//
|
|
return Collections.emptyMap();
|
|
}
|
|
|
|
Map<String, String> p = new HashMap<String, String>();
|
|
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 getDomain() {
|
|
return urlProvider.get() + "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";
|
|
|
|
Boolean stale;
|
|
|
|
Response(HttpServletResponse rsp) {
|
|
super(rsp);
|
|
}
|
|
|
|
private void status(int sc) {
|
|
if (sc == SC_UNAUTHORIZED) {
|
|
StringBuilder v = new StringBuilder();
|
|
v.append("Digest");
|
|
v.append(" realm=\"" + REALM_NAME + "\"");
|
|
v.append(", domain=\"" + getDomain() + "\"");
|
|
v.append(", qop=\"auth\"");
|
|
if (stale != null) {
|
|
v.append(", stale=" + stale);
|
|
}
|
|
v.append(", nonce=\"" + newNonce() + "\"");
|
|
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
|
|
public void setStatus(int sc, String sm) {
|
|
status(sc);
|
|
super.setStatus(sc, sm);
|
|
}
|
|
|
|
@Override
|
|
public void setStatus(int sc) {
|
|
status(sc);
|
|
super.setStatus(sc);
|
|
}
|
|
}
|
|
}
|