diff --git a/server/src/main/java/com/cloud/api/ApiServlet.java b/server/src/main/java/com/cloud/api/ApiServlet.java index f5ca18449fe1..b541eccca1fe 100644 --- a/server/src/main/java/com/cloud/api/ApiServlet.java +++ b/server/src/main/java/com/cloud/api/ApiServlet.java @@ -313,6 +313,11 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp if (!DefaultForgotPasswordAPIAuthenticatorCmd.APINAME.equalsIgnoreCase(command) || StringUtils.isNotBlank(username)) { LOGGER.debug("Authentication failure: {}", e.getMessage()); } + } catch (RuntimeException e) { + if (apiAuthenticator.getAPIType() == APIAuthenticationType.LOGIN_API) { + invalidateHttpSession(session, "invalidating session after unexpected login failure"); + } + throw e; } if (apiAuthenticator.getAPIType() == APIAuthenticationType.LOGOUT_API) { @@ -426,6 +431,11 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp } catch (final Exception ex) { LOGGER.error("unknown exception writing api response", ex); auditTrailSb.append(" unknown exception writing api response"); + if (!resp.isCommitted()) { + final String serializedResponseText = apiServer.getSerializedApiError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Internal error processing the request", params, responseType); + HttpUtils.writeHttpResponse(resp, serializedResponseText, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, responseType, ApiServer.JSONContentType.value()); + } } finally { ACCESSLOGGER.info(auditTrailSb.toString()); if (LOGGER.isDebugEnabled()) { diff --git a/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java b/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java index 0d1e1d966740..2bdccf4fbf09 100644 --- a/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java +++ b/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java @@ -155,7 +155,10 @@ public String authenticate(String command, Map params, HttpSes } serializedResponse = ApiResponseSerializer.toSerializedString(_apiServer.loginUser(session, username[0], pwd, domainId, domain, remoteAddress, params), responseType); - userDetailsDao.removeDetail(userAccount.getId(), UserDetailVO.OauthLogin); + final Long userId = (Long)session.getAttribute("userid"); + if (userId != null) { + userDetailsDao.removeDetail(userId, UserDetailVO.OauthLogin); + } return serializedResponse; } catch (final CloudAuthenticationException ex) { ApiServlet.invalidateHttpSession(session, "fall through to API key,"); diff --git a/server/src/test/java/com/cloud/api/ApiServletTest.java b/server/src/test/java/com/cloud/api/ApiServletTest.java index 749533625551..a1a2099bd306 100644 --- a/server/src/test/java/com/cloud/api/ApiServletTest.java +++ b/server/src/test/java/com/cloud/api/ApiServletTest.java @@ -318,6 +318,31 @@ public void processRequestInContextLogin() throws UnknownHostException { Mockito.eq(InetAddress.getByName("127.0.0.1")), Mockito.anyString(), Mockito.isA(StringBuilder.class), Mockito.isA(HttpServletRequest.class), Mockito.isA(HttpServletResponse.class)); } + @SuppressWarnings("unchecked") + @Test + public void processRequestInContextLoginUnexpectedExceptionInvalidatesSessionAndReturnsError() throws UnknownHostException { + Mockito.when(request.getMethod()).thenReturn("POST"); + Mockito.when(request.getSession(false)).thenReturn(null); + Mockito.when(request.getSession(true)).thenReturn(session); + HashMap params = new HashMap(); + params.put(ApiConstants.COMMAND, new String[] { "login" }); + params.put(ApiConstants.USERNAME, new String[] { "TEST" }); + params.put(ApiConstants.PASSWORD, new String[] { "TEST-PWD" }); + Mockito.when(request.getParameterMap()).thenReturn(params); + Mockito.when(authenticator.getAPIType()).thenReturn(APIAuthenticationType.LOGIN_API); + Mockito.when(authenticator.authenticate(Mockito.anyString(), Mockito.anyMap(), Mockito.isA(HttpSession.class), + Mockito.eq(InetAddress.getByName("127.0.0.1")), Mockito.anyString(), Mockito.isA(StringBuilder.class), Mockito.isA(HttpServletRequest.class), Mockito.isA(HttpServletResponse.class))) + .thenThrow(new NullPointerException("test")); + Mockito.when(apiServer.getSerializedApiError(Mockito.eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), Mockito.anyString(), Mockito.anyMap(), Mockito.anyString())) + .thenReturn("{\"errorresponse\":{}}"); + + servlet.processRequestInContext(request, response); + + Mockito.verify(session).invalidate(); + Mockito.verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + Assert.assertEquals("{\"errorresponse\":{}}", responseWriter.toString()); + } + @Test public void getClientAddressWithXForwardedFor() throws UnknownHostException { setConfigValue("proxy.header.verify", "true"); diff --git a/server/src/test/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmdTest.java b/server/src/test/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmdTest.java new file mode 100644 index 000000000000..2af45922ddf7 --- /dev/null +++ b/server/src/test/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmdTest.java @@ -0,0 +1,128 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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.cloud.api.auth; + +import java.net.InetAddress; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.resourcedetail.UserDetailVO; +import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.domain.Domain; +import com.cloud.user.AccountService; +import com.cloud.user.DomainService; +import com.cloud.user.UserAccount; + +@RunWith(MockitoJUnitRunner.class) +public class DefaultLoginAPIAuthenticatorCmdTest { + + private static final String USERNAME = "user@example.com"; + private static final long DOMAIN_ID = 1L; + private static final long USER_ID = 42L; + private static final String SERIALIZED_RESPONSE = "{\"loginresponse\":{}}"; + + @Mock + ApiServerService apiServer; + @Mock + UserDetailsDao userDetailsDao; + @Mock + AccountService accountService; + @Mock + DomainService domainService; + @Mock + HttpSession session; + @Mock + HttpServletRequest req; + @Mock + HttpServletResponse resp; + @Mock + Domain domain; + @Mock + ResponseObject loginResponse; + + DefaultLoginAPIAuthenticatorCmd cmd = new DefaultLoginAPIAuthenticatorCmd(); + + private MockedStatic serializerMock; + private Map params; + + @Before + public void setUp() throws Exception { + cmd._apiServer = apiServer; + cmd._accountService = accountService; + cmd._domainService = domainService; + cmd.userDetailsDao = userDetailsDao; + + params = new HashMap<>(); + params.put(ApiConstants.USERNAME, new String[] {USERNAME}); + params.put(ApiConstants.PASSWORD, new String[] {"password"}); + params.put(ApiConstants.DOMAIN, new String[] {"/"}); + + Mockito.when(req.getMethod()).thenReturn("POST"); + Mockito.when(domain.getId()).thenReturn(DOMAIN_ID); + Mockito.when(domainService.findDomainByIdOrPath(Mockito.any(), Mockito.anyString())).thenReturn(domain); + Mockito.when(apiServer.loginUser(Mockito.eq(session), Mockito.eq(USERNAME), Mockito.anyString(), Mockito.eq(DOMAIN_ID), + Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(loginResponse); + Mockito.when(session.getAttribute("userid")).thenReturn(USER_ID); + + serializerMock = Mockito.mockStatic(ApiResponseSerializer.class); + serializerMock.when(() -> ApiResponseSerializer.toSerializedString(loginResponse, "json")).thenReturn(SERIALIZED_RESPONSE); + } + + @After + public void tearDown() { + serializerMock.close(); + } + + private String authenticate() throws Exception { + return cmd.authenticate("login", params, session, InetAddress.getLoopbackAddress(), "json", new StringBuilder(), req, resp); + } + + @Test + public void authenticateUserCreatedDuringLoginReturnsLoginResponse() throws Exception { + Mockito.when(accountService.getActiveUserAccount(USERNAME, DOMAIN_ID)).thenReturn(null); + + Assert.assertEquals(SERIALIZED_RESPONSE, authenticate()); + Mockito.verify(userDetailsDao).removeDetail(USER_ID, UserDetailVO.OauthLogin); + } + + @Test + public void authenticateExistingUserRemovesOauthLoginDetail() throws Exception { + UserAccount userAccount = Mockito.mock(UserAccount.class); + Mockito.when(accountService.getActiveUserAccount(USERNAME, DOMAIN_ID)).thenReturn(userAccount); + + Assert.assertEquals(SERIALIZED_RESPONSE, authenticate()); + Mockito.verify(userDetailsDao).removeDetail(USER_ID, UserDetailVO.OauthLogin); + } +} diff --git a/ui/src/main.js b/ui/src/main.js index 7441f8010865..9f6cbb5bba6f 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -68,8 +68,22 @@ vueApp.use(backupUtilPlugin) vueApp.use(extensions) vueApp.use(directives) +vueProps.$localStorage.keys().forEach(key => { + try { + vueProps.$localStorage.get(key) + } catch (e) { + console.warn('Removing unreadable local storage entry: ' + key) + vueProps.$localStorage.remove(key) + } +}) + +let configLoaded = false + const renderError = (err) => { console.error('Fatal error during app initialization: ', err) + const details = configLoaded + ? 'The application failed to start: ' + err + '. Check the browser console for more information.' + : 'Missing or malformed config.json. Please ensure the file is present, accessible, and contains valid JSON. Check the browser console for more information.' const ErrorComponent = { render: () => h( 'div', @@ -81,7 +95,7 @@ const renderError = (err) => { h('summary', { style: 'cursor: pointer;' }, 'Technical details'), h('pre', { style: 'text-align: left; display: inline-block; margin-top: 10px;' - }, 'Missing or malformed config.json. Please ensure the file is present, accessible, and contains valid JSON. Check the browser console for more information.') + }, details) ]) ] ) @@ -97,6 +111,7 @@ fetch('config.json?ts=' + Date.now()) return response.json() }) .then(async config => { + configLoaded = true vueProps.$config = config let baseUrl = config.apiBase if (config.multipleServer) { diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 4103ff920d63..87a48dd93967 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -64,7 +64,10 @@ function setLoginCookies (result) { } function processLoginResponse (commit, response) { - const result = response.loginresponse || {} + const result = response?.loginresponse || {} + if (!result.sessionkey) { + throw new Error('Login response not valid.') + } setLoginCookies(result) vueProps.$localStorage.set(ACCESS_TOKEN, result.sessionkey, 24 * 60 * 60 * 1000) commit('SET_TOKEN', result.sessionkey)