Skip to content

Commit

Permalink
OP-1337 | Add token refresh mechanism (#471)
Browse files Browse the repository at this point in the history
* First version of refresh token

* Add tests

* Format code

* Improve testing

* Add login success test

* Add TODO for future enhancements

* Improve comments

* Remove unneeded logger

* Remove old comment

* Improve UserHelper

* Update OpenAPI spec

* Remove unnecessary @Autowired

* Apply code review suggestions

* Use JUnit5

* Refresh the refreshToken

* Fix import

* Replace ResponseEntity with OHAPIException

* Update oh.yaml
  • Loading branch information
mwithi authored Oct 3, 2024
1 parent 3f579ed commit a6c4f13
Show file tree
Hide file tree
Showing 10 changed files with 2,804 additions and 2,368 deletions.
4,592 changes: 2,310 additions & 2,282 deletions openapi/oh.yaml

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/main/java/org/isf/login/dto/LoginRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public class LoginRequest {
@Schema(description = "Password of user", example = "admin")
private String password;

public LoginRequest(String username, String password) {
this.username = username;
this.password = password;
}

public String getUsername() {
return username;
}
Expand Down
18 changes: 15 additions & 3 deletions src/main/java/org/isf/login/dto/LoginResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,22 @@ public class LoginResponse {

@Schema(description = "Token")
private String token;


@Schema(description = "RefreshToken")
private String refreshToken;

@Schema(description = "Type of Token", example = "Bearer")
private String type = "Bearer";

@Schema(description = "User name", example = "admin")
private String username;

public LoginResponse() {
}

public LoginResponse(String token, String username) {
public LoginResponse(String token, String refreshToken, String username) {
this.token = token;
this.refreshToken = refreshToken;
this.username = username;
}

Expand All @@ -51,6 +55,14 @@ public void setToken(String token) {
this.token = token;
}

public String getRefreshToken() {
return refreshToken;
}

public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}

public String getType() {
return type;
}
Expand Down
42 changes: 42 additions & 0 deletions src/main/java/org/isf/login/dto/TokenRefreshRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Open Hospital (www.open-hospital.org)
* Copyright © 2006-2024 Informatici Senza Frontiere ([email protected])
*
* Open Hospital is a free and open source software for healthcare data management.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* https://www.gnu.org/licenses/gpl-3.0-standalone.html
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.isf.login.dto;

public class TokenRefreshRequest {

private String refreshToken;

public TokenRefreshRequest() {
}

public TokenRefreshRequest(String refreshToken) {
this.refreshToken = refreshToken;
}

public String getRefreshToken() {
return refreshToken;
}

public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
50 changes: 41 additions & 9 deletions src/main/java/org/isf/login/rest/LoginController.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,20 @@

import org.isf.login.dto.LoginRequest;
import org.isf.login.dto.LoginResponse;
import org.isf.login.dto.TokenRefreshRequest;
import org.isf.menu.manager.UserBrowsingManager;
import org.isf.menu.model.User;
import org.isf.security.CustomAuthenticationManager;
import org.isf.security.jwt.TokenProvider;
import org.isf.security.jwt.TokenValidationResult;
import org.isf.sessionaudit.manager.SessionAuditManager;
import org.isf.sessionaudit.model.SessionAudit;
import org.isf.sessionaudit.model.UserSession;
import org.isf.shared.exceptions.OHAPIException;
import org.isf.utils.exception.OHServiceException;
import org.isf.utils.exception.model.OHExceptionMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
Expand All @@ -49,37 +51,46 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import io.jsonwebtoken.JwtException;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;

@RestController(value = "/auth")
@RestController
@Tag(name = "Login")
@SecurityRequirement(name = "bearerAuth")
public class LoginController {

@Autowired
private HttpSession httpSession;

@Autowired
private SessionAuditManager sessionAuditManager;

@Autowired
private TokenProvider tokenProvider;

@Autowired
private CustomAuthenticationManager authenticationManager;

@Autowired
private UserBrowsingManager userManager;

private static final Logger LOGGER = LoggerFactory.getLogger(LoginController.class);

public LoginController(HttpSession httpSession,
SessionAuditManager sessionAuditManager,
TokenProvider tokenProvider,
CustomAuthenticationManager authenticationManager,
UserBrowsingManager userManager) {
this.httpSession = httpSession;
this.sessionAuditManager = sessionAuditManager;
this.tokenProvider = tokenProvider;
this.authenticationManager = authenticationManager;
this.userManager = userManager;
}

@PostMapping(value = "/auth/login", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<LoginResponse> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) throws OHAPIException {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.generateJwtToken(authentication, true);
String jwt = tokenProvider.generateJwtToken(authentication, false); // use the shorter validity
String refreshToken = tokenProvider.generateRefreshToken(authentication);

String userDetails = (String) authentication.getPrincipal();
User user;
Expand All @@ -97,6 +108,27 @@ public ResponseEntity<LoginResponse> authenticateUser(@Valid @RequestBody LoginR
LOGGER.error("Unable to log user login in the session_audit table");
}

return ResponseEntity.ok(new LoginResponse(jwt, userDetails));
return ResponseEntity.ok(new LoginResponse(jwt, refreshToken, userDetails));
}

@PostMapping("/auth/refresh-token")
public ResponseEntity<LoginResponse> refreshToken(@RequestBody TokenRefreshRequest request) throws OHAPIException {
String refreshToken = request.getRefreshToken();

try {
if (tokenProvider.validateToken(refreshToken) == TokenValidationResult.VALID) {
String username = tokenProvider.getUsernameFromToken(refreshToken);
Authentication authentication = tokenProvider.getAuthenticationByUsername(username);
String newAccessToken = tokenProvider.generateJwtToken(authentication, false);
String newRefreshToken = tokenProvider.generateRefreshToken(authentication);

return ResponseEntity.ok(new LoginResponse(newAccessToken, newRefreshToken, username));
} else {
throw new OHAPIException(new OHExceptionMessage("Invalid Refresh Token"));
}
} catch (JwtException e) {
throw new OHAPIException(new OHExceptionMessage("Refresh token expired or invalid"));
}
}

}
71 changes: 37 additions & 34 deletions src/main/java/org/isf/security/UserDetailsServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,44 +41,47 @@
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

private static final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
private static final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

@Autowired
protected UserBrowsingManager manager;
@Autowired
protected UserBrowsingManager manager;

@Autowired
@Autowired
protected PermissionManager permissionManager;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user;
try {
user = manager.getUserByName(username);
} catch (OHServiceException serviceException) {
LOGGER.error("User login received an unexpected OHServiceException.", serviceException);
throw new UsernameNotFoundException(username + " authentication failed.", serviceException);
}
if (user == null) {
throw new UsernameNotFoundException(username + " was not found.");
}

List<SimpleGrantedAuthority> authorities = new ArrayList<>();
List<Permission> permissions;
try {
permissions = permissionManager.retrievePermissionsByUsername(username);
} catch (OHServiceException serviceException) {
LOGGER.error("Retrieving permissions for user received an unexpected OHServiceException.", serviceException);
throw new UsernameNotFoundException(username + " authentication failed.", serviceException);
}
for (Permission p : permissions) {
authorities.add(new SimpleGrantedAuthority(p.getName()));
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user;
try {
user = manager.getUserByName(username);
} catch (OHServiceException serviceException) {
LOGGER.error("User login received an unexpected OHServiceException.", serviceException);
throw new UsernameNotFoundException(username + " authentication failed.", serviceException);
}
if (user == null) {
throw new UsernameNotFoundException(username + " was not found.");
}

org.springframework.security.core.userdetails.User userDetails =
new org.springframework.security.core.userdetails.User(
user.getUserName(), user.getPasswd(), true, true, true, true, authorities
);
return userDetails;
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
List<Permission> permissions;
try {
permissions = permissionManager.retrievePermissionsByUsername(username);
} catch (OHServiceException serviceException) {
LOGGER.error("Retrieving permissions for user received an unexpected OHServiceException.", serviceException);
throw new UsernameNotFoundException(username + " authentication failed.", serviceException);
}
for (Permission p : permissions) {
authorities.add(new SimpleGrantedAuthority(p.getName()));
}

org.springframework.security.core.userdetails.User userDetails = new org.springframework.security.core.userdetails.User(
/*
* TODO: to pass same {@link User} information for:
*
* boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked
*/
user.getUserName(), user.getPasswd(), true, true, true, true, authorities);
return userDetails;
}

}
39 changes: 37 additions & 2 deletions src/main/java/org/isf/security/jwt/TokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

import jakarta.annotation.PostConstruct;

import org.isf.security.UserDetailsServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -41,6 +42,7 @@
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
Expand Down Expand Up @@ -71,15 +73,21 @@ public class TokenProvider implements Serializable {

private JwtParser jwtParser;

@Autowired
private UserDetailsServiceImpl userDetailsService;

@PostConstruct
public void init() {
String secret = env.getProperty("jwt.token.secret");
LOGGER.info("Initializing JWT key with secret: {}", secret);
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
this.key = Keys.hmacShaKeyFor(keyBytes);

this.tokenValidityInMilliseconds = 1000L * 6000;
this.tokenValidityInMillisecondsForRememberMe = 1000L * 6000;
// 30 minutes (900,000 milliseconds)
this.tokenValidityInMilliseconds = 1000L * 60 * 30;

// 3 days (604,800,000 milliseconds)
this.tokenValidityInMillisecondsForRememberMe = 1000L * 60 * 60 * 24 * 3;

this.jwtParser = Jwts.parserBuilder().setSigningKey(this.key).build();
}
Expand Down Expand Up @@ -137,14 +145,33 @@ public String generateJwtToken(Authentication authentication, boolean rememberMe
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.setIssuedAt(new Date())
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}

public String generateRefreshToken(Authentication authentication) {
return Jwts.builder()
.setSubject(authentication.getName())
.setIssuedAt(new Date())
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(new Date(System.currentTimeMillis() + this.tokenValidityInMillisecondsForRememberMe))
.compact();
}

public Authentication getAuthentication(String token) {
final Claims claims = getAllClaimsFromToken(token);

/*
* claims.get(AUTHORITIES_KEY) cannot be null, at least an empty string Left for security but not testable
*/
String authoritiesClaim = claims.get(AUTHORITIES_KEY) != null ? claims.get(AUTHORITIES_KEY).toString() : "";
if (authoritiesClaim.isEmpty()) {
LOGGER.error("JWT token does not contain any authorities");
throw new IllegalArgumentException("JWT token does not contain authorities.");
}

final Collection< ? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
Expand All @@ -154,9 +181,17 @@ public Authentication getAuthentication(String token) {
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}

public Authentication getAuthenticationByUsername(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}

public TokenValidationResult validateToken(String token) {
try {
Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
/*
* If claims.getSubject() not null for sure is not empy. Left here for security but not testable
*/
if (claims.getSubject() == null || claims.getSubject().isEmpty()) {
throw new IllegalArgumentException("JWT claims string is empty.");
}
Expand Down
Loading

0 comments on commit a6c4f13

Please sign in to comment.