Skip to content

Commit

Permalink
Merge pull request #200 from Olog/CSSTUDIO-2768
Browse files Browse the repository at this point in the history
Update login endpoint to avoid credentials in URL
  • Loading branch information
georgweiss authored Oct 30, 2024
2 parents 2ec0650 + b784fbb commit e26352c
Show file tree
Hide file tree
Showing 5 changed files with 75 additions and 41 deletions.
65 changes: 33 additions & 32 deletions src/main/java/org/phoebus/olog/AuthenticationResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@

package org.phoebus.olog;

import com.fasterxml.jackson.databind.ObjectMapper;

import org.phoebus.olog.entity.UserData;
import org.phoebus.olog.security.LoginCredentials;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
Expand All @@ -29,42 +28,43 @@
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;

import static org.phoebus.olog.OlogResourceDescriptors.OLOG_SERVICE;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.phoebus.olog.OlogResourceDescriptors.OLOG_SERVICE;


@Controller
@RequestMapping(OLOG_SERVICE)
public class AuthenticationResource {

@SuppressWarnings("unused")
@Autowired
private AuthenticationManager authenticationManager;

@SuppressWarnings("unused")
@Autowired
private FindByIndexNameSessionRepository sessionRepository;
private FindByIndexNameSessionRepository<Session> sessionRepository;

@SuppressWarnings("unused")
@Value("${spring.session.timeout:30}")
private int sessionTimeout;

private ObjectMapper objectMapper = new ObjectMapper();

public static final int ONE_YEAR = 60 * 60 * 24 * 365;

/**
Expand All @@ -73,18 +73,17 @@ public class AuthenticationResource {
* This endpoint can be used by a form-based login, or a POST where username
* and password are specified as request parameters.
*
* @param userName The user principal name
* @param password User's password
* @param response {@link HttpServletResponse} to which a session cookie is
* attached upon successful authentication.
* @param loginCredentials User's credentials
* @param response {@link HttpServletResponse} to which a session cookie is
* attached upon successful authentication.
* @return A {@link ResponseEntity} carrying a {@link UserData} object if the login was successful,
* otherwise the body will be <code>null</code>.
*/
@SuppressWarnings("unused")
@PostMapping(value = "login")
public ResponseEntity<UserData> login(@RequestParam(value = "username") String userName,
@RequestParam(value = "password") String password,
public ResponseEntity<UserData> login(@RequestBody LoginCredentials loginCredentials,
HttpServletResponse response) {
Authentication authentication = new UsernamePasswordAuthenticationToken(userName, password);
Authentication authentication = new UsernamePasswordAuthenticationToken(loginCredentials.username(), loginCredentials.password());
try {
authentication = authenticationManager.authenticate(authentication);
} catch (AuthenticationException e) {
Expand All @@ -93,28 +92,29 @@ public ResponseEntity<UserData> login(@RequestParam(value = "username") String u
HttpStatus.UNAUTHORIZED);
}
List<String> roles = authentication.getAuthorities().stream()
.map(authority -> authority.getAuthority()).collect(Collectors.toList());
Session session = findOrCreateSession(userName, roles);
.map(GrantedAuthority::getAuthority).collect(Collectors.toList());
Session session = findOrCreateSession(loginCredentials.username(), roles);
session.setLastAccessedTime(Instant.now());
sessionRepository.save(session);
Cookie cookie = new Cookie(WebSecurityConfig.SESSION_COOKIE_NAME, session.getId());
if(sessionTimeout < 0){
if (sessionTimeout < 0) {
cookie.setMaxAge(ONE_YEAR); // Cannot set infinite on Cookie, so 1 year.
}
else{
} else {
cookie.setMaxAge(60 * sessionTimeout); // sessionTimeout is in minutes.
}
response.addCookie(cookie);
return new ResponseEntity<>(
new UserData(userName, roles),
new UserData(loginCredentials.username(), roles),
HttpStatus.OK);
}

/**
* Deletes a session identified by the session cookie, if present in the request.
*
* @param cookieValue An optional cookie value.
* @return A {@link ResponseEntity} with empty body.
*/
@SuppressWarnings("unused")
@GetMapping(value = "logout")
public ResponseEntity<String> logout(@CookieValue(value = WebSecurityConfig.SESSION_COOKIE_NAME, required = false) String cookieValue) {
if (cookieValue != null) {
Expand All @@ -124,12 +124,13 @@ public ResponseEntity<String> logout(@CookieValue(value = WebSecurityConfig.SESS
}

/**
* Returns a {@link UserData} object populated with user name and roles. If the session cookie
* Returns a {@link UserData} object populated with username and roles. If the session cookie
* is missing from the request, the {@link UserData} object fields are set to <code>null</code>.
*
* @param cookieValue An optional cookie value.
* @return A {@link ResponseEntity} containing {@link UserData}, if any is found.
*/
@SuppressWarnings("unused")
@GetMapping(value = "user")
public ResponseEntity<UserData> getCurrentUser(@CookieValue(value = WebSecurityConfig.SESSION_COOKIE_NAME,
required = false) String cookieValue) {
Expand All @@ -147,24 +148,24 @@ public ResponseEntity<UserData> getCurrentUser(@CookieValue(value = WebSecurityC

/**
* Creates a session or returns an existing one if a non-expired one is found in the session repository.
* This is synchronized so that a user name is always associated with one session, irrespective of the
* This is synchronized so that a username is always associated with one session, irrespective of the
* number of logins from clients.
* @param userName A user name
* @param roles List of user roles
*
* @param userName A username
* @param roles List of user roles
* @return A {@link Session} object.
*/
protected synchronized Session findOrCreateSession(String userName, List<String> roles){
protected synchronized Session findOrCreateSession(String userName, List<String> roles) {
Session session;
Map<String, Session> sessions = sessionRepository.findByPrincipalName(userName);
if(!sessions.isEmpty()){
// Get the first object in the map. Since a given user name should always use the same session,
if (!sessions.isEmpty()) {
// Get the first object in the map. Since a given username should always use the same session,
// the sessions maps should have only one element. However, an existing session may have
// expired, so this must be checked as well.
session = sessions.entrySet().iterator().next().getValue();
if(session.isExpired()){
if (session.isExpired()) {
sessionRepository.deleteById(session.getId());
}
else{
} else {
return session;
}
}
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/org/phoebus/olog/security/LoginCredentials.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Copyright (C) 2024 European Spallation Source ERIC.
*/

package org.phoebus.olog.security;

/**
* Wrapper around user's credentials
*
* @param username Self-explanatory
* @param password Self-explanatory
*/
public record LoginCredentials(String username, String password) {
}
12 changes: 11 additions & 1 deletion src/site/sphinx/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -449,10 +449,20 @@ Create multiple properties
`Javadocs <apidocs/index.html>`_

Authentication
##############

In general, non-GET methods are protected, i.e. client needs to send a basic authentication header for each request.
Alternatively, client may use a session cookie returned upon successful authentication with the login endpoint:

**POST** https://localhost:8181/Olog/login

.. code-block:: json
{"username":"johndoe", "password":"undisclosed"}

Developer Documentation:
#########################
########################

.. toctree::
:maxdepth: 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.phoebus.olog.security.LoginCredentials;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.security.authentication.AuthenticationManager;
Expand Down Expand Up @@ -64,7 +65,9 @@ void testSuccessfullLogin() throws Exception {
when(mockAuthentication.getAuthorities()).thenReturn(authorities);
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "adminPass");
when(authenticationManager.authenticate(authentication)).thenReturn(mockAuthentication);
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login?username=admin&password=adminPass");
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login")
.contentType("application/json")
.content(objectMapper.writeValueAsString(new LoginCredentials("admin", "adminPass")));
MvcResult result = mockMvc.perform(request).andExpect(status().isOk())
.andReturn();
Cookie cookie = result.getResponse().getCookie("SESSION");
Expand Down
20 changes: 13 additions & 7 deletions src/test/java/org/phoebus/olog/AuthenticationResourceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.phoebus.olog.entity.UserData;
import org.phoebus.olog.security.LoginCredentials;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
Expand Down Expand Up @@ -80,7 +81,8 @@ void testSuccessfullLogin() throws Exception {
when(mockAuthentication.getAuthorities()).thenReturn(authorities);
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "adminPass");
when(authenticationManager.authenticate(authentication)).thenReturn(mockAuthentication);
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login?username=admin&password=adminPass");
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login")
.contentType(JSON).content(objectMapper.writeValueAsString(new LoginCredentials("admin", "adminPass")));
MvcResult result = mockMvc.perform(request).andExpect(status().isOk())
.andReturn();
Cookie cookie = result.getResponse().getCookie("SESSION");
Expand All @@ -96,7 +98,9 @@ void testSuccessfullLogin() throws Exception {
// Log in again and verify that the cookie value is the same, i.e. same session on server.
when(mockAuthentication.getAuthorities()).thenReturn(authorities);
when(authenticationManager.authenticate(authentication)).thenReturn(mockAuthentication);
request = post("/" + OLOG_SERVICE + "/login?username=admin&password=adminPass");
request = post("/" + OLOG_SERVICE + "/login")
.contentType(JSON)
.content(objectMapper.writeValueAsString(new LoginCredentials("admin", "adminPass")));
result = mockMvc.perform(request).andExpect(status().isOk())
.andReturn();
Cookie cookie2 = result.getResponse().getCookie("SESSION");
Expand Down Expand Up @@ -129,7 +133,8 @@ void testGetUserNoSession() throws Exception {
when(mockAuthentication.getAuthorities()).thenReturn(authorities);
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "adminPass");
when(authenticationManager.authenticate(authentication)).thenReturn(mockAuthentication);
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login?username=admin&password=adminPass");
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login")
.contentType("application/json").content(objectMapper.writeValueAsString(new LoginCredentials("admin", "adminPass")));
MvcResult result = mockMvc.perform(request).andExpect(status().isOk())
.andReturn();

Expand All @@ -141,7 +146,7 @@ void testGetUserNoSession() throws Exception {
}

@Test
void testSuccessfullFormLogin() throws Exception {
void testFailedFormLogin() throws Exception {
SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_ADMIN");
Authentication mockAuthentication = mock(Authentication.class);
Set authorities = new HashSet();
Expand All @@ -152,15 +157,16 @@ void testSuccessfullFormLogin() throws Exception {
RequestBuilder requestBuilder = formLogin("/" + OLOG_SERVICE + "/login").acceptMediaType(MediaType.APPLICATION_JSON).user("admin").password("adminPass");
mockMvc.perform(requestBuilder)
.andDo(print())
.andExpect(status().isOk())
.andExpect(cookie().exists("SESSION"));
.andExpect(status().isBadRequest());
reset(authenticationManager);
}

@Test
void testFailedLogin() throws Exception {
doThrow(new BadCredentialsException("bad")).when(authenticationManager).authenticate(any(Authentication.class));
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login?username=admin&password=badPass");
MockHttpServletRequestBuilder request = post("/" + OLOG_SERVICE + "/login")
.contentType(JSON)
.content(objectMapper.writeValueAsString(new LoginCredentials("admin", "badPass")));
mockMvc.perform(request).andExpect(status().isUnauthorized());
reset(authenticationManager);
}
Expand Down

0 comments on commit e26352c

Please sign in to comment.