| // 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 com.google.gerrit.server.ioutil.BasicSerialization.writeBytes; |
| import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32; |
| import static com.google.inject.Scopes.SINGLETON; |
| import static java.util.concurrent.TimeUnit.HOURS; |
| import static java.util.concurrent.TimeUnit.MILLISECONDS; |
| import static java.util.concurrent.TimeUnit.SECONDS; |
| |
| import com.google.gerrit.reviewdb.Account; |
| import com.google.gerrit.reviewdb.AccountExternalId; |
| import com.google.gerrit.reviewdb.ActiveSession; |
| import com.google.gerrit.reviewdb.ReviewDb; |
| import com.google.gerrit.server.AccessPath; |
| import com.google.gerrit.server.AnonymousUser; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.account.AuthResult; |
| import com.google.gerrit.server.cache.Cache; |
| import com.google.gerrit.server.cache.CacheModule; |
| import com.google.gerrit.server.cache.EvictionPolicy; |
| import com.google.gerrit.server.util.FutureUtil; |
| import com.google.gwtorm.client.OrmException; |
| import com.google.inject.Inject; |
| import com.google.inject.Module; |
| import com.google.inject.Singleton; |
| import com.google.inject.TypeLiteral; |
| import com.google.inject.name.Named; |
| import com.google.inject.servlet.RequestScoped; |
| |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.ByteArrayOutputStream; |
| import java.io.IOException; |
| import java.security.SecureRandom; |
| import java.sql.Timestamp; |
| import java.util.Arrays; |
| import java.util.concurrent.TimeUnit; |
| |
| import javax.servlet.http.Cookie; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| |
| @RequestScoped |
| public final class WebSession { |
| private static final Logger log = LoggerFactory.getLogger(WebSession.class); |
| private static final String ACCOUNT_COOKIE = "GerritAccount"; |
| static final String CACHE_NAME = "web_sessions"; |
| private static final long UPDATE_WAIT_MILLISECONDS = |
| MILLISECONDS.convert(5, TimeUnit.MINUTES); |
| |
| static Module module() { |
| return new CacheModule() { |
| @Override |
| protected void configure() { |
| final TypeLiteral<Cache<ActiveSession.Key, ActiveSession>> type = |
| new TypeLiteral<Cache<ActiveSession.Key, ActiveSession>>() {}; |
| cache(type, CACHE_NAME) // |
| .memoryLimit(1024) // reasonable default for many sites |
| .maxAge(12, HOURS) // expire sessions if they are inactive |
| .evictionPolicy(EvictionPolicy.LRU) // keep most recently used |
| ; |
| bind(WebSession.class).in(RequestScoped.class); |
| bind(WebSession.KeyGenerator.class).in(SINGLETON); |
| } |
| }; |
| } |
| |
| // We want a singleton SecureRandom, but we don't want to make every |
| // SecureRandom a singleton, so instead we have a KeyGenerator class that can |
| // be used in its place. |
| @Singleton |
| private static class KeyGenerator extends SecureRandom { |
| } |
| |
| private static long now() { |
| return System.currentTimeMillis(); |
| } |
| |
| private final KeyGenerator prng; |
| private final Cache<ActiveSession.Key, ActiveSession> cache; |
| private final HttpServletRequest request; |
| private final HttpServletResponse response; |
| private final AnonymousUser anonymous; |
| private final IdentifiedUser.RequestFactory identified; |
| private final ReviewDb schema; |
| private AccessPath accessPath = AccessPath.WEB_UI; |
| private Cookie outCookie; |
| |
| private ActiveSession.Key key; |
| private ActiveSession session; |
| |
| @Inject |
| WebSession(final HttpServletRequest request, |
| final HttpServletResponse response, final AnonymousUser anonymous, |
| final IdentifiedUser.RequestFactory identified, ReviewDb schema, |
| @Named(CACHE_NAME) final Cache<ActiveSession.Key, ActiveSession> cache, |
| KeyGenerator prng) throws OrmException { |
| this.request = request; |
| this.response = response; |
| this.anonymous = anonymous; |
| this.identified = identified; |
| this.schema = schema; |
| this.cache = cache; |
| this.prng = prng; |
| |
| final String cookie = readCookie(); |
| if (cookie != null) { |
| key = new ActiveSession.Key(cookie); |
| session = get(key); |
| } else { |
| key = null; |
| session = null; |
| } |
| |
| if (isSignedIn() && session.needsCookieRefresh()) { |
| // Cookie is more than half old. Send the cookie again to the |
| // client with an updated expiration date. We don't dare to |
| // change the key token here because there may be other RPCs |
| // queued up in the browser whose xsrfKey would not get updated |
| // with the new token, causing them to fail. |
| // |
| session = createSession(key, session); |
| saveCookie(); |
| } |
| } |
| |
| private String readCookie() { |
| final Cookie[] all = request.getCookies(); |
| if (all != null) { |
| for (final Cookie c : all) { |
| if (ACCOUNT_COOKIE.equals(c.getName())) { |
| final String v = c.getValue(); |
| return v != null && !"".equals(v) ? v : null; |
| } |
| } |
| } |
| return null; |
| } |
| |
| public boolean isSignedIn() { |
| return session != null; |
| } |
| |
| public String getToken() { |
| return isSignedIn() ? session.getXsrfToken() : null; |
| } |
| |
| public boolean isTokenValid(final String inputToken) { |
| return isSignedIn() // |
| && session.getXsrfToken() != null // |
| && session.getXsrfToken().equals(inputToken); |
| } |
| |
| public AccountExternalId.Key getLastLoginExternalId() { |
| return session != null ? session.getExternalId() : null; |
| } |
| |
| CurrentUser getCurrentUser() { |
| if (isSignedIn()) { |
| return identified.create(accessPath, session.getAccountId()); |
| } |
| return anonymous; |
| } |
| |
| public void login(final AuthResult res, final boolean rememberMe) |
| throws OrmException { |
| final Account.Id id = res.getAccountId(); |
| final AccountExternalId.Key identity = res.getExternalId(); |
| |
| if (session != null) { |
| destroy(key); |
| key = null; |
| session = null; |
| } |
| |
| key = createKey(id); |
| session = createSession(key, id, rememberMe, identity, null); |
| saveCookie(); |
| } |
| |
| /** Change the access path from the default of {@link AccessPath#WEB_UI}. */ |
| void setAccessPath(AccessPath path) { |
| accessPath = path; |
| } |
| |
| /** Set the user account for this current request only. */ |
| void setUserAccountId(Account.Id id) { |
| key = new ActiveSession.Key("id:" + id); |
| session = new ActiveSession(key, id, new Timestamp(0), false, null, ""); |
| } |
| |
| public void logout() { |
| if (session != null) { |
| try { |
| destroy(key); |
| } catch (OrmException e) { |
| log.error("Could not remove session key from cache", e); |
| } |
| key = null; |
| session = null; |
| saveCookie(); |
| } |
| } |
| |
| private void saveCookie() { |
| final String token; |
| final int ageSeconds; |
| |
| if (key == null) { |
| token = ""; |
| ageSeconds = 0 /* erase at client */; |
| } else { |
| token = key.get(); |
| ageSeconds = getCookieAge(session); |
| } |
| |
| String path = request.getContextPath(); |
| if (path.equals("")) { |
| path = "/"; |
| } |
| |
| if (outCookie != null) { |
| throw new IllegalStateException("Cookie " + ACCOUNT_COOKIE + " was set"); |
| } |
| |
| outCookie = new Cookie(ACCOUNT_COOKIE, token); |
| outCookie.setSecure(isSecure(request)); |
| outCookie.setPath(path); |
| outCookie.setMaxAge(ageSeconds); |
| response.addCookie(outCookie); |
| } |
| |
| private static boolean isSecure(final HttpServletRequest req) { |
| return req.isSecure() || "https".equals(req.getScheme()); |
| } |
| |
| private ActiveSession.Key createKey(final Account.Id who) { |
| try { |
| final int nonceLen = 20; |
| final ByteArrayOutputStream buf; |
| final byte[] rnd = new byte[nonceLen]; |
| prng.nextBytes(rnd); |
| |
| buf = new ByteArrayOutputStream(3 + nonceLen); |
| writeVarInt32(buf, who.get()); |
| writeBytes(buf, rnd); |
| |
| return new ActiveSession.Key(CookieBase64.encode(buf.toByteArray())); |
| } catch (IOException e) { |
| throw new RuntimeException("Cannot produce new account cookie", e); |
| } |
| } |
| |
| private ActiveSession createSession(final ActiveSession.Key key, |
| final ActiveSession session) throws OrmException { |
| final Account.Id who = session.getAccountId(); |
| final boolean remember = session.isPersistentCookie(); |
| final AccountExternalId.Key lastLogin = session.getExternalId(); |
| final String xsrfToken = session.getXsrfToken(); |
| |
| return createSession(key, who, remember, lastLogin, xsrfToken); |
| } |
| |
| private ActiveSession createSession(final ActiveSession.Key key, |
| final Account.Id who, final boolean remember, |
| final AccountExternalId.Key lastLogin, String xsrfToken) |
| throws OrmException { |
| // Refresh the cookie every hour or when it is half-expired. |
| // This reduces the odds that the user session will be kicked |
| // early but also avoids us needing to refresh the cookie on |
| // every single request. |
| // |
| final long halfAgeRefresh = cache.getTimeToLive(MILLISECONDS) >>> 1; |
| final long minRefresh = MILLISECONDS.convert(1, HOURS); |
| final long refresh = Math.min(halfAgeRefresh, minRefresh); |
| final long refreshCookieAt = now() + refresh; |
| |
| if (xsrfToken == null) { |
| // If we don't yet have a token for this session, establish one. |
| // |
| final int nonceLen = 20; |
| final ByteArrayOutputStream buf; |
| final byte[] rnd = new byte[nonceLen]; |
| prng.nextBytes(rnd); |
| xsrfToken = CookieBase64.encode(rnd); |
| } |
| |
| ActiveSession session = |
| new ActiveSession(key, who, new Timestamp(refreshCookieAt), remember, |
| lastLogin, xsrfToken); |
| put(session); |
| return session; |
| } |
| |
| private int getCookieAge(final ActiveSession session) { |
| if (session.isPersistentCookie()) { |
| // Client may store the cookie until we would remove it from our |
| // own cache, after which it will certainly be invalid. |
| // |
| return (int) cache.getTimeToLive(SECONDS); |
| } else { |
| // Client should not store the cookie, as the user asked for us |
| // to not remember them long-term. Sending -1 as the age will |
| // cause the cookie to be only for this "browser session", which |
| // is usually until the user exits their browser. |
| // |
| return -1; |
| } |
| } |
| |
| private ActiveSession get(final ActiveSession.Key key) throws OrmException { |
| ActiveSession as = FutureUtil.get(cache.get(key)); |
| if (as == null) { |
| as = schema.activeSessions().get(key); |
| |
| if (as == null) { |
| return null; |
| } else { |
| if (expiredFromCache(as)) { |
| destroy(key); |
| return null; |
| } else if (needsCacheRefresh(as)) { |
| as.updateLastSeen(); |
| put(as); |
| } |
| return as; |
| } |
| } else { |
| if (needsCacheRefresh(as)) { |
| as.updateLastSeen(); |
| put(as); |
| } |
| return as; |
| } |
| } |
| |
| private boolean needsCacheRefresh(ActiveSession as) { |
| if (as.getLastSeen() == null) { |
| return true; |
| } |
| Timestamp refreshAt = |
| new Timestamp(as.getLastSeen().getTime() + UPDATE_WAIT_MILLISECONDS); |
| Timestamp now = new Timestamp(now()); |
| |
| return now.after(refreshAt); |
| } |
| |
| private boolean expiredFromCache(ActiveSession as) { |
| if (as.getLastSeen() == null) { |
| return true; |
| } |
| Timestamp expireAt = |
| new Timestamp(as.getLastSeen().getTime() |
| + cache.getTimeToLive(MILLISECONDS)); |
| Timestamp now = new Timestamp(now()); |
| |
| return now.after(expireAt); |
| } |
| |
| private void destroy(final ActiveSession.Key key) throws OrmException { |
| schema.activeSessions().deleteKeys(Arrays.asList(key)); |
| FutureUtil.waitFor(cache.removeAsync(key)); |
| } |
| |
| private void put(final ActiveSession as) throws OrmException { |
| schema.activeSessions().upsert(Arrays.asList(as)); |
| FutureUtil.waitFor(cache.putAsync(as.getKey(), as)); |
| } |
| } |