From b17dddfca956aac0aab666eb49dd1b20d9a71b16 Mon Sep 17 00:00:00 2001 From: JesseBrault0709 <62299747+JesseBrault0709@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:48:47 +0200 Subject: [PATCH] RefreshToken refresh() method/endpoint and logic. --- .../api/auth/AuthController.java | 56 +++++++++--- .../mealsmadeeasy/api/auth/AuthService.java | 2 + .../api/auth/AuthServiceImpl.java | 67 ++++++++++++-- .../mealsmadeeasy/api/auth/RefreshToken.java | 11 +++ .../api/auth/RefreshTokenEntity.java | 78 ++++++++++++++++ .../api/auth/RefreshTokenRepository.java | 9 ++ .../app/mealsmadeeasy/api/jwt/JwtService.java | 9 ++ .../api/{security => jwt}/JwtServiceImpl.java | 26 +++--- .../mealsmadeeasy/api/security/AuthToken.java | 21 +---- .../mealsmadeeasy/api/security/JwtFilter.java | 19 ++-- .../api/security/JwtService.java | 6 -- .../api/security/SecurityConfiguration.java | 21 ++--- .../api/security/SimpleAuthToken.java | 23 +++++ .../api/auth/AuthControllerTests.java | 89 +++++++++++++++++++ 14 files changed, 356 insertions(+), 81 deletions(-) create mode 100644 src/main/java/app/mealsmadeeasy/api/auth/RefreshToken.java create mode 100644 src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenEntity.java create mode 100644 src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenRepository.java create mode 100644 src/main/java/app/mealsmadeeasy/api/jwt/JwtService.java rename src/main/java/app/mealsmadeeasy/api/{security => jwt}/JwtServiceImpl.java (64%) delete mode 100644 src/main/java/app/mealsmadeeasy/api/security/JwtService.java create mode 100644 src/main/java/app/mealsmadeeasy/api/security/SimpleAuthToken.java create mode 100644 src/test/java/app/mealsmadeeasy/api/auth/AuthControllerTests.java diff --git a/src/main/java/app/mealsmadeeasy/api/auth/AuthController.java b/src/main/java/app/mealsmadeeasy/api/auth/AuthController.java index cbb8d65..2f55559 100644 --- a/src/main/java/app/mealsmadeeasy/api/auth/AuthController.java +++ b/src/main/java/app/mealsmadeeasy/api/auth/AuthController.java @@ -1,5 +1,6 @@ package app.mealsmadeeasy.api.auth; +import app.mealsmadeeasy.api.security.AuthToken; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; @@ -10,6 +11,17 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/auth") public final class AuthController { + private static ResponseCookie getRefreshTokenCookie(String token, long maxAge) { + final ResponseCookie.ResponseCookieBuilder b = ResponseCookie.from("refresh-token") + .httpOnly(true) + .secure(true) + .maxAge(maxAge); + if (token != null) { + b.value(token); + } + return b.build(); + } + private final AuthService authService; public AuthController(AuthService authService) { @@ -20,13 +32,12 @@ public final class AuthController { public ResponseEntity login(@RequestBody LoginBody loginBody) { try { final LoginDetails loginDetails = this.authService.login(loginBody.getUsername(), loginBody.getPassword()); - final String serializedToken = loginDetails.getRefreshToken().getToken(); - final ResponseCookie refreshCookie = ResponseCookie.from("refresh-token", serializedToken) - .httpOnly(true) - .secure(true) - .maxAge(loginDetails.getRefreshToken().getLifetime()) - .build(); - final LoginView loginView = new LoginView( + final AuthToken refreshToken = loginDetails.getRefreshToken(); + final ResponseCookie refreshCookie = getRefreshTokenCookie( + refreshToken.getToken(), + refreshToken.getLifetime() + ); + final var loginView = new LoginView( loginDetails.getUsername(), loginDetails.getAccessToken().getToken() ); return ResponseEntity.ok() @@ -37,13 +48,32 @@ public final class AuthController { } } + @PostMapping("/refresh") + public ResponseEntity refresh( + @CookieValue(value = "refresh-token") String oldRefreshToken + ) { + try { + final LoginDetails loginDetails = this.authService.refresh(oldRefreshToken); + final AuthToken newRefreshToken = loginDetails.getRefreshToken(); + final ResponseCookie refreshCookie = getRefreshTokenCookie( + newRefreshToken.getToken(), + newRefreshToken.getLifetime() + ); + final var loginView = new LoginView(loginDetails.getUsername(), loginDetails.getAccessToken().getToken()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body(loginView); + } catch (LoginException loginException) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } + @PostMapping("/logout") - public ResponseEntity logout(@CookieValue("refresh-token") String refreshToken) { - final ResponseCookie deleteRefreshCookie = ResponseCookie.from("refresh-token") - .httpOnly(true) - .secure(true) - .maxAge(0) - .build(); + public ResponseEntity logout(@CookieValue(value = "refresh-token", required = false) String refreshToken) { + if (refreshToken != null) { + this.authService.logout(refreshToken); + } + final ResponseCookie deleteRefreshCookie = getRefreshTokenCookie(null, 0); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, deleteRefreshCookie.toString()) .build(); diff --git a/src/main/java/app/mealsmadeeasy/api/auth/AuthService.java b/src/main/java/app/mealsmadeeasy/api/auth/AuthService.java index 2c40370..c85f5d0 100644 --- a/src/main/java/app/mealsmadeeasy/api/auth/AuthService.java +++ b/src/main/java/app/mealsmadeeasy/api/auth/AuthService.java @@ -2,4 +2,6 @@ package app.mealsmadeeasy.api.auth; public interface AuthService { LoginDetails login(String username, String password) throws LoginException; + void logout(String refreshToken); + LoginDetails refresh(String refreshToken) throws LoginException; } diff --git a/src/main/java/app/mealsmadeeasy/api/auth/AuthServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/auth/AuthServiceImpl.java index 89f37f3..6d3bb1f 100644 --- a/src/main/java/app/mealsmadeeasy/api/auth/AuthServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/auth/AuthServiceImpl.java @@ -1,34 +1,91 @@ package app.mealsmadeeasy.api.auth; -import app.mealsmadeeasy.api.security.JwtService; +import app.mealsmadeeasy.api.jwt.JwtService; +import app.mealsmadeeasy.api.user.UserEntity; +import io.jsonwebtoken.JwtException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.UUID; + @Service public final class AuthServiceImpl implements AuthService { private final AuthenticationManager authenticationManager; private final JwtService jwtService; + private final RefreshTokenRepository refreshTokenRepository; + private final long refreshTokenLifetime; - public AuthServiceImpl(AuthenticationManager authenticationManager, JwtService jwtService) { + public AuthServiceImpl( + AuthenticationManager authenticationManager, + JwtService jwtService, + RefreshTokenRepository refreshTokenRepository, + @Value("${app.mealsmadeeasy.api.security.refresh-token-lifetime}") Long refreshTokenLifetime + ) { this.authenticationManager = authenticationManager; this.jwtService = jwtService; + this.refreshTokenRepository = refreshTokenRepository; + this.refreshTokenLifetime = refreshTokenLifetime; + } + + private RefreshToken createRefreshToken(UserEntity principal) { + final RefreshTokenEntity refreshTokenDraft = new RefreshTokenEntity(); + refreshTokenDraft.setToken(UUID.randomUUID().toString()); + refreshTokenDraft.setIssued(LocalDateTime.now()); + refreshTokenDraft.setExpiration(LocalDateTime.now().plusSeconds(this.refreshTokenLifetime)); + refreshTokenDraft.setOwner(principal); + return this.refreshTokenRepository.save(refreshTokenDraft); } @Override public LoginDetails login(String username, String password) throws LoginException { try { - this.authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( + final Authentication authentication = this.authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( username, password )); + final UserEntity principal = (UserEntity) authentication.getPrincipal(); return new LoginDetails( username, this.jwtService.generateAccessToken(username), - this.jwtService.generateRefreshToken(username) + this.createRefreshToken(principal) ); - } catch (Exception e) { + } catch (AuthenticationException e) { + throw new LoginException(e); + } + } + + @Override + public void logout(String refreshToken) { + this.refreshTokenRepository.findByToken(refreshToken).ifPresent(this.refreshTokenRepository::delete); + } + + @Override + public LoginDetails refresh(String refreshToken) throws LoginException { + try { + final RefreshTokenEntity old = this.refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new LoginException("No such refresh-token: " + refreshToken)); + if (old.isRevoked()) { + throw new LoginException("RefreshToken is revoked."); + } + if (old.getExpiration().isBefore(LocalDateTime.now())) { + throw new LoginException("RefreshToken is expired."); + } + final UserEntity principal = old.getOwner(); + this.refreshTokenRepository.delete(old); + + final String username = principal.getUsername(); + return new LoginDetails( + username, + this.jwtService.generateAccessToken(username), + this.createRefreshToken(principal) + ); + } catch (JwtException e) { throw new LoginException(e); } } diff --git a/src/main/java/app/mealsmadeeasy/api/auth/RefreshToken.java b/src/main/java/app/mealsmadeeasy/api/auth/RefreshToken.java new file mode 100644 index 0000000..9981290 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/auth/RefreshToken.java @@ -0,0 +1,11 @@ +package app.mealsmadeeasy.api.auth; + +import app.mealsmadeeasy.api.security.AuthToken; + +import java.time.LocalDateTime; + +public interface RefreshToken extends AuthToken { + LocalDateTime getIssued(); + LocalDateTime getExpiration(); + boolean isRevoked(); +} diff --git a/src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenEntity.java b/src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenEntity.java new file mode 100644 index 0000000..e7d8ebe --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenEntity.java @@ -0,0 +1,78 @@ +package app.mealsmadeeasy.api.auth; + +import app.mealsmadeeasy.api.user.UserEntity; +import jakarta.persistence.*; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +@Entity(name = "RefreshToken") +public class RefreshTokenEntity implements RefreshToken { + + @Id + @Column(unique = true, nullable = false) + private String token; + + @Column(nullable = false) + private LocalDateTime issued; + + @Column(nullable = false) + private LocalDateTime expiration; + + @Column(nullable = false) + private Boolean revoked = false; + + @JoinColumn(nullable = false) + @ManyToOne + private UserEntity owner; + + @Override + public String getToken() { + return this.token; + } + + public void setToken(String token) { + this.token = token; + } + + @Override + public LocalDateTime getIssued() { + return this.issued; + } + + public void setIssued(LocalDateTime issued) { + this.issued = issued; + } + + @Override + public LocalDateTime getExpiration() { + return this.expiration; + } + + public void setExpiration(LocalDateTime expiration) { + this.expiration = expiration; + } + + @Override + public boolean isRevoked() { + return this.revoked; + } + + public void setRevoked(Boolean revoked) { + this.revoked = revoked; + } + + public UserEntity getOwner() { + return this.owner; + } + + public void setOwner(UserEntity owner) { + this.owner = owner; + } + + @Override + public long getLifetime() { + return ChronoUnit.SECONDS.between(this.issued, this.expiration); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenRepository.java b/src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenRepository.java new file mode 100644 index 0000000..d7f1f7f --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/auth/RefreshTokenRepository.java @@ -0,0 +1,9 @@ +package app.mealsmadeeasy.api.auth; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); +} diff --git a/src/main/java/app/mealsmadeeasy/api/jwt/JwtService.java b/src/main/java/app/mealsmadeeasy/api/jwt/JwtService.java new file mode 100644 index 0000000..d0194f6 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/jwt/JwtService.java @@ -0,0 +1,9 @@ +package app.mealsmadeeasy.api.jwt; + +import app.mealsmadeeasy.api.security.AuthToken; +import io.jsonwebtoken.JwtException; + +public interface JwtService { + AuthToken generateAccessToken(String username); + String getSubject(String token) throws JwtException; +} diff --git a/src/main/java/app/mealsmadeeasy/api/security/JwtServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/jwt/JwtServiceImpl.java similarity index 64% rename from src/main/java/app/mealsmadeeasy/api/security/JwtServiceImpl.java rename to src/main/java/app/mealsmadeeasy/api/jwt/JwtServiceImpl.java index 6b1cc2f..79a1926 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/JwtServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/jwt/JwtServiceImpl.java @@ -1,6 +1,9 @@ -package app.mealsmadeeasy.api.security; +package app.mealsmadeeasy.api.jwt; +import app.mealsmadeeasy.api.security.AuthToken; +import app.mealsmadeeasy.api.security.SimpleAuthToken; import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Serializer; import io.jsonwebtoken.jackson.io.JacksonSerializer; @@ -17,18 +20,15 @@ public final class JwtServiceImpl implements JwtService { private final Serializer> serializer; private final long accessTokenLifetime; - private final long refreshTokenLifetime; private final SecretKey secretKey; public JwtServiceImpl( ObjectMapper objectMapper, @Value("${app.mealsmadeeasy.api.security.access-token-lifetime}") Long accessTokenLifetime, - @Value("${app.mealsmadeeasy.api.security.refresh-token-lifetime}") Long refreshTokenLifetime, SecretKey secretKey ) { this.serializer = new JacksonSerializer<>(); this.accessTokenLifetime = accessTokenLifetime; - this.refreshTokenLifetime = refreshTokenLifetime; this.secretKey = secretKey; } @@ -42,20 +42,16 @@ public final class JwtServiceImpl implements JwtService { .signWith(this.secretKey) .json(this.serializer) .compact(); - return new AuthToken(token, this.accessTokenLifetime); + return new SimpleAuthToken(token, this.accessTokenLifetime); } @Override - public AuthToken generateRefreshToken(String username) { - final Instant now = Instant.now(); - final String token = Jwts.builder() - .subject(username) - .issuedAt(Date.from(now)) - .expiration(Date.from(now.plusSeconds(this.refreshTokenLifetime))) - .signWith(this.secretKey) - .json(this.serializer) - .compact(); - return new AuthToken(token, this.refreshTokenLifetime); + public String getSubject(String token) throws JwtException { + final var jws = Jwts.parser() + .verifyWith(this.secretKey) + .build() + .parseSignedClaims(token); + return jws.getPayload().getSubject(); } } diff --git a/src/main/java/app/mealsmadeeasy/api/security/AuthToken.java b/src/main/java/app/mealsmadeeasy/api/security/AuthToken.java index 0a17a8a..10af099 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/AuthToken.java +++ b/src/main/java/app/mealsmadeeasy/api/security/AuthToken.java @@ -1,21 +1,6 @@ package app.mealsmadeeasy.api.security; -public final class AuthToken { - - private final String token; - private final long lifetime; - - public AuthToken(String token, long lifetime) { - this.token = token; - this.lifetime = lifetime; - } - - public String getToken() { - return this.token; - } - - public long getLifetime() { - return this.lifetime; - } - +public interface AuthToken { + String getToken(); + long getLifetime(); } diff --git a/src/main/java/app/mealsmadeeasy/api/security/JwtFilter.java b/src/main/java/app/mealsmadeeasy/api/security/JwtFilter.java index 41bdd07..44a7302 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/JwtFilter.java +++ b/src/main/java/app/mealsmadeeasy/api/security/JwtFilter.java @@ -1,29 +1,32 @@ package app.mealsmadeeasy.api.security; -import io.jsonwebtoken.Jwts; +import app.mealsmadeeasy.api.jwt.JwtService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import javax.crypto.SecretKey; import java.io.IOException; +@Lazy +@Component public final class JwtFilter extends OncePerRequestFilter { - private final SecretKey secretKey; private final UserDetailsService userDetailsService; + private final JwtService jwtService; - public JwtFilter(SecretKey secretKey, UserDetailsService userDetailsService) { - this.secretKey = secretKey; + public JwtFilter(UserDetailsService userDetailsService, JwtService jwtService) { this.userDetailsService = userDetailsService; + this.jwtService = jwtService; } @Override @@ -38,11 +41,7 @@ public final class JwtFilter extends OncePerRequestFilter { if (authorizationHeader.startsWith("Bearer ") && authorizationHeader.length() > 7) { final String token = authorizationHeader.substring(7); - final var jws = Jwts.parser() - .verifyWith(this.secretKey) - .build() - .parseSignedClaims(token); - final String username = jws.getPayload().getSubject(); + final String username = this.jwtService.getSubject(token); final UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); final var authenticationToken = new UsernamePasswordAuthenticationToken( userDetails, diff --git a/src/main/java/app/mealsmadeeasy/api/security/JwtService.java b/src/main/java/app/mealsmadeeasy/api/security/JwtService.java deleted file mode 100644 index 06e9d7e..0000000 --- a/src/main/java/app/mealsmadeeasy/api/security/JwtService.java +++ /dev/null @@ -1,6 +0,0 @@ -package app.mealsmadeeasy.api.security; - -public interface JwtService { - AuthToken generateAccessToken(String username); - AuthToken generateRefreshToken(String username); -} diff --git a/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java b/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java index f886cce..83d00b6 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java +++ b/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java @@ -1,6 +1,7 @@ package app.mealsmadeeasy.api.security; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.BeanFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -12,29 +13,26 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import javax.crypto.SecretKey; - @Configuration @EnableWebSecurity public class SecurityConfiguration { - private final SecretKey secretKey; private final JpaUserDetailsService jpaUserDetailsService; + private final BeanFactory beanFactory; - public SecurityConfiguration(SecretKey secretKey, JpaUserDetailsService jpaUserDetailsService) { - this.secretKey = secretKey; + public SecurityConfiguration(JpaUserDetailsService jpaUserDetailsService, BeanFactory beanFactory) { this.jpaUserDetailsService = jpaUserDetailsService; + this.beanFactory = beanFactory; } @Bean public WebSecurityCustomizer webSecurityCustomizer() { - return web -> web.ignoring().requestMatchers("/greeting", "/auth/login", "/auth/logout"); + return web -> web.ignoring().requestMatchers("/greeting", "/auth/**"); } @Bean @@ -51,17 +49,12 @@ public class SecurityConfiguration { }); }); httpSecurity.addFilterBefore( - new JwtFilter(this.secretKey, this.jpaUserDetailsService), + this.beanFactory.getBean(JwtFilter.class), UsernamePasswordAuthenticationFilter.class ); return httpSecurity.build(); } - @Bean - public UserDetailsService userDetailsService() { - return this.jpaUserDetailsService; - } - @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(10); @@ -70,7 +63,7 @@ public class SecurityConfiguration { @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { final var provider = new DaoAuthenticationProvider(); - provider.setUserDetailsService(this.userDetailsService()); + provider.setUserDetailsService(this.jpaUserDetailsService); provider.setPasswordEncoder(this.passwordEncoder()); return provider; } diff --git a/src/main/java/app/mealsmadeeasy/api/security/SimpleAuthToken.java b/src/main/java/app/mealsmadeeasy/api/security/SimpleAuthToken.java new file mode 100644 index 0000000..f15d698 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/security/SimpleAuthToken.java @@ -0,0 +1,23 @@ +package app.mealsmadeeasy.api.security; + +public final class SimpleAuthToken implements AuthToken { + + private final String token; + private final long lifetime; + + public SimpleAuthToken(String token, long lifetime) { + this.token = token; + this.lifetime = lifetime; + } + + @Override + public String getToken() { + return this.token; + } + + @Override + public long getLifetime() { + return this.lifetime; + } + +} diff --git a/src/test/java/app/mealsmadeeasy/api/auth/AuthControllerTests.java b/src/test/java/app/mealsmadeeasy/api/auth/AuthControllerTests.java new file mode 100644 index 0000000..f87559e --- /dev/null +++ b/src/test/java/app/mealsmadeeasy/api/auth/AuthControllerTests.java @@ -0,0 +1,89 @@ +package app.mealsmadeeasy.api.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +public class AuthControllerTests { + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MockMvc mockMvc; + + private MockHttpServletRequestBuilder getLoginRequest() throws Exception { + final Map body = Map.of( + "username", "test", + "password", "test" + ); + return post("/auth/login") + .content(this.objectMapper.writeValueAsString(body)) + .contentType(MediaType.APPLICATION_JSON) + .with(user("test").password("test")); + } + + @Test + public void simpleLogin() throws Exception { + this.mockMvc.perform(this.getLoginRequest()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.accessToken").isString()) + .andExpect(cookie().exists("refresh-token")); + } + + private Cookie getRefreshTokenCookie() throws Exception { + final MvcResult loginResult = this.mockMvc.perform(this.getLoginRequest()).andReturn(); + final Cookie refreshTokenCookie = loginResult.getResponse().getCookie("refresh-token"); + if (refreshTokenCookie == null) { + throw new NullPointerException("refreshTokenCookie is null"); + } + return refreshTokenCookie; + } + + @Test + public void simpleLogout() throws Exception { + final MockHttpServletRequestBuilder req = post("/auth/logout") + .cookie(this.getRefreshTokenCookie()); + this.mockMvc.perform(req) + .andExpect(status().isOk()) + .andExpect(cookie().maxAge("refresh-token", 0)); + } + + @Test + public void simpleRefresh() throws Exception { + final Cookie firstRefreshTokenCookie = this.getRefreshTokenCookie(); + final MockHttpServletRequestBuilder req = post("/auth/refresh") + .cookie(firstRefreshTokenCookie); + final MvcResult res = this.mockMvc.perform(req) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.accessToken").isString()) + .andExpect(cookie().exists("refresh-token")) + .andReturn(); + final Cookie secondRefreshTokenCookie = res.getResponse().getCookie("refresh-token"); + if (secondRefreshTokenCookie == null) { + throw new NullPointerException("secondRefreshTokenCookie is null"); + } + assertThat(firstRefreshTokenCookie.getValue(), is(not(secondRefreshTokenCookie.getValue()))); + } + +}