blob: 151d7aa08960215936c45576ae280ccf0ba57fe7 [file] [log] [blame]
// 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));
}
}