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

[Spring MVC(인증)] 정원영 미션 제출합니다. #117

Merged
merged 33 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3c2620a
feat: 로그인 및 토큰 생성 로직 구현
gardenzeeero Jan 16, 2025
751823c
feat: 토큰 생성 로직 분리
gardenzeeero Jan 16, 2025
0587b38
feat: 로그인 로직 컨트롤러 연결
gardenzeeero Jan 16, 2025
95214f6
feat: 토큰 유효성 검증 메서드 구현
gardenzeeero Jan 16, 2025
e5fa217
feat: 토큰에서 멤버 클레임 추출 구현
gardenzeeero Jan 16, 2025
fcc4e23
feat: 토큰으로 로그인 검증
gardenzeeero Jan 16, 2025
a4c1804
feat: 로그인 검증 로직 컨트롤러 연결
gardenzeeero Jan 16, 2025
e059346
fix: 테스트 실패로 인한 토큰 이름 변경
gardenzeeero Jan 16, 2025
7863060
fix: 토큰 만료시간 설정
gardenzeeero Jan 16, 2025
7c7ee83
fix: Controller를 RestController로 변경
gardenzeeero Jan 18, 2025
b526907
refactor: 토큰키를 생성자에서 초기화하도록 수정
gardenzeeero Jan 18, 2025
95f6ebf
test: TokenService 단위테스트 구현
gardenzeeero Jan 18, 2025
6d28e8f
test: AuthService 단위테스트 구현
gardenzeeero Jan 18, 2025
34a3e9e
test: 테스트 대기시간 삭제
gardenzeeero Jan 25, 2025
c2275a7
fix: 잘못된 이메일 또는 비밀번호 입력시 예외처리
gardenzeeero Jan 25, 2025
c59ae63
test: Mock 없이 AuthServiceTest 수정
gardenzeeero Jan 25, 2025
556d351
test: Mock 없이 TokenServiceTest 수정
gardenzeeero Jan 25, 2025
3ce8e0f
feat: ConfigurationProperties를 이용해 설정 구현
gardenzeeero Jan 25, 2025
7521e7f
refactor: TokenService를 ConfigurationProperties를 이용하도록 변경
gardenzeeero Jan 25, 2025
e9cc2ec
test: JwtConfig를 이용한 테스트 재작성
gardenzeeero Jan 25, 2025
288dc07
feat: email 기반 password 검색 구현
gardenzeeero Jan 25, 2025
9e7f03b
feat: 로그인 시 email, password에 대한 자세한 예외 구현
gardenzeeero Jan 25, 2025
95cdb12
test: 기능 구현에 따른 테스트 재작성
gardenzeeero Jan 25, 2025
bf3068b
refactor: dto에서 Long 대신 long 사용
gardenzeeero Jan 25, 2025
eca6383
fix: 잘못된 쿼리 삭제
gardenzeeero Jan 25, 2025
996a524
feat: 로그인 정보를 담기 위한 DTO 생성
gardenzeeero Jan 23, 2025
2c2ca03
feat: 쿠키를 이용해 로그인 정보를 매핑하는 ArgumentResolver 구현
gardenzeeero Jan 23, 2025
8f96052
feat: 토큰이 없는 경우 예외 발생 구현
gardenzeeero Jan 23, 2025
85e3be5
feat: 구현한 ArgumentResolver 등록
gardenzeeero Jan 24, 2025
4d861b5
feat: ReservationRequest 생성자 구현
gardenzeeero Jan 24, 2025
ec3f8cf
feat: ReservationRequest 및 쿠키를 이용하도록 변경
gardenzeeero Jan 24, 2025
a47ee15
test: 통합 테스트 등록
gardenzeeero Jan 24, 2025
9dd8f56
fix: static 키워드 삭제
gardenzeeero Jan 25, 2025
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
40 changes: 40 additions & 0 deletions src/main/java/roomescape/auth/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package roomescape.auth;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

@RestController
public class AuthController {

private static final String ACCESS_TOKEN_NAME = "token";

private final AuthService authService;

public AuthController(AuthService authService) {
this.authService = authService;
}

@PostMapping("/login")
public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
String token = authService.loginWithEmailAndPassword(loginRequest.email(), loginRequest.password());
gardenzeeero marked this conversation as resolved.
Show resolved Hide resolved
addCookie(response, ACCESS_TOKEN_NAME, token);

return ResponseEntity.ok().build();
}

@GetMapping("/login/check")
public ResponseEntity loginCheck(@CookieValue(ACCESS_TOKEN_NAME) String accessToken) {
return ResponseEntity.ok().body(authService.loginCheckWithToken(accessToken));
}

private void addCookie(HttpServletResponse response, String cookieName, String cookieValue) {
Cookie cookie = new Cookie(cookieName, cookieValue);
cookie.setHttpOnly(true);
cookie.setPath("/");
gardenzeeero marked this conversation as resolved.
Show resolved Hide resolved
response.addCookie(cookie);
}
}
54 changes: 54 additions & 0 deletions src/main/java/roomescape/auth/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package roomescape.auth;

import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.stereotype.Service;
import roomescape.auth.jwt.MemberTokenDto;
import roomescape.auth.jwt.TokenService;
import roomescape.member.Member;
import roomescape.member.MemberDao;

@Service
public class AuthService {

public static final String WRONG_PASSWORD_EXCEPTION_MESSAGE = "잘못된 비밀번호입니다.";
public static final String INVALID_EMAIL_EXCEPTION_MESSAGE = "없는 이메일 입니다.";
public static final String INVALID_TOKEN_EXCEPTION_MESSAGE = "잘못된 토큰입니다.";
private final MemberDao memberDao;
private final TokenService tokenService;


public AuthService(MemberDao memberDao, TokenService tokenService) {
this.memberDao = memberDao;
this.tokenService = tokenService;
}

public String loginWithEmailAndPassword(String email, String password) {

Member member = null;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분에 member 가 존재하는 이유가 있나요? 🙂

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 마지막에 고친부분이 잘못 고친거였네요... 저도 이부분을 보면서 "member가 필요없는데? 쿼리가 두번 나가네?" 하고 바로 삭제해버린것 같습니다. 반환값에 member에 대한 정보가 필요해서 findByEmailAndPassword 반환값을 받으려고 했습니다. 아마 현재 코드로 테스트를 돌리면 통과하지 못할 것 같네요..

푸시하기전에 테스트를 돌리고 푸시했어야했는데 아주 기초적인 실수를 했네요.. 수정하겠습니다!

try {
validatePasswordByEmail(email, password);
} catch (EmptyResultDataAccessException e) {
throw new IllegalArgumentException(INVALID_EMAIL_EXCEPTION_MESSAGE, e);
}

return tokenService.createToken(
new MemberTokenDto(member.getId(), member.getName(), member.getEmail(), member.getRole()));
}

public MemberDetailResponse loginCheckWithToken(String token) {
//유효기간 확인을 위해 필요
gardenzeeero marked this conversation as resolved.
Show resolved Hide resolved
if (!tokenService.checkValidToken(token)) {
throw new IllegalArgumentException(INVALID_TOKEN_EXCEPTION_MESSAGE);
}

MemberTokenDto member = tokenService.getMemberClaims(token);
return new MemberDetailResponse(member.id(), member.name(), member.email(), member.role());
}

private void validatePasswordByEmail(String email, String password) {
String findPassword = memberDao.findPasswordByEmail(email);
if (!findPassword.equals(password)) {
throw new IllegalArgumentException(WRONG_PASSWORD_EXCEPTION_MESSAGE);
}
}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/auth/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.auth;

public record LoginRequest(
String email,
String password
) {
}
9 changes: 9 additions & 0 deletions src/main/java/roomescape/auth/MemberDetailResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package roomescape.auth;

public record MemberDetailResponse(
Long id,
String name,
String email,
String role
) {
}
27 changes: 27 additions & 0 deletions src/main/java/roomescape/auth/jwt/JwtConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package roomescape.auth.jwt;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configuration 을 선언한 이유가 있나요?

Copy link
Author

@gardenzeeero gardenzeeero Jan 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TokenService에서 주입받아서 사용하려고 했습니다!

메서드로 빈을 등록하거나 하지는 않지만 jwt에 대한 설정을 나타내는 것 같기도 해서 붙였습니다. 근데 그냥 @Component를 붙이는게 더 맞는 것 같기도하네요

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 질문한 이유는
ConfigurationProperteis-@EnableConfigurationProperties
로 해도 될거 같은데 Configuration 을 선언해서 질문했습니다.

@ConfigurationProperties("roomescape.auth.jwt")
public class JwtConfig {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

record 여도 될 거 같네용

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigurationProperties를 위해서 setter 메서드가 존재해야하는 줄 알았는데 지금보니 생성자도 지원하게 바뀌었다고 하네요..

private String secret;
private long expiration;

public String getSecret() {
return secret;
}

public long getExpiration() {
return expiration;
}

public void setSecret(String secret) {
this.secret = secret;
}

public void setExpiration(long expiration) {
this.expiration = expiration;
}
}
9 changes: 9 additions & 0 deletions src/main/java/roomescape/auth/jwt/MemberTokenDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package roomescape.auth.jwt;

public record MemberTokenDto(
long id,
String name,
String email,
String role
) {
}
69 changes: 69 additions & 0 deletions src/main/java/roomescape/auth/jwt/TokenService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package roomescape.auth.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;

import java.security.Key;
import java.util.Date;

@Service
public class TokenService {

private static final String NAME_CLAIM = "name";
private static final String EMAIL_CLAIM = "email";
private static final String ROLE_CLAIM = "role";

private final JwtConfig jwtConfig;
private Key key;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final 을 붙여도 괜찮을 거 같아요.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그러네요!


public TokenService(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
this.key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes());
}

public String createToken(MemberTokenDto memberTokenDto) {
return Jwts.builder()
.setSubject(String.valueOf(memberTokenDto.id()))
.claim(NAME_CLAIM, memberTokenDto.name())
.claim(EMAIL_CLAIM, memberTokenDto.email())
.claim(ROLE_CLAIM, memberTokenDto.role())
.setExpiration(new Date(System.currentTimeMillis() + jwtConfig.getExpiration()))
.signWith(key)
.compact();
}

public boolean checkValidToken(String token) {
try {
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);

return claims.getBody().getExpiration().after(new Date());
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}

public MemberTokenDto getMemberClaims(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();

return new MemberTokenDto(
Long.valueOf(claims.getSubject()),
claims.get(NAME_CLAIM).toString(),
claims.get(EMAIL_CLAIM).toString(),
claims.get(ROLE_CLAIM).toString());
} catch (JwtException | IllegalArgumentException e) {
throw new IllegalArgumentException("잘못된 토큰입니다.");
}
}
}
8 changes: 8 additions & 0 deletions src/main/java/roomescape/member/MemberDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ public Member findByEmailAndPassword(String email, String password) {
);
}

public String findPasswordByEmail(String email) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생각해보니 아직 JPA 가 아니군요.😭
비밀번호 검증 부분은 나중에 JPA 부분에서 다시 도전해봐도 될 거 같아요.

return jdbcTemplate.queryForObject(
"SELECT password FROM member WHERE email = ?",
String.class,
email
);
}

public Member findByName(String name) {
return jdbcTemplate.queryForObject(
"SELECT id, name, email, role FROM member WHERE name = ?",
Expand Down
79 changes: 79 additions & 0 deletions src/test/java/roomescape/auth/AuthServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package roomescape.auth;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import roomescape.member.Member;
import roomescape.member.MemberDao;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
@Transactional

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 코드에 Transactional 을 붙인 이유가 있나요?
( 틀린게 아니라, 의견을 묻는 겁니당. )

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 현재 상황에 좋은 방식은 아니라고 생각합니다.

붙인 이유

  • beforeEach로 save를 호출하니 동일한 member가 있다고 예외가 터져서 입니다.
  • 생성자로 초기화 해주려고 했으나 무슨 이유에선지 MemberDao가 주입되기 전에 자꾸 save를 시도해서 방법을 바꾸었습니다.
  • 물론 event로 처리할 수 있지만 가장 간단한 Transactional 을 붙였습니다.

안좋다고 생각하는 이유

  1. 테스트에서 조회만 하고 수정은 안하는데 Transaction을 사용할 이유가 없다. (리소스 낭비)
  2. 테스트 DB에 Member가 있었다면 조회를 위한 setUp()을 안해도 됐을 것이다. (사실 이게 제일 좋은 방법이라고 생각합니다.)

현재 상황외

@Transactional을 테스트에 붙이는 것이 좋은 것인가는 조금 고민을 해봐야할 것 같습니다. 습관적으로 붙인다면 전파 옵션이 Requires_new인 상황에 매우 복잡해집니다. 그래서 저는 습관적으로 @Transactional 붙이는 것을 지양하고 있습니다. 가능하면 로직을 다 알고 있을 때 해당 어노테이션을 붙이는게 좋은 것 같아요 😁

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에서 말한 내용들도 맞긴 하지만
가장 큰 문제점은 테스트 메소드가 트랜잭션으로 묶여서 기존 로직과 의도하지 않은 결과가 나올 수 있는걸로 알고 있어요.

이에 대해선 테스트에 Transactional 만 쳐도 많이 나올거니 제가 자세히 말하는건 생략할게요 🙂


저희 팀은 로직이 엔티티의 특정 상태를 바꾸는 것이라면 @Transactional 을 사용했던거 같아용.

그리고

습관적으로 붙인다면 전파 옵션이 Requires_new인 상황에 매우 복잡해집니다.

해당 부분과 관련해서
JPA Transactional 잘 알고 쓰고 계신가요?

성능적으로도 측정한 아티클 있어서 남기고 갑니다~

class AuthServiceTest {

@Autowired
private MemberDao memberDao;
@Autowired
private AuthService authService;

private Member member;

@BeforeEach
void setUp() {
member = new Member("testName", "[email protected]", "testPassword", "user");
memberDao.save(member);
}

@Test
@DisplayName("이메일 및 비밀번호로 로그인 성공")
void loginWithEmailAndPassword_Success() {
String actualToken = authService.loginWithEmailAndPassword(member.getEmail(), member.getPassword());

assertThat(actualToken).isNotBlank();
}

@Test
@DisplayName("유효하지 않은 이메일")
void loginWithEmailAndPassword_Failure_InvalidEmail() {
assertThatThrownBy(() -> authService.loginWithEmailAndPassword("invalid_email", "invalid_password"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(AuthService.INVALID_EMAIL_EXCEPTION_MESSAGE);
}

@Test
@DisplayName("이메일은 맞으나 잘못된 비밀번호")
void loginWithEmailAndPassword_Failure_WrongPassword() {
assertThatThrownBy(() -> authService.loginWithEmailAndPassword(member.getEmail(), "invalid_password"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(AuthService.WRONG_PASSWORD_EXCEPTION_MESSAGE);
}

@Test
@DisplayName("토큰으로 로그인 성공")
void loginCheckWithToken_Success() {
String token = authService.loginWithEmailAndPassword(member.getEmail(), member.getPassword());

MemberDetailResponse response = authService.loginCheckWithToken(token);

assertThat(response).isNotNull();
assertThat(response.id()).isNotNull();
assertThat(response.name()).isEqualTo(member.getName());
assertThat(response.email()).isEqualTo(member.getEmail());
assertThat(response.role()).isEqualTo(member.getRole());
}

@Test
@DisplayName("잘못된 토큰으로 로그인시 실패")
void loginCheckWithToken_Failure_InvalidToken() {
String token = "invalid_token";

assertThatThrownBy(() -> authService.loginCheckWithToken(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("잘못된 토큰입니다.");
}
}
Loading