CookieVerifier.java
/*
* Copyright (c) 2007-2017 MetaSolutions AB
*
* 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.entrystore.rest.auth;
import org.apache.commons.lang3.RandomStringUtils;
import org.entrystore.Entry;
import org.entrystore.PrincipalManager;
import org.entrystore.config.Config;
import org.entrystore.repository.RepositoryManager;
import org.entrystore.repository.config.Settings;
import org.entrystore.rest.EntryStoreApplication;
import org.entrystore.rest.auth.CustomCookieSettings.SameSite;
import org.entrystore.rest.filter.CORSFilter;
import org.entrystore.rest.util.HttpUtil;
import org.restlet.Request;
import org.restlet.Response;
import org.restlet.data.Cookie;
import org.restlet.data.CookieSetting;
import org.restlet.security.Verifier;
import org.restlet.util.Series;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.time.Duration;
import java.time.LocalDateTime;
import static org.entrystore.repository.config.Settings.AUTH_COOKIE_INVALID_TOKEN_ERROR;
import static org.entrystore.repository.config.Settings.AUTH_COOKIE_PATH;
/**
* @author Hannes Ebner
*/
public class CookieVerifier implements Verifier {
private static final Logger log = LoggerFactory.getLogger(CookieVerifier.class);
private static CustomCookieSettings cookieSettings;
private final PrincipalManager pm;
private final RepositoryManager rm;
private final CORSFilter corsFilter;
private final boolean configInvalidTokenError;
private final LoginTokenCache loginTokenCache;
public CookieVerifier(EntryStoreApplication app, RepositoryManager rm) {
this(app, rm, null);
}
public CookieVerifier(EntryStoreApplication app, RepositoryManager rm, CORSFilter corsFilter) {
this.rm = rm;
this.pm = rm.getPrincipalManager();
this.corsFilter = corsFilter;
Config config = rm.getConfiguration();
this.configInvalidTokenError = config.getBoolean(AUTH_COOKIE_INVALID_TOKEN_ERROR, true);
this.loginTokenCache = app.getLoginTokenCache();
if (cookieSettings == null) {
SameSite sameSite = SameSite.Strict;
try {
String sameSiteStr = config.getString(Settings.AUTH_COOKIE_SAMESITE, SameSite.Strict.name()).toLowerCase();
if (sameSiteStr.length() > 1) {
sameSiteStr = sameSiteStr.substring(0, 1).toUpperCase() + sameSiteStr.substring(1);
}
sameSite = SameSite.valueOf(sameSiteStr);
} catch (IllegalArgumentException iae) {
log.warn("Invalid value for setting " + Settings.AUTH_COOKIE_SAMESITE + ": " + iae.getMessage());
}
cookieSettings = new CustomCookieSettings(
config.getBoolean(Settings.AUTH_COOKIE_SECURE, true),
config.getBoolean(Settings.AUTH_COOKIE_HTTPONLY, true),
sameSite);
}
}
@Override
public int verify(Request request, Response response) {
// to avoid an override of an already existing authentication, e.g. from BasicVerifier
URI authUser = pm.getAuthenticatedUserURI();
if (authUser != null && !pm.getGuestUser().getURI().equals(authUser)) {
return RESULT_VALID;
}
URI userURI = null;
try {
pm.setAuthenticatedUserURI(pm.getAdminUser().getURI());
String authToken = getAuthToken(request);
if (authToken != null) {
UserInfo ui = loginTokenCache.registerUserInteraction(authToken, request);
if (ui != null) {
String userName = ui.getUserName();
Entry userEntry = pm.getPrincipalEntry(userName);
if (userEntry != null) {
userURI = userEntry.getResourceURI();
} else {
log.error("Auth token maps to non-existing user, removing token");
loginTokenCache.removeToken(authToken);
cleanCookies(rm,"auth_token", request, response);
}
} else {
log.debug("Auth token not found in token cache");
cleanCookies(rm,"auth_token", request, response);
// CORS needs to be handled here, because we return a RESULT_INVALID which
// interrupts the filter chain before the CORS filter can do its work
if (corsFilter != null) {
corsFilter.addCorsHeader(request, response);
}
if (configInvalidTokenError) {
return RESULT_INVALID;
}
}
}
if (userURI == null) {
userURI = pm.getGuestUser().getURI();
return RESULT_VALID;
}
return RESULT_VALID;
} finally {
pm.setAuthenticatedUserURI(userURI);
}
}
public static void cleanCookies(RepositoryManager rm, String cookieName, Request request, Response response) {
response.getCookieSettings().removeAll(cookieName);
Series<Cookie> cookies = request.getCookies();
for (Cookie c : cookies) {
if (c.getName().equals(cookieName)) {
// The following is a hack, explained in createAuthToken() below
String value = c.getValue();
value += "; Max-Age=0; ";
value += cookieSettings.toString();
CookieSetting cs = new CookieSetting(c.getVersion(), c.getName(), value, getCookiePath(rm), null);
cs.setMaxAge(0);
response.getCookieSettings().add(cs);
}
}
}
public void createAuthToken(String userName, String maxAgeStr, Request request, Response response) {
// Generates a random string without the '+' and '/' chars used in Base64, which can
// cause problems if this string is ever needed to be UUDecoded or sent over http,
// as in a link or in a form.
String token = RandomStringUtils.random(128, true, true);
Config config = rm.getConfiguration();
int maxAge = loginTokenCache.MAX_AGE_IN_SECONDS;
if (maxAgeStr != null) {
try {
maxAge = Math.min(maxAge, Integer.parseInt(maxAgeStr));
} catch (NumberFormatException ignored) {}
}
UserInfo userInfo = new UserInfo(userName, LocalDateTime.now(), maxAge);
userInfo.setLastAccessTime(userInfo.getLoginTime());
userInfo.setLoginExpiration(userInfo.getLoginTime().plusSeconds(maxAge));
userInfo.setLastUsedUserAgent(request.getClientInfo().getAgent());
userInfo.setLastUsedIpAddress(HttpUtil.getClientIpAddress(request));
loginTokenCache.putToken(token, userInfo);
log.debug("User [{}] receives authentication token [{}]", userName, userInfo);
// We hack the mechanism and set additional properties as part of the Cookie value since
// there is no direct way to set properties such as Max-Age, SameSite, etc.
// This works since Restlet does not parse or process the value; this hack might break in the future.
// We only set Max-Age for positive values; omission of Max-Age and Expires makes it a session cookie.
if (maxAge >= 0) {
if (loginTokenCache.isTokenUpdateExpiry()) {
// we set a long duration because the token's expiration will be extended upon access, but it would
// be difficult to always send a "set-cookie" header to also extend the cookie's lifetime.
maxAge = (int) Duration.ofDays(365).toSeconds();
}
token += "; Max-Age=" + maxAge;
}
token += "; " + cookieSettings;
CookieSetting tokenCookieSetting = new CookieSetting(0, "auth_token", token);
tokenCookieSetting.setPath(getCookiePath(rm));
response.getCookieSettings().add(tokenCookieSetting);
}
private static String getCookiePath(RepositoryManager rm) {
String cookiePath = rm.getConfiguration().getString(AUTH_COOKIE_PATH, "auto");
if ("auto".equalsIgnoreCase(cookiePath)) {
return rm.getRepositoryURL().getPath();
}
return cookiePath;
}
public static String getAuthToken(Request request) {
Cookie authTokenCookie = request.getCookies().getFirst("auth_token");
if (authTokenCookie != null) {
return authTokenCookie.getValue();
}
return null;
}
}