/*
* Copyright 2013-2017 the original author or authors.
*
* 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.glowroot.ui;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Iterator;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import javax.annotation.Nullable;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.net.MediaType;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http.cookie.DefaultCookie;
import io.netty.handler.codec.http.cookie.ServerCookieDecoder;
import io.netty.handler.codec.http.cookie.ServerCookieEncoder;
import org.immutables.serial.Serial;
import org.immutables.value.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.glowroot.common.config.LdapConfig;
import org.glowroot.common.config.RoleConfig;
import org.glowroot.common.config.RoleConfig.SimplePermission;
import org.glowroot.common.config.UserConfig;
import org.glowroot.common.repo.ConfigRepository;
import org.glowroot.common.util.Clock;
import org.glowroot.ui.CommonHandler.CommonRequest;
import org.glowroot.ui.CommonHandler.CommonResponse;
import org.glowroot.ui.LdapAuthentication.AuthenticationException;
import static com.google.common.base.Preconditions.checkState;
import static io.netty.handler.codec.http.HttpResponseStatus.OK;
import static java.util.concurrent.TimeUnit.MINUTES;
class HttpSessionManager {
private static final Logger logger = LoggerFactory.getLogger(HttpSessionManager.class);
private static final Logger auditLogger = LoggerFactory.getLogger("audit");
private final boolean central;
private final boolean offline;
private final ConfigRepository configRepository;
private final Clock clock;
private final LayoutService layoutService;
private final SecureRandom secureRandom = new SecureRandom();
private final ConcurrentMap<String, ImmutableSession> sessionMap;
HttpSessionManager(boolean central, boolean offline, ConfigRepository configRepository,
Clock clock, LayoutService layoutService, SessionMapFactory sessionMapFactory) {
this.central = central;
this.offline = offline;
this.configRepository = configRepository;
this.clock = clock;
this.layoutService = layoutService;
this.sessionMap = sessionMapFactory.create();
}
CommonResponse login(String username, String password) throws Exception {
if (username.equalsIgnoreCase("anonymous")) {
auditFailedLogin(username);
return buildIncorrectLoginResponse();
}
UserConfig userConfig = getUserConfigCaseInsensitive(username);
if (userConfig == null || userConfig.ldap()) {
Set<String> roles;
try {
roles = authenticateAgainstLdapAndGetGlowrootRoles(username, password);
} catch (AuthenticationException e) {
logger.debug(e.getMessage(), e);
auditFailedLogin(username);
return buildIncorrectLoginResponse();
}
if (userConfig != null) {
roles = Sets.newHashSet(roles);
roles.addAll(userConfig.roles());
}
if (!roles.isEmpty()) {
return createSession(username, roles, true);
}
} else if (validatePassword(password, userConfig.passwordHash())) {
return createSession(userConfig.username(), userConfig.roles(), false);
}
auditFailedLogin(username);
return buildIncorrectLoginResponse();
}
void signOut(CommonRequest request) throws Exception {
String sessionId = getSessionId(request);
if (sessionId != null) {
Session session = sessionMap.remove(sessionId);
if (session != null) {
auditLogout(session.caseAmbiguousUsername());
}
}
}
void deleteSessionCookie(CommonResponse response) throws Exception {
Cookie cookie = new DefaultCookie(configRepository.getWebConfig().sessionCookieName(), "");
cookie.setHttpOnly(true);
cookie.setMaxAge(0);
cookie.setPath("/");
response.setHeader(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(cookie));
}
Authentication getAuthentication(CommonRequest request, boolean touch) throws Exception {
if (offline) {
return getOfflineViewerAuthentication();
}
String sessionId = getSessionId(request);
if (sessionId == null) {
return getAnonymousAuthentication();
}
Session session = sessionMap.get(sessionId);
if (session == null) {
return getAnonymousAuthentication();
}
long currentTimeMillis = clock.currentTimeMillis();
long timeoutMillis =
MINUTES.toMillis(configRepository.getWebConfig().sessionTimeoutMinutes());
if (session.isTimedOut(currentTimeMillis, timeoutMillis)) {
return getAnonymousAuthentication();
}
if (touch) {
// need to re-put in order to force replication when using clustered central
sessionMap.put(sessionId, ImmutableSession.builder()
.copyFrom(session)
.lastRequest(currentTimeMillis)
.build());
}
return session.createAuthentication(central, configRepository);
}
@Nullable
String getSessionId(CommonRequest request) throws Exception {
String cookieHeader = request.getHeader(HttpHeaderNames.COOKIE);
if (cookieHeader == null) {
return null;
}
Set<Cookie> cookies = ServerCookieDecoder.STRICT.decode(cookieHeader);
for (Cookie cookie : cookies) {
if (cookie.name().equals(configRepository.getWebConfig().sessionCookieName())) {
return cookie.value();
}
}
return null;
}
Authentication getAnonymousAuthentication() throws Exception {
UserConfig userConfig = getUserConfigCaseInsensitive("anonymous");
return ImmutableAuthentication.builder()
.central(central)
.offline(false)
.anonymous(true)
.ldap(false)
.caseAmbiguousUsername("anonymous")
.roles(userConfig == null ? ImmutableSet.<String>of() : userConfig.roles())
.configRepository(configRepository)
.build();
}
private CommonResponse buildIncorrectLoginResponse() {
return new CommonResponse(OK, MediaType.JSON_UTF_8, "{\"incorrectLogin\":true}");
}
private CommonResponse createSession(String username, Set<String> roles, boolean ldap)
throws Exception {
String sessionId = new BigInteger(130, secureRandom).toString(32);
ImmutableSession session = ImmutableSession.builder()
.caseAmbiguousUsername(username)
.ldap(ldap)
.roles(roles)
.lastRequest(clock.currentTimeMillis())
.build();
sessionMap.put(sessionId, session);
String layoutJson = layoutService
.getLayoutJson(session.createAuthentication(central, configRepository));
CommonResponse response = new CommonResponse(OK, MediaType.JSON_UTF_8, layoutJson);
Cookie cookie =
new DefaultCookie(configRepository.getWebConfig().sessionCookieName(), sessionId);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.setHeader(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(cookie));
purgeExpiredSessions();
auditSuccessfulLogin(username);
return response;
}
private Authentication getOfflineViewerAuthentication() {
return ImmutableAuthentication.builder()
.central(false) // offline only applies to embedded
.offline(true)
.anonymous(true)
.ldap(false)
.caseAmbiguousUsername("anonymous")
.configRepository(configRepository)
.build();
}
private @Nullable UserConfig getUserConfigCaseInsensitive(String username) throws Exception {
for (UserConfig userConfig : configRepository.getUserConfigs()) {
if (userConfig.username().equalsIgnoreCase(username)) {
return userConfig;
}
}
return null;
}
private void purgeExpiredSessions() throws Exception {
long currentTimeMillis = clock.currentTimeMillis();
long timeoutMillis =
MINUTES.toMillis(configRepository.getWebConfig().sessionTimeoutMinutes());
Iterator<Entry<String, ImmutableSession>> i = sessionMap.entrySet().iterator();
while (i.hasNext()) {
Session session = i.next().getValue();
if (session.isTimedOut(currentTimeMillis, timeoutMillis)) {
i.remove();
auditSessionTimeout(session.caseAmbiguousUsername());
}
}
}
private Set<String> authenticateAgainstLdapAndGetGlowrootRoles(String username, String password)
throws Exception {
LdapConfig ldapConfig = configRepository.getLdapConfig();
String host = ldapConfig.host();
if (host.isEmpty()) {
throw new AuthenticationException("LDAP is not configured");
}
Set<String> ldapGroupDns = LdapAuthentication.authenticateAndGetLdapGroupDns(username,
password, ldapConfig, null, configRepository.getLazySecretKey());
return LdapAuthentication.getGlowrootRoles(ldapGroupDns, ldapConfig);
}
private void auditFailedLogin(String username) {
auditLogger.info("{} - failed login", username);
}
private void auditSuccessfulLogin(String username) {
auditLogger.info("{} - successful login", username);
}
private void auditLogout(String username) {
auditLogger.info("{} - logout", username);
}
private void auditSessionTimeout(String username) {
auditLogger.info("{} - session timeout", username);
}
private static boolean validatePassword(String password, String passwordHash)
throws GeneralSecurityException {
if (passwordHash.isEmpty()) {
// need special case for empty password
return password.isEmpty();
} else {
return PasswordHash.validatePassword(password, passwordHash);
}
}
@Value.Immutable
@Serial.Structural
abstract static class Session {
abstract String caseAmbiguousUsername(); // the case is exactly as user entered during login
abstract boolean ldap();
abstract Set<String> roles();
abstract long lastRequest();
Authentication createAuthentication(boolean central, ConfigRepository configRepository) {
return ImmutableAuthentication.builder()
.central(central)
.offline(false)
.anonymous(false) // sessions are only for non-anonymous authentication
.ldap(ldap())
.caseAmbiguousUsername(caseAmbiguousUsername())
.roles(roles())
.configRepository(configRepository)
.build();
}
private boolean isTimedOut(long currentTimeMillis, long timeoutMillis) {
return lastRequest() < currentTimeMillis - timeoutMillis;
}
}
@Value.Immutable
abstract static class Authentication {
abstract boolean central();
abstract boolean offline();
abstract boolean anonymous();
abstract boolean ldap();
abstract String caseAmbiguousUsername(); // the case is exactly as user entered during login
abstract Set<String> roles();
abstract ConfigRepository configRepository();
boolean isPermitted(String agentRollupId, String permission) throws Exception {
if (permission.startsWith("agent:")) {
return isAgentPermitted(agentRollupId, permission);
} else {
return isAdminPermitted(permission);
}
}
boolean isAgentPermitted(String agentRollupId, String permission) throws Exception {
checkState(permission.startsWith("agent:"));
if (offline()) {
return !permission.startsWith("agent:config:edit:");
}
if (permission.equals("agent:trace")) {
// special case for now
return isAgentPermitted(agentRollupId, "agent:transaction:traces")
|| isAgentPermitted(agentRollupId, "agent:error:traces");
}
return isPermitted(SimplePermission.create(agentRollupId, permission));
}
boolean isAdminPermitted(String permission) throws Exception {
checkState(permission.startsWith("admin:"));
if (offline()) {
return permission.equals("admin:view") || permission.startsWith("admin:view:");
}
return isPermitted(SimplePermission.create(permission));
}
private boolean isPermitted(SimplePermission permission) throws Exception {
for (RoleConfig roleConfig : configRepository().getRoleConfigs()) {
if (roles().contains(roleConfig.name()) && roleConfig.isPermitted(permission)) {
return true;
}
}
return false;
}
}
}