/* * Copyright (c) 2013 EMC Corporation * All Rights Reserved */ package com.emc.storageos.auth.service.impl.resource; import java.io.*; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; import java.net.URLEncoder; import java.security.Principal; import java.text.MessageFormat; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Map; import java.util.TimeZone; import java.util.regex.Matcher; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.*; import javax.ws.rs.core.*; import javax.ws.rs.core.Response.Status; import javax.xml.bind.annotation.XmlRootElement; import com.emc.storageos.model.password.PasswordChangeParam; import com.emc.storageos.security.password.Password; import com.emc.storageos.security.password.PasswordUtils; import com.emc.storageos.security.password.PasswordValidator; import com.emc.storageos.security.password.ValidatorFactory; import com.emc.storageos.services.util.SecurityUtils; import org.apache.commons.httpclient.UsernamePasswordCredentials; import org.apache.commons.lang.StringUtils; import org.eclipse.jetty.util.B64Code; import org.eclipse.jetty.util.StringUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import com.emc.storageos.auth.AuthenticationManager; import com.emc.storageos.security.password.InvalidLoginManager; import com.emc.storageos.auth.impl.CassandraTokenManager; import com.emc.storageos.db.client.DbClient; import com.emc.storageos.db.client.model.StorageOSUserDAO; import com.emc.storageos.security.authentication.RequestProcessingUtils; import com.emc.storageos.security.authentication.StorageOSUser; import com.emc.storageos.security.authorization.BasePermissionsHelper; import com.emc.storageos.security.authorization.Role; import com.emc.storageos.svcs.errorhandling.resources.APIException; import com.emc.storageos.services.OperationTypeEnum; import com.emc.storageos.security.audit.AuditLogManager; import com.emc.storageos.security.geo.RequestedTokenHelper; /** * Main resource class for all authentication api */ @Path("/") public class AuthenticationResource { private static final Logger _log = LoggerFactory.getLogger(AuthenticationResource.class); private static final String EVENT_SERVICE_TYPE = "auth"; public static final String AUTH_FORM_LOGIN_TOKEN_PARAM = "auth-token"; public static final String FROM_PORTAL = "portal"; public static final String DUMMY_HOST_NAME = "vipr"; private static final String UTF8_ENCODING = "UTF-8"; private static final String AUTH_FORM_LOGIN_PAGE_ACTION = "action=\""; public static final String AUTH_REALM_NAME = "ViPR"; private static final String FORM_LOGIN_DOC_PATH = "storageos-authsvc/docs/login.html"; private static final String FORM_CHANGE_PASSWORD_DOC_PATH = "storageos-authsvc/docs/changePassword.html"; // NOSONAR // ("Variable NAME contains substring password, but no sensitive information in the value.") private static final String FORM_LOGIN_HTML_ENT = "(<input\\s*id=\"username\")"; private static final String FORM_LOGIN_AUTH_ERROR_ENT = "<div class=\"alert alert-danger\">{0}</div>"; private static final String FORM_SUCCESS_ENT = "<div class=\"alert alert-success\">{0}</div>"; private static final String FORM_INFO_ENT = "<div class=\"alert alert-info\">{0}</div>"; private static final String FORM_LOGIN_BAD_CREDS_ERROR = "Invalid Username or Password"; private static final String FORM_NOT_MATCH_CONFIRM_PASSWORD = "password don't match confirm password"; // NOSONAR // ("Variable NAME contains substring password, but no sensitive information in the value.") private static final String FORM_INVALID_LOGIN_LIMIT_ERROR = "Exceeded invalid login limit"; private static final String FORM_INVALID_AUTH_TOKEN_ERROR = "Remote VDC token has either expired or was issued to a local user that is restricted to their home VDC only. Please relogin."; private static final String FORM_LOGIN_POST_NO_SERVICE_ERROR = "The POST request to formlogin does not have service query parameter"; private static final String SERVICE_URL_FORMAT_ERROR = "The provided service URI has invalid format"; private static final String LOGIN_BANNER_KEY = "system_login_banner"; private static String _cachedLoginPagePart1; private static String _cachedLoginPagePart2; private static String _cachedChangePasswordPagePart1; private static String _cachedChangePasswordPagePart2; private static String HEADER_PRAGMA = "Pragma"; private static String HEADER_PRAGMA_VALUE = "no-cache"; private static CacheControl _cacheControl = null; @Autowired private RequestedTokenHelper tokenNotificationHelper; static { _cacheControl = new CacheControl(); _cacheControl.setNoCache(true); _cacheControl.setNoStore(true); String[] loginPageParts = getStaticPageParts(FORM_LOGIN_DOC_PATH); _cachedLoginPagePart1 = loginPageParts[0]; _cachedLoginPagePart2 = loginPageParts[1]; String[] changePasswordPageParts = getStaticPageParts(FORM_CHANGE_PASSWORD_DOC_PATH); _cachedChangePasswordPagePart1 = changePasswordPageParts[0]; _cachedChangePasswordPagePart2 = changePasswordPageParts[1]; } private static String[] getStaticPageParts(String docPath) { String pageParts[] = new String[2]; String loginPage = null; ClassLoader loader = Thread.currentThread().getContextClassLoader(); InputStream is = loader.getResourceAsStream(docPath); if (is == null) { _log.error("Failed to find the custom login page"); } else { StringBuilder sb = new StringBuilder(); String line; BufferedReader br = null; try { br = new BufferedReader(new InputStreamReader(is)); while ((line = br.readLine()) != null) { sb.append(line); } loginPage = sb.toString(); int beforeIndex = loginPage.indexOf(AUTH_FORM_LOGIN_PAGE_ACTION); if (beforeIndex >= 0) { pageParts[0] = loginPage.substring(0, beforeIndex); String remainingChunk = loginPage.substring(beforeIndex + AUTH_FORM_LOGIN_PAGE_ACTION.length()); int afterIndex = remainingChunk.indexOf("\""); if (afterIndex >= 0) { pageParts[1] = remainingChunk.substring(afterIndex + 1); } } } catch (IOException e) { _log.error("Failed to load custom login template file", e); } finally { if (br != null) { try { br.close(); } catch (IOException e) { _log.error("Failed to clean up the BufferedReader resource"); } } } } return pageParts; } @Autowired protected DbClient _dbClient; @Autowired protected InvalidLoginManager _invLoginManager; @Autowired protected AuthenticationManager _authManager; @Autowired protected CassandraTokenManager _tokenManager; @Autowired protected BasePermissionsHelper _permissionsHelper; @Autowired protected AuditLogManager _auditMgr; @Autowired protected PasswordUtils _passwordUtils; @Autowired protected Map<String, StorageOSUser> _localUsers; @Context SecurityContext sc; @XmlRootElement public static class LoggedIn { public String user; LoggedIn() { } LoggedIn(String u) { user = u; } } @XmlRootElement(name = "LoggedOut") public static class LoggedOut { public String user; LoggedOut() { } LoggedOut(String u) { user = u; } } /** * Create and return a Cookie object with the token * * @param token * @param setMaxAge if true sets the age of the cookie to maxlife of token. Else defaults to browser * session * @return */ private NewCookie createWsCookie(String cookieName, String token, boolean setMaxAge, String userAgent) { // For IE, we need to use "expires" to support rememberme functionality String ieExpires = ""; int maxAge = setMaxAge ? _tokenManager.getMaxTokenLifeTimeInSecs() : NewCookie.DEFAULT_MAX_AGE; if (setMaxAge && StringUtils.contains(userAgent, "MSIE")) { ieExpires = ";expires=" + getExpiredTimeGMT(maxAge); _log.debug("Expires: " + ieExpires); } if (token != null && !token.isEmpty()) { return new NewCookie(cookieName, token + ";HttpOnly" + ieExpires, null, null, null, maxAge, true); } return null; } /** * Adds a special key in the end to identify as the request is redirected back from authsvc * * @param service * @return * @throws URISyntaxException */ private URI getServiceURLForRedirect(String service, HttpServletRequest request) throws UnsupportedEncodingException, URISyntaxException { String serviceDecoded = URLDecoder.decode(service, UTF8_ENCODING); _log.debug("Original service = " + serviceDecoded); String newService = ""; URI uriObject = new URI(serviceDecoded); String scheme = uriObject.getScheme(); if (StringUtils.isBlank(scheme)) { scheme = "https"; } // newservice will be constructed by replacing the host component in the original service by // serverName obtained from the HttpServletRequest. newService = scheme + "://" + request.getServerName(); int port = uriObject.getPort(); if (port > 0) { newService += ":" + port; } String path = uriObject.getPath(); if (StringUtils.isNotBlank(path)) { newService += (path.startsWith("/") ? "" : "/") + path; } String query = uriObject.getQuery(); if (query != null && !query.isEmpty()) { newService += "?" + query; } if (newService.contains("?")) { newService = String.format("%s&%s", newService, RequestProcessingUtils.REDIRECT_FROM_AUTHSVC); } else { newService = String.format("%s?%s", newService, RequestProcessingUtils.REDIRECT_FROM_AUTHSVC); } //Append the fragments if any. Fragments are used to the identify //particular service catalog. This is done to support the functionality //of redirecting directly to a particular service catalog upon the //the successful authentication. if (StringUtils.isNotBlank(uriObject.getFragment())) { newService += "#" + uriObject.getFragment(); } newService = SecurityUtils.stripXSS(newService); _log.debug("Updated service = " + newService); return URI.create(newService); } /** * Authenticates the user and obtains authentication token * to use in subsequent api calls. If valid X-SDS-AUTH-TOKEN * is provided, that will be used instead of creating the new * authentication token. * Setting the queryParam "using-cookies" to "true" sets the * following cookies in the response. * * <li>X-SDS-AUTH-TOKEN</li> * <li>HttpOnly</li> * <li>Version</li> * <li>Max-Age</li> * <li>Secure</li> * * @brief User login * @param httpRequest request object (contains basic authentication header with credentials) * @param servletResponse Response object * @param service Optional query parameter, to specify a URL to redirect to on successful * authentication * @prereq none * @return Response * @throws IOException * @throws URISyntaxException */ @GET @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) @Path("login") public Response getLoginToken(@Context HttpServletRequest httpRequest, @Context HttpServletResponse servletResponse, @QueryParam("service") String service) throws IOException { String clientIP = _invLoginManager.getClientIP(httpRequest); isClientIPBlocked(clientIP); boolean setCookie = RequestProcessingUtils.isRequestingQueryParam(httpRequest, RequestProcessingUtils.REQUESTING_COOKIES); LoginStatus loginStatus = tryLogin(httpRequest, service, setCookie, servletResponse, false); if (loginStatus.loggedIn()) { // Clean up the invalid login records if any _invLoginManager.removeInvalidRecord(clientIP); try { Response resp = buildLoginResponse(service, null, setCookie, true, loginStatus, httpRequest); return resp; } catch (URISyntaxException ex) { throw APIException.badRequests.serviceURLBadSyntax(); } } // The authentication failed. Make note of that in the ZK only if the user credentials are provided. if (loginStatus.areCredentialsProvided()) { _invLoginManager.markErrorLogin(clientIP); } return requestCredentials(); } /** * * Generates a response ready to be returned by REST methods in this resource. * The response will either be an ok or 302 depending on the parameters * * @param service optional, service to forward to. if null, reponse will be 200. * @param setCookie, whether or not to set the cookie in the response * @param setMaxAge, whether ot not to set the max age on the cookie * @param loginStatus, login status containing the token to add * @return the response * @throws UnsupportedEncodingException * @throws URISyntaxException */ private Response buildLoginResponse(String service, String source, boolean setCookie, boolean setMaxAge, LoginStatus loginStatus, HttpServletRequest request) throws UnsupportedEncodingException, URISyntaxException { String authTokenName = source != null && source.equals(FROM_PORTAL) ? RequestProcessingUtils.AUTH_PORTAL_TOKEN_HEADER : RequestProcessingUtils.AUTH_TOKEN_HEADER; Response.ResponseBuilder resp = null; if (service != null && !service.isEmpty()) { resp = Response.status(302).location(getServiceURLForRedirect(service, request)) .header(authTokenName, loginStatus.getToken()); } else { resp = Response.ok(new LoggedIn(loginStatus.getUser())).header(authTokenName, loginStatus.getToken()); } if (setCookie) { return resp.cookie(createWsCookie(authTokenName, loginStatus.getToken(), setMaxAge, request.getHeader("user-agent"))).build(); } else { return resp.build(); } } /** * Try to login the user. If not generate the form login page * * @brief INTERNAL USE * @param httpRequest request object (contains basic authentication header with credentials) * @param servletResponse Response object * @param service Optional query parameter, to specify a URL to redirect to on successful * authentication * @return form login page if the user is not authenticated. * OK status otherwise * @throws UnsupportedEncodingException * @prereq none * @throws IOException */ @GET @Produces({ MediaType.TEXT_HTML }) @Path("formlogin") public Response getFormLogin(@Context HttpServletRequest httpRequest, @Context HttpServletResponse servletResponse, @QueryParam("service") String service, @QueryParam("src") String source) throws UnsupportedEncodingException, IOException { String loginError = null; try { LoginStatus loginStatus = tryLogin(httpRequest, service, true, servletResponse, true); if (loginStatus.loggedIn()) { return buildLoginResponse(service, source, true, true, loginStatus, httpRequest); } } catch (URISyntaxException e) { loginError = SERVICE_URL_FORMAT_ERROR; } catch (Exception e) { loginError = MessageFormat.format(FORM_LOGIN_AUTH_ERROR_ENT, e.getMessage()); } if (service == null) { String port = httpRequest.getServerPort() != -1 ? ":" + httpRequest.getServerPort() : ""; // Dummy Host Name will be replaced by the actual host name during redirection service = httpRequest.getScheme() + "://" + DUMMY_HOST_NAME + port + "/login"; } String formLP = getFormLoginPage(service, source, httpRequest.getServerName(), loginError); if (formLP != null) { return Response.ok(formLP).type(MediaType.TEXT_HTML) .cacheControl(_cacheControl).header(HEADER_PRAGMA, HEADER_PRAGMA_VALUE).build(); } else { _log.error("Could not generate custom (form) login page"); return Response.status(Status.INTERNAL_SERVER_ERROR).build(); } } /** * display fromChangePassword page. it contains currently enabled password rules prompt information * to guide user input the new password. * * @brief INTERNAL USE * * @param httpRequest * @param servletResponse * @param service * @param source * @return * @throws UnsupportedEncodingException * @throws IOException */ @GET @Produces({ MediaType.TEXT_HTML }) @Path("formChangePassword") public Response getChangePasswordForm(@Context HttpServletRequest httpRequest, @Context HttpServletResponse servletResponse, @QueryParam("service") String service, @QueryParam("src") String source) throws UnsupportedEncodingException, IOException { String loginError = null; if (service == null) { String port = httpRequest.getServerPort() != -1 ? ":" + httpRequest.getServerPort() : ""; // Dummy Host Name will be replaced by the actual host name during redirection service = httpRequest.getScheme() + "://" + DUMMY_HOST_NAME + port + "/login"; } String formLP = getFormChangePasswordPage(service, source, httpRequest.getServerName(), loginError); if (formLP != null) { return Response.ok(formLP).type(MediaType.TEXT_HTML) .cacheControl(_cacheControl).header(HEADER_PRAGMA, HEADER_PRAGMA_VALUE).build(); } else { _log.error("Could not generate custom (form) changePassword page"); return Response.status(Status.INTERNAL_SERVER_ERROR).build(); } } /** * Requests a proxy authentication token corresponding to the user in the Context * A user must already be authenticated and have an auth-token in order to * be able to get a proxy token for itself. * This proxy token never expires and can be used with the * proxy user's authentication token to make proxy user work on behalf * of the user in the context. * * @brief Requests user's proxy authentication token. * @return Response * @throws IOException */ @GET @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) @Path("proxytoken") public Response getProxyToken() { _log.debug("Requesting proxy token"); StorageOSUser user = getUserFromContext(); if (user == null) { _log.error("Unauthenticated request for a proxytoken"); return requestCredentials(); } String proxyToken = _tokenManager.getProxyToken(user); Response.ResponseBuilder resp = Response.ok() .header(RequestProcessingUtils.AUTH_PROXY_TOKEN_HEADER, proxyToken); return resp.build(); } /** * Logs out a user's authentication token and optionally other related tokens and proxytokens * * @brief User logout * @param force Optional query parameter, if set to true, will delete all active tokens for the user, * excluding proxy tokens. Otherwise, invalidates only the token from the request * Default value: false * @param includeProxyTokens Optional query parameter, if set to true and combined with force, will delete * all active tokens, including proxy tokens for the user. * Default value: false * @param username Optional query parameter, if supplied, the user pointed by the username will * be logged out instead of the currently logged in user (SECURITY_ADMIN role required to * use this parameter) * @param notifyVDCs if set to true, will look if the token was copied to other VDCs and notify them * @return Response * @prereq none * @throws IOException */ @GET @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) @Path("logout") public Response logout(@DefaultValue("false") @QueryParam("force") boolean force, @DefaultValue("false") @QueryParam("proxytokens") boolean includeProxyTokens, @QueryParam("username") String username, @DefaultValue("true") @QueryParam("notifyvdcs") boolean notifyVDCs) { StorageOSUser user = getUserFromContext(); if (user != null) { if (StringUtils.isNotBlank(username)) { boolean isTargetUserLocal = _localUsers.containsKey(username); boolean hasRestrictedSecurityAdmin = _permissionsHelper.userHasGivenRole(user, URI.create(user.getTenantId()), Role.RESTRICTED_SECURITY_ADMIN); boolean hasSecurityAdmin = _permissionsHelper.userHasGivenRole(user, URI.create(user.getTenantId()), Role.SECURITY_ADMIN); // if the user is security admin or restricted sec admin (if the user to be logged out is just local) if (hasSecurityAdmin || (isTargetUserLocal && hasRestrictedSecurityAdmin)) { // boot the user out _tokenManager.deleteAllTokensForUser(username, includeProxyTokens); if (notifyVDCs && !isTargetUserLocal) { // broadcast the call to other vdcs if this is not a local user tokenNotificationHelper.broadcastLogoutForce(user.getToken(), username); } return Response.ok(new LoggedOut(username)).build(); } else { throw APIException.forbidden.userNotPermittedToLogoutAnotherUser(user .getUserName()); } } else { if (force) { // delete all tokens for this user _tokenManager.deleteAllTokensForUser(user.getUserName(), includeProxyTokens); if (notifyVDCs && !user.isLocal()) { tokenNotificationHelper.broadcastLogoutForce(user.getToken(), null); } } else { // delete only the current token _tokenManager.deleteToken(user.getToken()); if (notifyVDCs && !user.isLocal()) { // if other VDCs have a copy of this token, they need to be notified. tokenNotificationHelper.notifyExternalVDCs(user.getToken()); } } return Response.ok(new LoggedOut(user.getUserName())).build(); } } throw APIException.unauthorized.tokenNotFoundOrInvalidTokenProvided(); } /** * Change a local user's password without login. * * This interface need be provided with clear text old password and new password * * @brief Change your password * @throws APIException */ @PUT @Path("change-password") @Consumes({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) public Response changePassword(@Context HttpServletRequest httpRequest, @Context HttpServletResponse servletResponse, @DefaultValue("true") @QueryParam("logout_user") boolean logout, PasswordChangeParam passwordChange) { String clientIP = _invLoginManager.getClientIP(httpRequest); isClientIPBlocked(clientIP); // internal call to password service Response response = _passwordUtils.changePassword(passwordChange, false); if (response.getStatus() != Status.OK.getStatusCode()) { String message = response.getEntity().toString(); if (message.contains(_invLoginManager.OLD_PASSWORD_INVALID_ERROR)) { _invLoginManager.markErrorLogin(clientIP); } } else { // change password successfully, do some cleanup try { _invLoginManager.removeInvalidRecord(clientIP); if (logout) { _log.info("logout active sessions for: " + passwordChange.getUsername()); _tokenManager.deleteAllTokensForUser(passwordChange.getUsername(), false); } } catch (Exception cleanupException) { _log.error("clean up failed: {0}", cleanupException.getMessage()); } } return response; } /** * Check to see if a proposed password change parameter satisfies ViPR's password content rules * * the api can be called before login * * @brief Validate a proposed password for a user * @prereq none * @throws APIException */ @POST @Path("/validate-password-change") @Consumes({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) public Response validateUserPasswordForChange( @Context HttpServletRequest httpRequest, PasswordChangeParam passwordParam) { String clientIP = _invLoginManager.getClientIP(httpRequest); isClientIPBlocked(clientIP); // internal call to password service Response response = _passwordUtils.changePassword(passwordParam, true); return response; } /** * Authenticates a user with credentials provided in the form data of the request. * This method is for internal use by formlogin page. * * @brief INTERNAL USE * @return On successful authentication the client will be redirected to the provided service. * @throws IOException */ @POST @Produces({ MediaType.APPLICATION_XML, MediaType.TEXT_HTML }) @Consumes("application/x-www-form-urlencoded") @Path("formChangePassword") public Response changePassword(@Context HttpServletRequest request, @Context HttpServletResponse servletResponse, @QueryParam("service") String service, @QueryParam("src") String source, @DefaultValue("true") @QueryParam("logout_user") boolean logout, MultivaluedMap<String, String> formData) throws IOException { boolean isChangeSuccess = false; String message = null; String clientIP = _invLoginManager.getClientIP(request); String userName = formData.getFirst("username"); String userOldPassw = formData.getFirst("oldPassword"); String userPassw = formData.getFirst("password"); String confirmPassw = formData.getFirst("confirmPassword"); if (_invLoginManager.isTheClientIPBlocked(clientIP) == true) { _log.error("The client IP is blocked for too many invalid login attempts: " + clientIP); int minutes = _invLoginManager.getTimeLeftToUnblock(clientIP); message = String.format("%s.<br>Will be cleared within %d minutes", FORM_INVALID_LOGIN_LIMIT_ERROR, minutes); } else if (userName == null || userOldPassw == null || userPassw == null || confirmPassw == null) { message = FORM_LOGIN_BAD_CREDS_ERROR; } else if (!userPassw.equals(confirmPassw)) { message = FORM_NOT_MATCH_CONFIRM_PASSWORD; } else { PasswordChangeParam passwordChange = new PasswordChangeParam(); passwordChange.setUsername(userName); passwordChange.setOldPassword(userOldPassw); passwordChange.setPassword(userPassw); Response response = _passwordUtils.changePassword(passwordChange, false); if (response.getStatus() != Status.OK.getStatusCode()) { message = response.getEntity().toString(); message = message.replaceAll(".*<details>(.*)</details>.*", "$1"); } else { isChangeSuccess = true; message = "change password for user " + userName + " successful."; } } String formLP = null; if (!isChangeSuccess) { formLP = getFormChangePasswordPage(service, source, request.getServerName(), MessageFormat.format(FORM_LOGIN_AUTH_ERROR_ENT, message)); if (message.contains(_invLoginManager.OLD_PASSWORD_INVALID_ERROR)) { _invLoginManager.markErrorLogin(clientIP); } } else { // change password successfully, do some cleanup try { formLP = getFormLoginPage(service, source, request.getServerName(), MessageFormat.format(FORM_SUCCESS_ENT, message)); _invLoginManager.removeInvalidRecord(clientIP); if (logout) { _log.info("logout active sessions for: " + userName); _tokenManager.deleteAllTokensForUser(userName, false); } } catch (Exception cleanupException) { _log.error("clean up failed: {0}", cleanupException.getMessage()); } } if (formLP != null) { return Response.ok(formLP).type(MediaType.TEXT_HTML) .cacheControl(_cacheControl).header(HEADER_PRAGMA, HEADER_PRAGMA_VALUE).build(); } else { _log.error("Could not generate custom (form) login page"); return Response.status(Status.INTERNAL_SERVER_ERROR).build(); } } /** * Authenticates a user with credentials provided in the form data of the request. * This method is for internal use by formlogin page. * * @brief INTERNAL USE * * @param request the login request from the client. * @param servletResponse the response to be sent out to client. * @param service to be used to redirect on successful authentication. * @param source to be used to identify if the request is coming from portal * or some other client. * @param fragment to used to identify the service catalog to redirect on * successful authentication. * * @return On successful authentication the client will be redirected to the provided service. * @throws IOException */ @POST @Produces({ MediaType.APPLICATION_XML, MediaType.TEXT_HTML }) @Consumes("application/x-www-form-urlencoded") @Path("formlogin") public Response formlogin(@Context HttpServletRequest request, @Context HttpServletResponse servletResponse, @QueryParam("service") String service, @QueryParam("src") String source, @QueryParam("fragment") String fragment, MultivaluedMap<String, String> formData) throws IOException { boolean isPasswordExpired = false; String loginError = null; if (service == null || service.isEmpty()) { loginError = FORM_LOGIN_POST_NO_SERVICE_ERROR; } String updatedService = service; if (StringUtils.isNotBlank(service) && StringUtils.isNotBlank(fragment)) { updatedService = updatedService + "#" + fragment; } // Check invalid login count from the client IP boolean updateInvalidLoginCount = true; String clientIP = _invLoginManager.getClientIP(request); _log.debug("Client IP: {}", clientIP); if (_invLoginManager.isTheClientIPBlocked(clientIP) == true) { _log.error("The client IP is blocked for too many invalid login attempts: " + clientIP); int minutes = _invLoginManager.getTimeLeftToUnblock(clientIP); loginError = String.format("%s.<br>Will be cleared within %d minutes", FORM_INVALID_LOGIN_LIMIT_ERROR, minutes); updateInvalidLoginCount = false; } if (null == loginError) { String rememberMeStr = formData.getFirst("remember"); boolean rememberMe = StringUtils.isNotBlank(rememberMeStr) && rememberMeStr.equalsIgnoreCase("true"); // Look for a token passed in the form. If so, validate it and return it back // as a cookie if valid. Else, continue with the normal flow of formlogin to validate // credentials String tokenFromForm = formData.getFirst(AUTH_FORM_LOGIN_TOKEN_PARAM); if (StringUtils.isNotBlank(tokenFromForm)) { try { StorageOSUserDAO userDAOFromForm = _tokenManager.validateToken(tokenFromForm); if (userDAOFromForm != null) { _log.debug("Form login was posted with valid token"); return buildLoginResponse(updatedService, source, true, rememberMe, new LoginStatus(userDAOFromForm.getUserName(), tokenFromForm, false), request); } _log.error("Auth token passed to this formlogin could not be validated and returned null user"); loginError = FORM_INVALID_AUTH_TOKEN_ERROR; } catch (APIException ex) { // It is possible that validateToken would throw if the passed in token is unparsable // Unlike the regular use case for validatetoken which is done inside api calls, here we are // building a response to a web page, so we need to catch this and let the rest of this method // proceed which will result in requesting new credentials. loginError = FORM_INVALID_AUTH_TOKEN_ERROR; _log.error("Auth token passed to this formlogin could not be validated. Exception: ", ex); } catch (URISyntaxException e) { loginError = SERVICE_URL_FORMAT_ERROR; } } UsernamePasswordCredentials credentials = getFormCredentials(formData); if (null == loginError) { loginError = FORM_LOGIN_BAD_CREDS_ERROR; } try { if (credentials != null) { StorageOSUserDAO user = authenticateUser(credentials); if (user != null) { validateLocalUserExpiration(credentials); String token = _tokenManager.getToken(user); if (token == null) { _log.error("Could not generate token for user: {}", user.getUserName()); auditOp(null, null, OperationTypeEnum.AUTHENTICATION, false, null, credentials.getUserName()); return Response.status(Status.INTERNAL_SERVER_ERROR).build(); } _log.debug("Redirecting to the original service: {}", updatedService); _invLoginManager.removeInvalidRecord(clientIP); auditOp(URI.create(user.getTenantId()), URI.create(user.getUserName()), OperationTypeEnum.AUTHENTICATION, true, null, credentials.getUserName()); // If remember me check box is on, set the expiration time. return buildLoginResponse(updatedService, source, true, rememberMe, new LoginStatus(user.getUserName(), token, null != credentials), request); } } else { // Do not update the invalid login count for this client IP if credentials are not provided updateInvalidLoginCount = false; } } catch (APIException e) { loginError = e.getMessage(); if (loginError.contains("expired")) { isPasswordExpired = true; } } catch (URISyntaxException e) { loginError = SERVICE_URL_FORMAT_ERROR; } } // If we are here, request another login with appropriate error message // Mark this invalid login as a failure in ZK from the client IP if (updateInvalidLoginCount) { _invLoginManager.markErrorLogin(clientIP); } if (null != loginError) { _log.error(loginError); } String formLP = null; if (isPasswordExpired) { formLP = getFormChangePasswordPage(updatedService, source, request.getServerName(), MessageFormat.format(FORM_LOGIN_AUTH_ERROR_ENT, loginError)); } else { formLP = getFormLoginPage(updatedService, source, request.getServerName(), MessageFormat.format(FORM_LOGIN_AUTH_ERROR_ENT, loginError)); } auditOp(null, null, OperationTypeEnum.AUTHENTICATION, false, null, formData.getFirst("username")); if (formLP != null) { return Response.ok(formLP).type(MediaType.TEXT_HTML) .cacheControl(_cacheControl).header(HEADER_PRAGMA, HEADER_PRAGMA_VALUE).build(); } else { _log.error("Could not generate custom (form) login page"); return Response.status(Status.INTERNAL_SERVER_ERROR).build(); } } /** * See if the user is already logged in or try to login the user * if credentials were supplied. Return authentication status * * @param httpRequest * @param service * @param setCookie * @param servletResponse * @param tokenOnly false if either token or credentials can be used to attempt the login. True if only token is accepted. * @return LoginStatus of the user. * @throws UnsupportedEncodingException * @throws IOException */ private LoginStatus tryLogin(HttpServletRequest httpRequest, String service, boolean setCookie, HttpServletResponse servletResponse, boolean tokenOnly) throws UnsupportedEncodingException, IOException { String newToken = null; String userName = null; _log.debug("Logging in"); UsernamePasswordCredentials credentials = tokenOnly ? null : getCredentials(httpRequest); if (credentials == null) { // check if we already have a user context StorageOSUser user = getUserFromContext(); if (user != null) { newToken = user.getToken(); userName = user.getName(); _log.debug("Logged in with user from context"); } } else { StorageOSUserDAO user = authenticateUser(credentials); if (user != null) { validateLocalUserExpiration(credentials); newToken = _tokenManager.getToken(user); if (newToken == null) { _log.error("Could not generate token for user: {}", user.getUserName()); throw new IllegalStateException(MessageFormat.format("Could not generate token for user: {}", user.getUserName())); } userName = user.getUserName(); auditOp(URI.create(user.getTenantId()), URI.create(user.getUserName()), OperationTypeEnum.AUTHENTICATION, true, null, credentials.getUserName()); } else { auditOp(null, null, OperationTypeEnum.AUTHENTICATION, false, null, credentials.getUserName()); } } return new LoginStatus(userName, newToken, null != credentials); } /** * @param credentials User credentials to authenticate with * @return the User DAO object if authenticated, otherwise null */ private StorageOSUserDAO authenticateUser(UsernamePasswordCredentials credentials) { StorageOSUserDAO user = _authManager.authenticate(credentials); if (user != null) { if (null == user.getTenantId() || user.getTenantId().isEmpty()) { throw APIException.forbidden.userDoesNotMapToAnyTenancy(user .getUserName()); } _log.debug("Logging in after authentication"); } return user; } private UsernamePasswordCredentials getFormCredentials( MultivaluedMap<String, String> formData) { String userName = formData.getFirst("username"); String userPassw = formData.getFirst("password"); if (StringUtils.isNotBlank(userName) && StringUtils.isNotBlank(userPassw)) { return new UsernamePasswordCredentials(userName, userPassw); } else { _log.debug("The user name and/or password is empty"); } return null; } /** * Update the static login page with service query parameter * * @param service The requested service * @return */ private String getFormLoginPage(final String service, final String source, final String serverName, final String error) { if (StringUtils.isBlank(_cachedLoginPagePart1) || StringUtils.isBlank(_cachedLoginPagePart2)) { _log.error("The form login page is not processed correctly, missing part1 and/or part2"); return null; } String encodedTargetService = ""; try { URI serviceURL = getServiceURL(service, serverName); encodedTargetService = URLEncoder.encode(serviceURL.toString(), "UTF-8"); } catch (UnsupportedEncodingException | URISyntaxException e) { throw APIException.badRequests.unableToEncodeString(service, e); } StringBuffer sbFinal = new StringBuffer(); sbFinal.append(error == null ? _cachedLoginPagePart1 : _cachedLoginPagePart1.replaceAll(FORM_LOGIN_HTML_ENT, error + "$1")); sbFinal.append("action=\"./formlogin?service="); sbFinal.append(encodedTargetService); if (source != null && source.equals(FROM_PORTAL)) { sbFinal.append("&src="); sbFinal.append(source); } sbFinal.append("\" "); String loginBannerString = Matcher.quoteReplacement(_passwordUtils.getConfigProperty(LOGIN_BANNER_KEY)); String _cachedLoginPagePart2Tmp = ""; _cachedLoginPagePart2Tmp = _cachedLoginPagePart2.replaceAll(LOGIN_BANNER_KEY, loginBannerString).replaceAll(Matcher.quoteReplacement("\\\\n"), "<br>"); sbFinal.append(error == null ? _cachedLoginPagePart2Tmp : _cachedLoginPagePart2Tmp.replaceAll(FORM_LOGIN_HTML_ENT, error + "$1")); return sbFinal.toString(); } /** * Update the static changePassword page with service query parameter */ private String getFormChangePasswordPage(final String service, final String source, final String serverName, final String error) { if (StringUtils.isBlank(_cachedChangePasswordPagePart1) || StringUtils.isBlank(_cachedChangePasswordPagePart2)) { _log.error("The form changePassword page is not processed correctly, missing part1 and/or part2"); return null; } String encodedTargetService = ""; try { URI serviceURL = getServiceURL(service, serverName); encodedTargetService = URLEncoder.encode(serviceURL.toString(), "UTF-8"); } catch (UnsupportedEncodingException | URISyntaxException e) { throw APIException.badRequests.unableToEncodeString(service, e); } StringBuffer sbFinal = new StringBuffer(); sbFinal.append(error == null ? _cachedChangePasswordPagePart1 : _cachedChangePasswordPagePart1.replaceAll(FORM_LOGIN_HTML_ENT, error + "$1")); sbFinal.append("action=\"./formChangePassword?service="); sbFinal.append(encodedTargetService); if (source != null && source.equals(FROM_PORTAL)) { sbFinal.append("&src="); sbFinal.append(source); } sbFinal.append("\" "); // add password rule prompt information div String passwordRuleInfo = MessageFormat.format(FORM_INFO_ENT, getPasswordChangePromptRule()); _log.info("password rule info: \n" + passwordRuleInfo); String newPart2 = _cachedChangePasswordPagePart2.replaceAll(FORM_LOGIN_HTML_ENT, passwordRuleInfo + "$1"); String loginBannerString = Matcher.quoteReplacement(_passwordUtils.getConfigProperty(LOGIN_BANNER_KEY)); newPart2 = newPart2.replaceAll(LOGIN_BANNER_KEY, loginBannerString).replaceAll(Matcher.quoteReplacement("\\\\n"), "<br>"); sbFinal.append(error == null ? newPart2 : newPart2.replaceAll(FORM_LOGIN_HTML_ENT, error + "$1")); return sbFinal.toString(); } /** * Pull credentials from the header * * @param request * @return */ private UsernamePasswordCredentials getCredentials(HttpServletRequest request) { String credentials = request.getHeader(HttpHeaders.AUTHORIZATION); if (credentials != null) { credentials = credentials.substring(credentials.indexOf(' ') + 1); try { credentials = B64Code.decode(credentials, StringUtil.__ISO_8859_1); } catch (UnsupportedEncodingException e) { return null; } int i = credentials.indexOf(':'); UsernamePasswordCredentials creds = new UsernamePasswordCredentials(credentials.substring(0, i), credentials.substring(i + 1)); return creds; } return null; } /** * Respond with a 401 and challenge for auth * * @return * @throws IOException */ private Response requestCredentials() { Response response = Response.status(HttpServletResponse.SC_UNAUTHORIZED) .header(HttpHeaders.WWW_AUTHENTICATE, "basic realm=\"" + AUTH_REALM_NAME + '"') .cacheControl(_cacheControl) .header(HEADER_PRAGMA, HEADER_PRAGMA_VALUE) .build(); return response; } /** * Get StorageOSUser from the security context * * @return */ private StorageOSUser getUserFromContext() { if (sc != null && sc.getUserPrincipal() != null) { Principal principal = sc.getUserPrincipal(); if (!(principal instanceof StorageOSUser)) { throw APIException.forbidden.invalidSecurityContext(); } return (StorageOSUser) principal; } return null; } /** * Class to hold the username and auth token pair * */ private class LoginStatus { private String _user; private String _token; private boolean _areCredentialsProvided; public LoginStatus(final String user, final String token, boolean areCredentialsProvided) { _user = user; _token = token; _areCredentialsProvided = areCredentialsProvided; } /** * Method to return whether or not the user is logged in * * @return true if the user and token are not null */ public boolean loggedIn() { return _user != null && _token != null; } public String getToken() { return _token; } public String getUser() { return _user; } public boolean areCredentialsProvided() { return _areCredentialsProvided; } } /** * Record audit log for services * * @param opType audit event type (e.g. CREATE_VPOOL|TENANT etc.) * @param operationalStatus Status of operation (true|false) * @param operationStage Stage of operation. * For sync operation, it should be null; * For async operation, it should be "BEGIN" or "END"; * @param descparams Description paramters */ protected void auditOp(URI tenantId, URI userId, OperationTypeEnum opType, boolean operationalStatus, String operationStage, Object... descparams) { _auditMgr.recordAuditLog(tenantId, userId, EVENT_SERVICE_TYPE, opType, System.currentTimeMillis(), operationalStatus ? AuditLogManager.AUDITLOG_SUCCESS : AuditLogManager.AUDITLOG_FAILURE, operationStage, descparams); } /** * @param maxAge in seconds * @return GMT time of current time + maxAge */ private String getExpiredTimeGMT(int maxAge) { String dateFormat = "EEE, d-MMM-yyyy HH:mm:ss zzz"; SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat); simpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); long time = System.currentTimeMillis() + maxAge * 1000; Date expiryDate = new Date(time); return simpleDateFormat.format(expiryDate); } /** * String for prompting Password Rules * * @return */ private String getPasswordChangePromptRule() { List<String> promptRules = _passwordUtils.getPasswordChangePromptRules(); StringBuilder promptString = new StringBuilder(); promptString.append("<p>Password Validation Rules:</p>"); promptString.append("<ul>"); for (String item : promptRules) { promptString.append("<li>").append(item).append("</li>"); } promptString.append("</ul>"); return promptString.toString(); } /** * validate if local user's password expired * * @param credentials */ private void validateLocalUserExpiration(UsernamePasswordCredentials credentials) { // skip validation, if user is not a local one. if (!_passwordUtils.isLocalUser(credentials.getUserName())) { return; } PasswordValidator validator = ValidatorFactory.buildExpireValidator( _passwordUtils.getConfigProperties()); Password password = new Password(credentials.getUserName(), credentials.getPassword(), null); password.setPasswordHistory(_passwordUtils.getPasswordHistory(credentials.getUserName())); validator.validate(password); } /** * check if the client be blocked * * @param clientIP */ private void isClientIPBlocked(String clientIP) { if (_invLoginManager.isTheClientIPBlocked(clientIP)) { _log.error("The client IP is blocked for too many invalid login attempts: " + clientIP); throw APIException.unauthorized. exceedingErrorLoginLimit(_invLoginManager.getMaxAuthnLoginAttemtsCount(), _invLoginManager.getTimeLeftToUnblock(clientIP)); } } /** * Returns the Service URL to be redirected upon the successful login of * the user. The service URL is built using the service queryParam and * the host header of the http request. * * @param service the requested service url. * @param serverName the server name from the host header of the http request. * * @return returns the service url built from the server name. * @throws URISyntaxException */ private URI getServiceURL(String service, String serverName) throws UnsupportedEncodingException, URISyntaxException { String serviceDecoded = URLDecoder.decode(service, UTF8_ENCODING); _log.debug("Original service = " + serviceDecoded); serviceDecoded = SecurityUtils.stripXSS(serviceDecoded); String newService = ""; URI uriObject = new URI(serviceDecoded); String scheme = uriObject.getScheme(); if (StringUtils.isBlank(scheme) ) { scheme = "https"; } int port = uriObject.getPort(); // newservice will be constructed by replacing the host component in the original service by // serverName obtained from the HttpServletRequest. newService = scheme + "://" + serverName; if (port > 0) { newService += ":" + port; } String path = uriObject.getPath(); if (StringUtils.isNotBlank(path)) { newService += (path.startsWith("/")?"":"/") + path; } String query = uriObject.getQuery(); if (query != null && !query.isEmpty() ) { newService += "?" + query; } _log.debug("Updated service = " + newService); return URI.create(newService); } }