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(인증)] 안금서 미션 제출합니다. #102

Open
wants to merge 10 commits into
base: goldm0ng
Choose a base branch
from
Open
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

implementation 'dev.akkinoc.spring.boot:logback-access-spring-boot-starter:4.0.0'

Expand Down
14 changes: 0 additions & 14 deletions src/main/java/roomescape/ExceptionController.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package roomescape.authentication;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import roomescape.jwt.JwtProvider;
import roomescape.jwt.JwtResponse;
import roomescape.jwt.JwtUtils;

@Component
@RequiredArgsConstructor
public class AuthAdminRoleInterceptor implements HandlerInterceptor {
Copy link

Choose a reason for hiding this comment

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

금서님께서 'login', 'authentication', 'jwt' 패키지를 구분하신 기준이 궁금합니다.
기본 제공된 코드의 구조에 맞추어서 도메인 별로 패키지를 구분하신 것 같은데요,
얼핏 생각하기에 login과 authentication은 뜻 차이가 거의 없고,
jwt는 인증의 수단 중 하나이니 authentication에 포함되는 관계가 아닌가- 라는 생각이 들었어요.


private final JwtProvider jwtProvider;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
JwtResponse jwtResponse = JwtUtils.extractTokenFromCookie(request.getCookies());
if (jwtResponse.accessToken() == null || !isAdmin(jwtResponse.accessToken())) {
response.setStatus(401);
return false;
}

return true;
}

private boolean isAdmin(String token) {
MemberAuthInfo memberAuthInfo = JwtUtils.extractMemberAuthInfoFromToken(token);
return "ADMIN".equals(memberAuthInfo.role());
}
}
28 changes: 28 additions & 0 deletions src/main/java/roomescape/authentication/AuthenticationConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package roomescape.authentication;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class AuthenticationConfig implements WebMvcConfigurer {

private final LoginMemberArgumentResolver loginMemberArgumentResolver;
private final AuthAdminRoleInterceptor authAdminRoleInterceptor;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginMemberArgumentResolver);
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authAdminRoleInterceptor)
.addPathPatterns("/admin/**");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package roomescape.authentication;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import roomescape.jwt.JwtProvider;
import roomescape.jwt.JwtResponse;
import roomescape.jwt.JwtUtils;

@Component
@RequiredArgsConstructor
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

private final JwtProvider jwtProvider;

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(MemberAuthInfo.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest();

JwtResponse jwtResponse = JwtUtils.extractTokenFromCookie(request.getCookies());
if (jwtResponse.accessToken() == null) {
return null;
}

return JwtUtils.extractMemberAuthInfoFromToken(jwtResponse.accessToken());
}
}
6 changes: 6 additions & 0 deletions src/main/java/roomescape/authentication/MemberAuthInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package roomescape.authentication;

public record MemberAuthInfo(
String name,
String role) {
}
31 changes: 31 additions & 0 deletions src/main/java/roomescape/exception/GeneralExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package roomescape.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GeneralExceptionHandler {

@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<String> handleMemberNotFound(MemberNotFoundException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

@ExceptionHandler(JwtValidationException.class)
public ResponseEntity<String> handleJwtValidationException(JwtValidationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

@ExceptionHandler(JwtProviderException.class)
public ResponseEntity<String> handleJwtProviderException(JwtProviderException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGeneralException(Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/exception/JwtProviderException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class JwtProviderException extends RuntimeException {
public JwtProviderException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class JwtValidationException extends RuntimeException {
public JwtValidationException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class MemberNotFoundException extends RuntimeException {
public MemberNotFoundException(String message) {
super(message);
}
}
16 changes: 16 additions & 0 deletions src/main/java/roomescape/exception/PageExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package roomescape.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import roomescape.PageController;

@Slf4j
@ControllerAdvice(assignableTypes = PageController.class)
public class PageExceptionHandler {
@ExceptionHandler(Exception.class)
public String handleException(Exception e) {
log.error("error: " + e.getMessage());
return "error/500"; //view 렌더링 페이지는 만들지 않음
}
}
31 changes: 31 additions & 0 deletions src/main/java/roomescape/jwt/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package roomescape.jwt;

import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import roomescape.exception.JwtProviderException;
import roomescape.member.Member;

@Component
public class JwtProvider {

@Value("${roomescape.auth.jwt.secret}")
private String secretKey;

public JwtResponse createAccessToken(Member member) {
try {
String accessToken = Jwts.builder()
.setSubject(member.getId().toString())
.claim("name", member.getName())
.claim("role", member.getRole())
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.compact();

return new JwtResponse(accessToken);
} catch (JwtException e) {
throw new JwtProviderException("JWT 생성에 실패하였습니다.");
}
}
Copy link

Choose a reason for hiding this comment

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

  1. 이 메서드는 JwtUtils의 static 메서드로 만들지 않고 별도의 JwtProvider로 분리하셨습니다. 이유를 알 수 있을까요?

Copy link

Choose a reason for hiding this comment

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

} catch (JwtException e) {
    throw new JwtProviderException("JWT 생성에 실패하였습니다.");
}

이 코드는 에러 추적의 관점에서 문제가 하나 있습니다. 어떤 문제가 있을까요?

}
6 changes: 6 additions & 0 deletions src/main/java/roomescape/jwt/JwtResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package roomescape.jwt;

public record JwtResponse(
String accessToken
) {
}
65 changes: 65 additions & 0 deletions src/main/java/roomescape/jwt/JwtUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package roomescape.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import roomescape.authentication.MemberAuthInfo;
import roomescape.exception.JwtValidationException;

import java.util.Arrays;

@Component
@RequiredArgsConstructor
public class JwtUtils {

private static String secretKey;

@Value("${roomescape.auth.jwt.secret}")
public void setSecretKey(String secretKey) {
JwtUtils.secretKey = secretKey;
}

public static MemberAuthInfo extractMemberAuthInfoFromToken(String token) {
Copy link

@BackFoxx BackFoxx Jan 1, 2025

Choose a reason for hiding this comment

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

JwtUtils의 메서드는 static 메서드인데 secretKey라는 인스턴스 변수를 필요로 했습니다.
하지만 static 메서드는 인스턴스 변수에 접근할 수 없으니 secretKey 변수를 static한 클래스 변수로 만들었습니다.
스프링은 클래스 변수에 자동 주입을 해주지 않으니 인스턴스 메서드인 setScretKey()를 만들고 스프링이 사용하게끔 우회하셨습니다.

작성하시면서도 느끼셨겠지만 static 메서드에 주위 환경을 끼워맞추는 과정에서 점점 찜찜하고 부자연스러운 구조가 되었습니다.
이 클래스는 스프링이 없으면 단위 테스트를 하기도 어렵고, 동시성 측면에서 위험한 코드이기도 합니다.


자바의 정석 서적을 집필하신 멋쟁이 강사님께서 static 메서드에 대해 설명해주신 영상이 있습니다.
https://youtu.be/Fl4TzjPKAMU?si=JtddEqhCV7B127-A

위 영상을 참고해서 아래 두 질문의 답을 고민해주셨음 좋겠습니다.

  • 자바에서 static 메서드는 어떤 경우에 사용하나요?
  • JwtUtils의 메서드들은 static이어야 하는 메서드인가요? 그 이유는 무엇인가요?

if (token == null || token.isEmpty()) {
throw new JwtValidationException("토큰이 존재하지 않습니다.");
}

try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();

String name = claims.get("name", String.class);
String role = claims.get("role", String.class);

return new MemberAuthInfo(name, role);
} catch (JwtException e) {
throw new JwtValidationException("유효하지 않은 JWT 토큰입니다.");
}
}

public static JwtResponse extractTokenFromCookie(Cookie[] cookies) {
if (cookies == null) {
throw new JwtValidationException("쿠키가 존재하지 않습니다.");
}

try {
String accessToken = Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals("token"))
.map(Cookie::getValue)
.findFirst()
.orElseThrow(() -> new JwtValidationException("토큰이 존재하지 않습니다."));

return new JwtResponse(accessToken);
} catch (Exception e) {
throw new JwtValidationException("쿠키에서 토큰 추출 중 오류가 발생했습니다.");
}
}
}
4 changes: 4 additions & 0 deletions src/main/java/roomescape/login/LoginCheckResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package roomescape.login;

public record LoginCheckResponse(String name) {
}
54 changes: 54 additions & 0 deletions src/main/java/roomescape/login/LoginController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package roomescape.login;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import roomescape.authentication.MemberAuthInfo;
import roomescape.jwt.JwtResponse;
import roomescape.jwt.JwtUtils;

@RestController
@RequiredArgsConstructor
public class LoginController {

private final LoginService loginService;

@PostMapping("/login")
public ResponseEntity<Void> login(@Valid @RequestBody LoginRequest loginRequest, HttpServletResponse response) {
JwtResponse jwtResponse = loginService.login(loginRequest);

Cookie cookie = new Cookie("token", jwtResponse.accessToken());
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);

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

@GetMapping("/login/check")
public ResponseEntity<LoginCheckResponse> checkLogin(HttpServletRequest request) {
Copy link

Choose a reason for hiding this comment

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

일반적으로 컨트롤러의 반환 타입으로 ResponseEntity<>를 사용하면
비즈니스 로직의 상황에 따라 다양한 httpStatus를 지정하여 응답할 수 있다는 장점이 있습니다.

예시) 

try {
    doSomethong();
    return ResponseEntity.ok().build();
} catch (NotLoginException e) {
    return ResponseEntity.status(401).build();
} catch (IllegalStateException e) {
    return ResponseEntity.badRequest().build();
}

그런데 현재 컨트롤러 구조에서는
비즈니스 작업을 수행하다 예외가 발생했을 때
status를 401 또는 500으로 변환하여 응답하는 작업을 모두 ExceptionHandler에서 진행합니다.

그렇다면 LoginController 자체는 httpStatus를 분기하지 않고 언제나 ok를 응답하는데,
응답 타입으로 ResponseEntity를 사용할 필요가 있을까요?

JwtResponse jwtResponse = JwtUtils.extractTokenFromCookie(request.getCookies());
MemberAuthInfo memberAuthInfo = JwtUtils.extractMemberAuthInfoFromToken(jwtResponse.accessToken());
LoginCheckResponse loginCheckResponse = loginService.checkLogin(memberAuthInfo);

return ResponseEntity.ok(loginCheckResponse);
}

@PostMapping("/logout")
public ResponseEntity logout(HttpServletResponse response) {
Cookie cookie = new Cookie("token", "");
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);

return ResponseEntity.ok().build();
}
}
15 changes: 15 additions & 0 deletions src/main/java/roomescape/login/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package roomescape.login;

import jakarta.validation.constraints.NotEmpty;
import lombok.Data;

@Data
public class LoginRequest {

@NotEmpty
private String email;

@NotEmpty
private String password;

}
Copy link

Choose a reason for hiding this comment

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

이 친구는 record 대신 @DaTa를 사용하신 이유가 있나요? :D

Loading