Skip to content

Commit

Permalink
feat: revoke jwt tokens of modified, deleted or logged out users
Browse files Browse the repository at this point in the history
Introduce JWT token revocation functionality by storing issued tokens in a "whitelist" table and removing all tokens corresponding to a user for following actions:
 - User is deleted
 - User is updated (e.g. gets different role, changes password etc.)
 - User logs out

Furthermore, a scheduled task was added, to remove expired tokens from the whitelist table.

Refs: iris-connect/iris-backlog#90
  • Loading branch information
Fabio Aversente authored May 29, 2021
1 parent 596c7b8 commit cd1ffb9
Show file tree
Hide file tree
Showing 15 changed files with 3,311 additions and 2,473 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package iris.client_bff.auth.db;

import static iris.client_bff.auth.db.SecurityConstants.BEARER_TOKEN_PREFIX;

import iris.client_bff.auth.db.jwt.JWTService;
import lombok.AllArgsConstructor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Service;

import com.auth0.jwt.interfaces.DecodedJWT;

@Service
@AllArgsConstructor
public class CustomLogoutHandler implements LogoutHandler {

private final JWTService jwtService;

@Override
public void logout(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
Authentication authentication) {
String header = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION);

if (StringUtils.isEmpty(header) || !header.startsWith(BEARER_TOKEN_PREFIX)) {
return;
}
var token = header.replace(BEARER_TOKEN_PREFIX, "");
DecodedJWT jwt = jwtService.verify(token);
jwtService.invalidateTokensOfUser(jwt.getSubject());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@
import iris.client_bff.users.UserDetailsServiceImpl;
import lombok.AllArgsConstructor;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;

@AllArgsConstructor
@EnableWebSecurity
Expand All @@ -32,6 +35,9 @@ public class DbAuthSecurityAdapter extends WebSecurityConfigurerAdapter {
"/v3/api-docs/**"
};

@Autowired
private CustomLogoutHandler logoutHandler;

private PasswordEncoder passwordEncoder;

private JWTVerifier jwtVerifier;
Expand All @@ -50,6 +56,11 @@ protected void configure(HttpSecurity http) throws Exception {
.antMatchers(HttpMethod.POST, "/data-submission-rpc").permitAll()
.anyRequest().authenticated()
.and()
.logout()
.logoutUrl("/user/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
.and()
.addFilter(
new JWTAuthenticationFilter(authenticationManager(), jwtSigner))
.addFilter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,14 @@ protected void successfulAuthentication(HttpServletRequest req,
// By convention we expect that there exists only one authority and it represents the role
var role = user.getAuthorities().stream().findFirst().get().getAuthority();

Date expirationTime = new Date(System.currentTimeMillis() + EXPIRATION_TIME);
String token = jwtSigner.sign(JWT.create()
.withSubject(user.getUsername())
.withClaim(JWT_CLAIM_USER_ROLE, role)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)));
.withExpiresAt(expirationTime));

// Whitelist the token
jwtSigner.saveToken(token, user.getUsername(), expirationTime.toInstant());

res.addHeader(AUTHENTICATION_INFO, BEARER_TOKEN_PREFIX + token);
res.addHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, AUTHENTICATION_INFO);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package iris.client_bff.auth.db;

import static iris.client_bff.auth.db.SecurityConstants.*;
import static iris.client_bff.auth.db.SecurityConstants.BEARER_TOKEN_PREFIX;
import static iris.client_bff.auth.db.SecurityConstants.JWT_CLAIM_USER_ROLE;

import iris.client_bff.auth.db.jwt.JWTVerifier;

Expand Down Expand Up @@ -59,11 +60,10 @@ private UserAccountAuthentication authenticate(String token) {

var userName = jwt.getSubject();

if (userName != null) {
if (userName != null && jwtVerifier.isTokenWhitelisted(token)) {
var authority = new SimpleGrantedAuthority(jwt.getClaim(JWT_CLAIM_USER_ROLE).asString());
return new UserAccountAuthentication(userName, true, List.of(authority));
}
return null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package iris.client_bff.auth.db.jwt;

import lombok.*;

import java.io.Serializable;
import java.time.Instant;

import javax.persistence.*;

@Entity
@Table(name = "allowed_tokens")
@Data
public class AllowedToken implements Serializable {

@Id
private String jwtTokenDigest;

@Column(nullable = false)
private String userName;

@Column(nullable = false)
private Instant expirationTime;

@Column(nullable = false)
private Instant created;
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package iris.client_bff.auth.db.jwt;

import java.time.Instant;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;

public interface AllowedTokenRepository extends JpaRepository<AllowedToken, String> {
Optional<AllowedToken> findByJwtTokenDigest(String token);

@Transactional
void deleteByUserName(String userName);

@Transactional
void deleteByExpirationTimeBefore(Instant expirationTime);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@

import lombok.AllArgsConstructor;

import java.time.Instant;
import java.util.Optional;

import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator.Builder;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;

@Service
@AllArgsConstructor
@Component
@ConditionalOnProperty(
value = "security.auth",
havingValue = "db")
public class JWTService implements JWTVerifier, JWTSigner {

private final AllowedTokenRepository allowedTokenRepository;

private JwtProperties jwtProperties;

@Override
Expand All @@ -33,4 +39,30 @@ private Algorithm getAlgorithm() {
return Algorithm.HMAC512(jwtProperties.getJwtSharedSecret());
}

@Override
public void saveToken(String token, String userName, Instant expirationTime) {
var hashedToken = new AllowedToken();
hashedToken.setJwtTokenDigest(hashToken(token));
hashedToken.setUserName(userName);
hashedToken.setExpirationTime(expirationTime);
allowedTokenRepository.save(hashedToken);
}

@Override
public boolean isTokenWhitelisted(String token) {
Optional<AllowedToken> hashedToken = allowedTokenRepository.findByJwtTokenDigest(hashToken(token));
return hashedToken.isPresent();
}

public void invalidateTokensOfUser(String userName) {
allowedTokenRepository.deleteByUserName(userName);
}

public void removeExpiredTokens() {
allowedTokenRepository.deleteByExpirationTimeBefore(Instant.now());
}

private String hashToken(String jwt) {
return DigestUtils.sha256Hex(jwt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import com.auth0.jwt.JWTCreator.Builder;

import java.time.Instant;

public interface JWTSigner {

String sign(Builder builder);

void saveToken(String jwtTokenDigest, String userName, Instant expirationTime);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public interface JWTVerifier {

DecodedJWT verify(String jwt);

boolean isTokenWhitelisted(String jwtTokenDigest);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package iris.client_bff.auth.db.jwt;

import lombok.AllArgsConstructor;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

@Service
@Configuration
@EnableScheduling
@AllArgsConstructor
public class JWTWhitelistCleanup {

private final JWTService jwtService;
private final long DELETION_RATE = 30*60*1000; // 30 minutes

@Scheduled(fixedDelay = DELETION_RATE)
public void clean() {
jwtService.removeExpiredTokens();
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package iris.client_bff.users;

import iris.client_bff.auth.db.jwt.JWTService;
import iris.client_bff.users.entities.UserAccount;
import iris.client_bff.users.entities.UserRole;
import iris.client_bff.users.web.dto.UserInsertDTO;
Expand Down Expand Up @@ -31,6 +32,8 @@ public class UserDetailsServiceImpl implements UserDetailsService {

private final PasswordEncoder passwordEncoder;

private final JWTService jwtService;

@Override
public UserDetails loadUserByUsername(String username) {
UserAccount userAccount = userAccountsRepository.findByUserName(username)
Expand Down Expand Up @@ -68,6 +71,7 @@ public UserAccount update(UUID userId, UserUpdateDTO userUpdateDTO) {
log.error(error);
throw new RuntimeException(error);
}
jwtService.invalidateTokensOfUser(optional.get().getUserName());

var userAccount = optional.get();
userAccount.setLastName(userUpdateDTO.getLastName());
Expand All @@ -93,6 +97,10 @@ public UserAccount update(UUID userId, UserUpdateDTO userUpdateDTO) {

public void deleteById(UUID id) {
log.info("Delete user: {}", id);
var optional = userAccountsRepository.findById(id);
if (optional.isPresent()) {
jwtService.invalidateTokensOfUser(optional.get().getUserName());
}
userAccountsRepository.deleteById(id);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE allowed_tokens (
jwt_token_digest varchar(255) primary key,
user_name varchar(50) NOT NULL,
expiration_time timestamp NOT NULL,
created timestamp default now()
);
2 changes: 1 addition & 1 deletion iris-client-fe/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export default Vue.extend({
},
methods: {
logoutUser() {
this.$store.commit("userLogin/setSession");
this.$store.dispatch("userLogin/logout");
},
},
});
Expand Down
Loading

0 comments on commit cd1ffb9

Please sign in to comment.