| // 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); |
| } |
| } |
| } |