| // Copyright (C) 2009 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.httpd.CacheBasedWebSession.MAX_AGE_MINUTES; | |
| import static com.google.gerrit.server.ioutil.BasicSerialization.readFixInt64; | |
| import static com.google.gerrit.server.ioutil.BasicSerialization.readString; | |
| import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32; | |
| import static com.google.gerrit.server.ioutil.BasicSerialization.writeBytes; | |
| import static com.google.gerrit.server.ioutil.BasicSerialization.writeFixInt64; | |
| import static com.google.gerrit.server.ioutil.BasicSerialization.writeString; | |
| import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32; | |
| import static com.google.gerrit.server.util.time.TimeUtil.nowMs; | |
| import static java.util.concurrent.TimeUnit.HOURS; | |
| import static java.util.concurrent.TimeUnit.MILLISECONDS; | |
| import static java.util.concurrent.TimeUnit.MINUTES; | |
| import static java.util.concurrent.TimeUnit.SECONDS; | |
| import com.google.common.cache.Cache; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.common.Nullable; | |
| import com.google.gerrit.entities.Account; | |
| import com.google.gerrit.server.account.externalids.ExternalId; | |
| import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory; | |
| import com.google.gerrit.server.config.ConfigUtil; | |
| import com.google.gerrit.server.config.GerritServerConfig; | |
| import com.google.inject.Inject; | |
| import com.google.inject.assistedinject.Assisted; | |
| import java.io.ByteArrayOutputStream; | |
| import java.io.IOException; | |
| import java.io.ObjectInputStream; | |
| import java.io.ObjectOutputStream; | |
| import java.io.Serializable; | |
| import java.security.SecureRandom; | |
| import java.util.concurrent.TimeUnit; | |
| import org.eclipse.jgit.lib.Config; | |
| public class WebSessionManager { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| public static final String CACHE_NAME = "web_sessions"; | |
| private final long sessionMaxAgeMillis; | |
| private final SecureRandom prng; | |
| private final Cache<String, Val> self; | |
| @Inject | |
| WebSessionManager(@GerritServerConfig Config cfg, @Assisted Cache<String, Val> cache) { | |
| prng = new SecureRandom(); | |
| self = cache; | |
| sessionMaxAgeMillis = | |
| SECONDS.toMillis( | |
| ConfigUtil.getTimeUnit( | |
| cfg, | |
| "cache", | |
| CACHE_NAME, | |
| "maxAge", | |
| SECONDS.convert(MAX_AGE_MINUTES, MINUTES), | |
| SECONDS)); | |
| if (sessionMaxAgeMillis < MINUTES.toMillis(5)) { | |
| logger.atWarning().log( | |
| "cache.%s.maxAge is set to %d milliseconds; it should be at least 5 minutes.", | |
| CACHE_NAME, sessionMaxAgeMillis); | |
| } | |
| } | |
| Key createKey(Account.Id who) { | |
| return new Key(newUniqueToken(who)); | |
| } | |
| private String newUniqueToken(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, (int) Val.serialVersionUID); | |
| writeVarInt32(buf, who.get()); | |
| writeBytes(buf, rnd); | |
| return CookieBase64.encode(buf.toByteArray()); | |
| } catch (IOException e) { | |
| throw new RuntimeException("Cannot produce new account cookie", e); | |
| } | |
| } | |
| Val createVal(Key key, Val val) { | |
| Account.Id who = val.getAccountId(); | |
| boolean remember = val.isPersistentCookie(); | |
| ExternalId.Key lastLogin = val.getExternalId(); | |
| return createVal(key, who, remember, lastLogin, val.sessionId, val.auth); | |
| } | |
| Val createVal( | |
| Key key, | |
| Account.Id who, | |
| boolean remember, | |
| ExternalId.Key lastLogin, | |
| String sid, | |
| String auth) { | |
| // 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 = sessionMaxAgeMillis >>> 1; | |
| final long minRefresh = MILLISECONDS.convert(1, HOURS); | |
| final long refresh = Math.min(halfAgeRefresh, minRefresh); | |
| final long now = nowMs(); | |
| final long refreshCookieAt = now + refresh; | |
| final long expiresAt = now + sessionMaxAgeMillis; | |
| if (sid == null) { | |
| sid = newUniqueToken(who); | |
| } | |
| if (auth == null) { | |
| auth = newUniqueToken(who); | |
| } | |
| Val val = new Val(who, refreshCookieAt, remember, lastLogin, expiresAt, sid, auth); | |
| self.put(key.token, val); | |
| return val; | |
| } | |
| int getCookieAge(Val val) { | |
| if (val.isPersistentCookie()) { | |
| // Client may store the cookie until we would remove it from our | |
| // own cache, after which it will certainly be invalid. | |
| // | |
| return (int) MILLISECONDS.toSeconds(sessionMaxAgeMillis); | |
| } | |
| // 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; | |
| } | |
| @Nullable | |
| Val get(Key key) { | |
| Val val = self.getIfPresent(key.token); | |
| if (val != null && val.expiresAt <= nowMs()) { | |
| self.invalidate(key.token); | |
| return null; | |
| } | |
| return val; | |
| } | |
| void destroy(Key key) { | |
| self.invalidate(key.token); | |
| } | |
| static final class Key { | |
| private transient String token; | |
| Key(String t) { | |
| token = t; | |
| } | |
| String getToken() { | |
| return token; | |
| } | |
| @Override | |
| public int hashCode() { | |
| return token.hashCode(); | |
| } | |
| @Override | |
| public boolean equals(Object obj) { | |
| return obj instanceof Key && token.equals(((Key) obj).token); | |
| } | |
| } | |
| public static final class Val implements Serializable { | |
| static final long serialVersionUID = 2L; | |
| @Inject private static transient ExternalIdKeyFactory externalIdKeyFactory; | |
| private transient Account.Id accountId; | |
| private transient long refreshCookieAt; | |
| private transient boolean persistentCookie; | |
| private transient ExternalId.Key externalId; | |
| private transient long expiresAt; | |
| private transient String sessionId; | |
| private transient String auth; | |
| Val( | |
| Account.Id accountId, | |
| long refreshCookieAt, | |
| boolean persistentCookie, | |
| ExternalId.Key externalId, | |
| long expiresAt, | |
| String sessionId, | |
| String auth) { | |
| this.accountId = accountId; | |
| this.refreshCookieAt = refreshCookieAt; | |
| this.persistentCookie = persistentCookie; | |
| this.externalId = externalId; | |
| this.expiresAt = expiresAt; | |
| this.sessionId = sessionId; | |
| this.auth = auth; | |
| } | |
| public long getExpiresAt() { | |
| return expiresAt; | |
| } | |
| /** | |
| * Parse an Account.Id. | |
| * | |
| * <p>This is public so that plugins that implement a web session, can also implement a way to | |
| * clear per user sessions. | |
| * | |
| * @return account ID. | |
| */ | |
| public Account.Id getAccountId() { | |
| return accountId; | |
| } | |
| ExternalId.Key getExternalId() { | |
| return externalId; | |
| } | |
| String getSessionId() { | |
| return sessionId; | |
| } | |
| String getAuth() { | |
| return auth; | |
| } | |
| boolean needsCookieRefresh() { | |
| return refreshCookieAt <= nowMs(); | |
| } | |
| boolean isPersistentCookie() { | |
| return persistentCookie; | |
| } | |
| private void writeObject(ObjectOutputStream out) throws IOException { | |
| writeVarInt32(out, 1); | |
| writeVarInt32(out, accountId.get()); | |
| writeVarInt32(out, 2); | |
| writeFixInt64(out, refreshCookieAt); | |
| writeVarInt32(out, 3); | |
| writeVarInt32(out, persistentCookie ? 1 : 0); | |
| if (externalId != null) { | |
| writeVarInt32(out, 4); | |
| writeString(out, externalId.toString()); | |
| } | |
| if (sessionId != null) { | |
| writeVarInt32(out, 5); | |
| writeString(out, sessionId); | |
| } | |
| writeVarInt32(out, 6); | |
| writeFixInt64(out, expiresAt); | |
| if (auth != null) { | |
| writeVarInt32(out, 7); | |
| writeString(out, auth); | |
| } | |
| writeVarInt32(out, 0); | |
| } | |
| private void readObject(ObjectInputStream in) throws IOException { | |
| PARSE: | |
| for (; ; ) { | |
| final int tag = readVarInt32(in); | |
| switch (tag) { | |
| case 0: | |
| break PARSE; | |
| case 1: | |
| accountId = Account.id(readVarInt32(in)); | |
| continue; | |
| case 2: | |
| refreshCookieAt = readFixInt64(in); | |
| continue; | |
| case 3: | |
| persistentCookie = readVarInt32(in) != 0; | |
| continue; | |
| case 4: | |
| externalId = externalIdKeyFactory.parse(readString(in)); | |
| continue; | |
| case 5: | |
| sessionId = readString(in); | |
| continue; | |
| case 6: | |
| expiresAt = readFixInt64(in); | |
| continue; | |
| case 7: | |
| auth = readString(in); | |
| continue; | |
| default: | |
| throw new IOException("Unknown tag found in object: " + tag); | |
| } | |
| } | |
| if (expiresAt == 0) { | |
| expiresAt = refreshCookieAt + TimeUnit.HOURS.toMillis(2); | |
| } | |
| } | |
| } | |
| } |