Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import com.sofa.linkiving.domain.member.dto.request.LoginReq;
import com.sofa.linkiving.domain.member.dto.request.SignupReq;
import com.sofa.linkiving.domain.member.dto.response.TokenRes;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.global.common.BaseResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Tag(name = "User")
public interface MemberApi {
Expand All @@ -15,4 +18,7 @@ public interface MemberApi {

@Operation(summary = "로그인", description = "이메일, 비밀번호를 통해 로그인을 진행합니다.")
BaseResponse<TokenRes> login(LoginReq req);

@Operation(summary = "로그아웃", description = "리프레시 토큰을 무효화하고 로그아웃 처리합니다.")
BaseResponse<String> logout(Member member, HttpServletRequest request, HttpServletResponse response);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.sofa.linkiving.domain.member.controller;

import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -9,9 +11,13 @@
import com.sofa.linkiving.domain.member.dto.request.LoginReq;
import com.sofa.linkiving.domain.member.dto.request.SignupReq;
import com.sofa.linkiving.domain.member.dto.response.TokenRes;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.domain.member.service.MemberService;
import com.sofa.linkiving.global.common.BaseResponse;
import com.sofa.linkiving.security.annotation.AuthMember;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RestController
Expand All @@ -36,4 +42,28 @@ public BaseResponse<TokenRes> login(@Validated @RequestBody LoginReq req) {

return BaseResponse.success(login, "로그인에 성공하였습니다.");
}

@Override
@PostMapping("/logout")
public BaseResponse<String> logout(@AuthMember Member member, HttpServletRequest request,
HttpServletResponse response) {
memberService.logout(member);
expireCookie(request, response, "accessToken");
expireCookie(request, response, "refreshToken");
return BaseResponse.noContent("로그아웃에 성공하였습니다.");
}

private void expireCookie(HttpServletRequest request, HttpServletResponse response, String name) {
String domain = request.getServerName();
boolean isLocal = "localhost".equals(domain) || "127.0.0.1".equals(domain);

ResponseCookie cookie = ResponseCookie.from(name, "")
.path("/")
.maxAge(0)
.httpOnly(!isLocal)
.secure(!isLocal)
.sameSite("Lax")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.domain.member.error.MemberErrorCode;
import com.sofa.linkiving.global.error.exception.BusinessException;
import com.sofa.linkiving.infra.redis.RedisKeyRegistry;
import com.sofa.linkiving.infra.redis.RedisService;
import com.sofa.linkiving.security.jwt.JwtTokenProvider;

import lombok.RequiredArgsConstructor;
Expand All @@ -23,6 +25,7 @@ public class MemberService {
private final MemberCommandService memberCommandService;
private final MemberQueryService memberQueryService;
private final JwtTokenProvider jwtTokenProvider;
private final RedisService redisService;

public TokenRes signup(SignupReq req) {

Expand Down Expand Up @@ -59,4 +62,8 @@ public TokenRes login(LoginReq req) {

return TokenRes.of(accessToken, refreshToken);
}

public void logout(Member member) {
redisService.delete(RedisKeyRegistry.REFRESH_TOKEN, member.getEmail());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public class SecurityConfig {
"/ws/chat/**",

/* temp */
"/v1/member/**", "/mock/**",
"/v1/member/signup", "/v1/member/login", "/mock/**",

/* oauth2 */
"/oauth2/**"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
package com.sofa.linkiving.domain.member.integration;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.*;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;

import org.junit.jupiter.api.DisplayName;
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.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sofa.linkiving.domain.member.dto.request.LoginReq;
import com.sofa.linkiving.domain.member.dto.request.SignupReq;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.domain.member.enums.Role;
import com.sofa.linkiving.domain.member.error.MemberErrorCode;
import com.sofa.linkiving.domain.member.repository.MemberRepository;
import com.sofa.linkiving.infra.redis.RedisKeyRegistry;
import com.sofa.linkiving.infra.redis.RedisService;
import com.sofa.linkiving.security.userdetails.CustomMemberDetail;

@SpringBootTest
@AutoConfigureMockMvc
Expand Down Expand Up @@ -180,4 +188,96 @@ void shouldFailWhenIncorrectPassword() throws Exception {
.andExpect(jsonPath("$.message").value(MemberErrorCode.INCORRECT_PASSWORD.getMessage()))
.andExpect(jsonPath("$.data").value(MemberErrorCode.INCORRECT_PASSWORD.getCode()));
}

@Test
@DisplayName("로그아웃 시 로컬 환경에서는 HttpOnly/Secure 없이 쿠키가 만료된다")
void shouldExpireCookiesWithoutSecureFlagsOnLocalhost() throws Exception {
// given
Member member = memberRepository.save(Member.builder()
.email("logout-local@test.com")
.password("password")
.build());
CustomMemberDetail userDetails = new CustomMemberDetail(member, Role.USER);

// when
MvcResult result = mockMvc.perform(post(BASE_URL + "/logout")
.with(csrf())
.with(user(userDetails))
.with(request -> {
request.setServerName("localhost");
return request;
})
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("로그아웃에 성공하였습니다."))
.andReturn();

// then
verify(redisService).delete(RedisKeyRegistry.REFRESH_TOKEN, member.getEmail());

List<String> setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE);
assertThat(setCookies).hasSize(2);

String accessTokenCookie = setCookies.stream()
.filter(cookie -> cookie.startsWith("accessToken="))
.findFirst()
.orElseThrow();
String refreshTokenCookie = setCookies.stream()
.filter(cookie -> cookie.startsWith("refreshToken="))
.findFirst()
.orElseThrow();

assertThat(accessTokenCookie).contains("Max-Age=0", "Path=/", "SameSite=Lax");
assertThat(refreshTokenCookie).contains("Max-Age=0", "Path=/", "SameSite=Lax");
assertThat(accessTokenCookie).doesNotContain("HttpOnly");
assertThat(accessTokenCookie).doesNotContain("Secure");
assertThat(refreshTokenCookie).doesNotContain("HttpOnly");
assertThat(refreshTokenCookie).doesNotContain("Secure");
}

@Test
@DisplayName("로그아웃 시 운영 환경에서는 HttpOnly/Secure 쿠키로 만료된다")
void shouldExpireCookiesWithSecureFlagsOnNonLocalhost() throws Exception {
// given
Member member = memberRepository.save(Member.builder()
.email("logout-prod@test.com")
.password("password")
.build());
CustomMemberDetail userDetails = new CustomMemberDetail(member, Role.USER);

// when
MvcResult result = mockMvc.perform(post(BASE_URL + "/logout")
.with(csrf())
.with(user(userDetails))
.with(request -> {
request.setServerName("example.com");
return request;
})
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("로그아웃에 성공하였습니다."))
.andReturn();

// then
verify(redisService).delete(RedisKeyRegistry.REFRESH_TOKEN, member.getEmail());

List<String> setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE);
assertThat(setCookies).hasSize(2);

String accessTokenCookie = setCookies.stream()
.filter(cookie -> cookie.startsWith("accessToken="))
.findFirst()
.orElseThrow();
String refreshTokenCookie = setCookies.stream()
.filter(cookie -> cookie.startsWith("refreshToken="))
.findFirst()
.orElseThrow();

assertThat(accessTokenCookie).contains("Max-Age=0", "Path=/", "SameSite=Lax");
assertThat(refreshTokenCookie).contains("Max-Age=0", "Path=/", "SameSite=Lax");
assertThat(accessTokenCookie).contains("HttpOnly");
assertThat(accessTokenCookie).contains("Secure");
assertThat(refreshTokenCookie).contains("HttpOnly");
assertThat(refreshTokenCookie).contains("Secure");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.domain.member.error.MemberErrorCode;
import com.sofa.linkiving.global.error.exception.BusinessException;
import com.sofa.linkiving.infra.redis.RedisService;
import com.sofa.linkiving.security.jwt.JwtTokenProvider;

@ExtendWith(MockitoExtension.class)
Expand All @@ -32,6 +33,8 @@ public class MemberServiceTest {
MemberCommandService memberCommandService;
@Mock
JwtTokenProvider jwtTokenProvider;
@Mock
RedisService redisService;
@InjectMocks
MemberService memberService;
@Mock
Expand Down Expand Up @@ -143,4 +146,17 @@ void shouldThrowIncorrectPasswordErrorCodeWhenPasswordNotMatch() {

verify(memberQueryService, times(1)).getUser(email);
}

@Test
@DisplayName("로그아웃 시 Redis에 저장된 refresh token 삭제")
void shouldDeleteRefreshTokenOnLogout() {
// given
Member member = Member.builder().email("test@test.com").password("pw").build();

// when
memberService.logout(member);

// then
verify(redisService, times(1)).delete(any(), eq(member.getEmail()));
}
}