Java tutorial
/* * Portions Copyright (c) 2009-10 Zepheira LLC, Some Rights Reserved * Portions Copyright (c) 2010-11 Talis Inc, Some Rights Reserved * * 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 org.callimachusproject.auth; import static org.callimachusproject.util.PercentCodec.encode; import info.aduna.net.ParsedURI; import java.io.IOException; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.httpclient.util.DateParseException; import org.apache.commons.httpclient.util.DateUtil; import org.apache.http.HttpEntity; import org.apache.http.HttpMessage; import org.apache.http.HttpResponse; import org.apache.http.HttpVersion; import org.apache.http.entity.StringEntity; import org.apache.http.message.BasicHttpResponse; import org.apache.http.message.BasicStatusLine; import org.callimachusproject.server.exceptions.BadRequest; import org.callimachusproject.server.exceptions.TooManyRequests; import org.callimachusproject.traits.CalliObject; import org.openrdf.OpenRDFException; import org.openrdf.model.Resource; import org.openrdf.model.URI; import org.openrdf.repository.object.ObjectConnection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Validates HTTP digest authorization. */ public class DigestAuthenticationManager implements DetachedAuthenticationManager { private static final Pattern TOKENS_REGEX = Pattern.compile( "\\s*([\\w\\!\\#\\$\\%\\&\\'\\*\\+\\-\\.\\^\\_\\`\\~]+)(?:\\s*=\\s*(?:\"([^\"]*)\"|([^,\"]*)))?\\s*,?"); private static final int MAX_NONCE_AGE = 300000; // nonce timeout of 5min private static final BasicStatusLine _401 = new BasicStatusLine(HttpVersion.HTTP_1_1, 401, "Unauthorized"); private static final BasicStatusLine _204 = new BasicStatusLine(HttpVersion.HTTP_1_1, 204, "No Content"); private static final Map<String, String> DIGEST_OPTS = new HashMap<String, String>(); static { DIGEST_OPTS.put("realm", null); DIGEST_OPTS.put("nonce", null); DIGEST_OPTS.put("username", null); DIGEST_OPTS.put("uri", null); DIGEST_OPTS.put("qop", null); DIGEST_OPTS.put("cnonce", null); DIGEST_OPTS.put("nc", null); DIGEST_OPTS.put("response", null); } private final Logger logger = LoggerFactory.getLogger(DigestAuthenticationManager.class); private final String authName; private final String protectedDomains; private final String protectedPath; private final List<String> domains; private final FailManager fail = new FailManager(); private final Set<String> userCookies = new LinkedHashSet<String>(); private final DigestAccessor accessor; public DigestAuthenticationManager(String authName, String path, List<String> domains, DigestAccessor accessor) { assert authName != null; assert accessor != null; this.authName = authName; this.protectedPath = path; assert domains != null; assert domains.size() > 0; this.domains = domains; Set<Integer> ports = new HashSet<Integer>(); StringBuilder sb = new StringBuilder(); for (String domain : domains) { sb.append(' ').append(domain); int port = java.net.URI.create(domain).getPort(); ports.add(port); StringBuilder suffix = new StringBuilder(); if (port > 0) { suffix.append(port); } if (domain.startsWith("https")) { suffix.append('s'); } suffix.append('='); userCookies.add("username" + suffix); } this.protectedDomains = sb.substring(1); this.accessor = accessor; } @Override public String toString() { return getDigestAccessor().getIdentifier(); } @Override public String getIdentifier() { return getDigestAccessor().getIdentifier(); } public DigestAccessor getDigestAccessor() { return accessor; } public boolean isProtected(String url) { for (String domain : domains) { if (url.startsWith(domain)) return true; } return false; } @Override public HttpResponse unauthorized(String method, Object resource, Map<String, String[]> request, HttpEntity body) throws IOException { String nonce = nextNonce(resource, request.get("via")); String authenticate = "Digest realm=\"" + authName + "\"" + ", domain=\"" + protectedDomains + "\"" + ", nonce=\"" + nonce + "\", algorithm=\"MD5\", qop=\"auth\""; Map<String, String> options = parseDigestAuthorization(request); HttpResponse resp; if (options == null && isUserLoggedIn(request)) { resp = new BasicHttpResponse(_401); resp.setHeader("Content-Type", "text/plain;charset=\"UTF-8\""); try { resp.setEntity(new StringEntity("Authorization header required", "UTF-8")); } catch (Exception e) { // UnsupportedEncodingException throw new AssertionError(e); } } else if (options != null && !isRecentDigest(resource, request, options)) { resp = new BasicHttpResponse(_401); resp.setHeader("WWW-Authenticate", authenticate + ",stale=true"); resp.setHeader("Content-Type", "text/plain;charset=\"UTF-8\""); try { resp.setEntity(new StringEntity("Stale authorization header", "UTF-8")); } catch (Exception e) { // UnsupportedEncodingException throw new AssertionError(e); } } else { String url = getRequestUrl(request); String[] via = request.get("via"); Collection<String> cookie = asList(request.get("cookie")); if (options == null) { resp = accessor.getNotLoggedInResponse(method, url, via, cookie, body); } else { resp = accessor.getBadCredentialResponse(method, url, via, cookie, body); } } if (!resp.containsHeader("Cache-Control")) { resp.setHeader("Cache-Control", "no-store"); } if (resp.getStatusLine().getStatusCode() == 401 && !resp.containsHeader("WWW-Authenticate")) { resp.setHeader("WWW-Authenticate", authenticate); } return resp; } @Override public HttpMessage authenticationInfo(String method, Object resource, Map<String, String[]> request, ObjectConnection con) throws OpenRDFException, IOException { Map<String, String> auth = parseDigestAuthorization(request); if (auth == null) return null; Map.Entry<String, String> password = findAuthUser(method, resource, request, auth, con); if (password == null) return null; String cnonce = auth.get("cnonce"); String nc = auth.get("nc"); String uri = auth.get("uri"); String nonce = auth.get("nonce"); String ha1 = password.getKey(); String ha2 = md5(":" + uri); String rspauth = md5(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":auth:" + ha2); String authenticate = "qop=auth,cnonce=\"" + cnonce + "\",nc=" + nc + ",rspauth=\"" + rspauth + "\""; BasicHttpResponse resp = new BasicHttpResponse(_204); resp.addHeader("Authentication-Info", authenticate); return resp; } @Override public String authenticateRequest(String method, Object resource, Map<String, String[]> request, ObjectConnection con) { try { Map<String, String> options = parseDigestAuthorization(request); if (options == null) return null; String username = options.get("username"); int retryAfter = fail.retryAfter(username); if (retryAfter > 0) { logger.warn("Account {} is locked for {} seconds", username, retryAfter); int min = (int) Math.ceil(retryAfter / 60.0); throw new TooManyRequests("User Account is Locked\nTry again in " + min + " minutes", retryAfter); } Map.Entry<String, String> password = findAuthUser(method, resource, request, options, con); if (password == null) { if (isRecentDigest(resource, request, options)) { fail.failedAttempt(username); } return null; } else { fail.successfulAttempt(username); } if (options.containsKey("qop")) { if (fail.isReplayed(options)) { fail.failedAttempt(username); logger.info("Request replayed {}", options); return null; } } return password.getValue(); } catch (TooManyRequests e) { throw e; } catch (BadRequest e) { throw e; } catch (Exception e) { logger.warn(e.toString(), e); return null; } } @Override public HttpResponse logout(Collection<String> tokens) { for (String token : tokens) { if (token.indexOf("username=\"-\"") > 0) { // # bogus credentials received HttpResponse resp = getDigestAccessor().getLogoutResponse(); for (String userCookie : userCookies) { String secure = userCookie.endsWith("s=") ? ";Secure" : ""; resp.addHeader("Set-Cookie", userCookie + ";Max-Age=0;Path=" + protectedPath + secure); } return resp; } } // # the browser must send invalid credentials to logout BasicHttpResponse resp = new BasicHttpResponse(HttpVersion.HTTP_1_1, 401, "Unauthorized"); String hd = "Digest realm=\"" + authName + "\", domain=\"" + protectedDomains + "\", nonce=\"logout\", algorithm=\"MD5\", qop=\"auth\""; resp.setHeader("WWW-Authenticate", hd); return resp; } public String[] getUsernameSetCookie(Collection<String> tokens, ObjectConnection con) { String username = getUserLogin(tokens, con); if (username == null) return new String[0]; int i = 0; String[] result = new String[userCookies.size()]; for (String userCookie : userCookies) { String secure = userCookie.endsWith("s=") ? ";Secure" : ""; result[i++] = userCookie + encode(username) + ";Path=" + protectedPath + secure; } return result; } public String getUserIdentifier(String method, Collection<String> tokens, ObjectConnection con) throws OpenRDFException, IOException { Map<String, String> options = parseDigestAuthorization(tokens); if (options == null) return null; String username = options.get("username"); if (username == null) throw new BadRequest("Missing username"); Map.Entry<String, String> passwords = findAuthUser(method, options, tokens, con); if (passwords == null) return null; return passwords.getValue(); } public String getUserLogin(Collection<String> tokens, ObjectConnection con) { Map<String, String> options = parseDigestAuthorization(tokens); if (options == null) return null; String username = options.get("username"); if (username == null) throw new BadRequest("Missing username"); return username; } @Override public void registered(Resource invitedUser, URI registeredUser, ObjectConnection con) throws OpenRDFException, IOException { getDigestAccessor().registerUser(invitedUser, registeredUser, con); } private Map.Entry<String, String> findAuthUser(String method, Object resource, Map<String, String[]> request, Map<String, String> auth, ObjectConnection con) throws OpenRDFException, IOException { if (!isRecentDigest(resource, request, auth)) return null; Collection<String> cookies = asList(request.get("cookie")); return findAuthUser(method, auth, cookies, con); } private Map.Entry<String, String> findAuthUser(String method, Map<String, String> auth, Collection<String> cookies, ObjectConnection con) throws OpenRDFException, IOException { String qop = auth.get("qop"); String uri = auth.get("uri"); String nonce = auth.get("nonce"); String username = auth.get("username"); String realm = auth.get("realm"); String response = auth.get("response"); String ha2 = md5(method + ":" + uri); assert username != null; DigestAccessor accessor = getDigestAccessor(); Map<String, String> passwords = accessor.findDigestUser(method, username, realm, cookies, con); if (passwords == null) { logger.debug("Account {} not found in {}", username, getIdentifier()); return null; } for (Map.Entry<String, String> e : passwords.entrySet()) { String ha1 = e.getKey(); String legacy = ha1 + ":" + nonce + ":" + ha2; if (qop == null && md5(legacy).equals(response)) return e; String expected = ha1 + ":" + nonce + ":" + auth.get("nc") + ":" + auth.get("cnonce") + ":" + qop + ":" + ha2; if (md5(expected).equals(response)) return e; } if (passwords.isEmpty()) { logger.info("Missing password for: {}", username); return null; } else { logger.info("Passwords don't match for: {}", username); return null; } } private String nextNonce(Object resource, String[] via) { String ip = hash(via); String revision = getRevisionOf(resource); long now = System.currentTimeMillis(); String time = Long.toString(now, Character.MAX_RADIX); return time + ":" + revision + ":" + ip; } private String getRevisionOf(Object resource) { if (resource instanceof CalliObject) { String revision = ((CalliObject) resource).revision(); if (revision != null) return revision; } return ""; } private Map<String, String> parseDigestAuthorization(Map<String, String[]> request) { return parseDigestAuthorization(asList(request.get("authorization"))); } private Map<String, String> parseDigestAuthorization(Collection<String> authorization) { if (authorization == null) return null; for (String digest : authorization) { if (digest == null || !digest.startsWith("Digest ")) continue; if (digest.indexOf("username=\"-\"") > 0) continue; // bogus String options = digest.substring("Digest ".length()); Map<String, String> result = new HashMap<String, String>(DIGEST_OPTS); Matcher m = TOKENS_REGEX.matcher(options); while (m.find()) { String key = m.group(1); if (result.containsKey(key)) { if (m.group(2) != null) { result.put(key, m.group(2)); } else if (m.group(3) != null) { result.put(key, m.group(3)); } } } return result; } return null; } private String getRequestUrl(Map<String, String[]> request) { String target = request.get("request-target")[0]; if (target.charAt(0) != '/') return target; String scheme = request.get("request-scheme")[0]; String host = request.get("host")[0]; return scheme + "://" + host + target; } private boolean isUserLoggedIn(Map<String, String[]> request) { if (request.get("cookie") == null) return false; for (String cookie : request.get("cookie")) { for (String cookieName : userCookies) { if (cookie.contains(cookieName)) { int start = cookie.indexOf(cookieName) + cookieName.length(); if (start >= cookie.length()) return false; int semi = cookie.indexOf(';', start); int comma = cookie.indexOf(',', start); int end = comma < 0 || semi < comma && semi > 0 ? semi : comma; if (end < 0) { end = cookie.length(); } String value = cookie.substring(start, end).trim(); return value.length() > 0; } } } return false; } private Collection<String> asList(String[] array) { if (array == null) return null; return Arrays.asList(array); } private boolean isRecentDigest(Object target, Map<String, String[]> request, Map<String, String> authorization) { if (authorization == null) return false; String url = request.get("request-target")[0]; String date = request.get("date")[0]; String[] via = request.get("via"); String realm = authorization.get("realm"); String uri = authorization.get("uri"); String username = authorization.get("username"); if (username == null) throw new BadRequest("Missing username"); ParsedURI parsed = new ParsedURI(url); String path = parsed.getPath(); if (parsed.getQuery() != null) { path = path + "?" + parsed.getQuery(); } if (realm == null || !url.equals(uri) && !path.equals(uri)) { logger.info("Bad authorization on {} using {}", url, authorization); throw new BadRequest("Bad Authorization"); } if (!realm.equals(authName)) return false; try { long now = DateUtil.parseDate(date).getTime(); String nonce = authorization.get("nonce"); if (nonce == null) return false; int first = nonce.indexOf(':'); int last = nonce.lastIndexOf(':'); if (first < 0 || last < 0) return false; if (!hash(via).equals(nonce.substring(last + 1))) return false; String revision = nonce.substring(first + 1, last); if (!revision.equals(getRevisionOf(target))) return false; String time = nonce.substring(0, first); Long ms = Long.valueOf(time, Character.MAX_RADIX); long age = now - ms; return age < MAX_NONCE_AGE; } catch (NumberFormatException e) { logger.debug(e.toString(), e); return false; } catch (DateParseException e) { logger.warn(e.toString(), e); return false; } } private String md5(String text) { return new String(Hex.encodeHex(DigestUtils.md5(text))); } private String hash(String... values) { long code = 0; if (values != null) { for (String str : values) { code = code * 31 + str.hashCode(); } } return Long.toString(code, Character.MAX_RADIX); } }