// 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.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 org.eclipse.jgit.lib.Config; 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.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; /** * 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; } 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 (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); } } }