Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update login endpoint to avoid credentials in URL #200

Merged
merged 2 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading