Skip to content
Open
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
Binary file added .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
.DS_Store
Copy link

Choose a reason for hiding this comment

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

요거 추가했는데 .DS_Store은 PR에 들어있네요?

.application.properties

### STS ###
.apt_generated
Expand Down
Binary file added src/.DS_Store
Binary file not shown.
Binary file added src/main/.DS_Store
Binary file not shown.
Binary file added src/main/java/.DS_Store
Binary file not shown.
58 changes: 58 additions & 0 deletions src/main/java/roomescape/auth/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package roomescape.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
public class JwtProvider {

@Value("${roomescape.auth.jwt.secret}")
private String SECRET_KEY;
Comment on lines +14 to +15
Copy link

Choose a reason for hiding this comment

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

여기를 생성자 주입을 통해 불변으로 만들겠다고 하신거죠? 고민 좋네요 👍


private static final long EXPIRATION_MS = 1000 * 60 * 60 * 24;

public String generateToken(Long memberId, String email, String role) {
return Jwts.builder()
.setSubject(String.valueOf(memberId))
.claim("email", email)
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}

public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
Comment on lines +30 to +39
Copy link

Choose a reason for hiding this comment

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

일반적으로 validate~ 라는 네이밍을 가진 메서드의 경우 특정 조건이 유효함을 검증한다 라고 쓰이기 때문에 boolean 반환을 기대하는 것은 어색해보여요. 예외를 그대로 던지는 형태로 사용하거나, isValid 정도로 네이밍해보는 것은 어떨까요?


public String getRoleFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();

return claims.get("role", String.class);
}

public String getEmailFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();

return claims.get("email", String.class);
}
}
24 changes: 24 additions & 0 deletions src/main/java/roomescape/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package roomescape.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import roomescape.interceptor.AdminInterceptor;

@Configuration
public class WebConfig implements WebMvcConfigurer {

private final AdminInterceptor adminInterceptor;

@Autowired
public WebConfig(AdminInterceptor adminInterceptor) {
this.adminInterceptor = adminInterceptor;
}
Comment on lines +14 to +17
Copy link

Choose a reason for hiding this comment

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

스프링 4.3 버전 이후부터는 단일 생성자에 대해서는 @Autowired를 명시하지 않더라도 자동으로 의존성 주입이 이루어집니다!


@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**");
}
}
47 changes: 47 additions & 0 deletions src/main/java/roomescape/controller/LoginController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package roomescape.controller;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import roomescape.auth.JwtProvider;
import roomescape.login.LoginRequest;
import roomescape.member.Member;
import roomescape.member.MemberDao;

@RestController
public class LoginController {

private final MemberDao memberDao;
Copy link

Choose a reason for hiding this comment

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

생각해보기) 우리가 지금 계층형 구조(흔히 레이어드 아키텍쳐라고 하죠)를 채택하고 사용하고 있는데, 표현 계층인 컨트롤러가 서비스를 건너뛰고 바로 DAO를 사용하는 것에는 어떤 문제가 있을까요?

물론 이 문제에 대해서는 의존 방향이 아래로 향하기만 하면 문제가 없다는 의견도 있기 때문에 반드시 DAO를 여기서 쓰는 것이 틀렸다고 리뷰드리는 것은 아닙니다!

private final JwtProvider jwtProvider;

public LoginController(MemberDao memberDao, JwtProvider jwtProvider) {
this.memberDao = memberDao;
this.jwtProvider = jwtProvider;
}

@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
try {
Member member = memberDao.findByEmailAndPassword(
loginRequest.getEmail(),
loginRequest.getPassword()
);

String token = jwtProvider.generateToken(
member.getId(),
member.getEmail(),
member.getRole()
);

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

return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.status(401).build();
Copy link

Choose a reason for hiding this comment

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

401 Unauthorized 는 요청한 리소스에 대해 권한이 없을 때 를 나타내는 HTTP 코드인데요, 개인적으로는 로그인 실패에 이 상태코드가 맞는지는 조금 의문이 듭니다! 로그인 자체는 권한이 필요한게 아니라 권한이 얻기 위해 아이디와 비밀번호를 검증하는 과정이기 때문에 그렇습니다. 어떠한 이유로 401이라는 상태코드를 사용하셨나요?

}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package roomescape;
package roomescape.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down
41 changes: 41 additions & 0 deletions src/main/java/roomescape/interceptor/AdminInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package roomescape.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import roomescape.auth.JwtProvider;

@Component
public class AdminInterceptor implements HandlerInterceptor {

private final JwtProvider jwtProvider;

public AdminInterceptor(JwtProvider jwtProvider) {
this.jwtProvider = jwtProvider;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = extractTokenFromCookies(request);

if (token == null || !jwtProvider.validateToken(token) || !"ADMIN".equals(jwtProvider.getRoleFromToken(token))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}

return true;
}

private String extractTokenFromCookies(HttpServletRequest request) {
if (request.getCookies() == null) return null;

for (var cookie : request.getCookies()) {
Comment on lines +31 to +33
Copy link

Choose a reason for hiding this comment

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

사소하지만 request.getCookies() 를 미리 변수에 할당해두면 두 번 호출을 피할 수 있겠군요

if ("token".equals(cookie.getName())) {
return cookie.getValue();
}
}

return null;
}
}
20 changes: 20 additions & 0 deletions src/main/java/roomescape/login/LoginMember.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package roomescape.login;

public class LoginMember {
private final Long id;
private final String name;
private final String email;
private final String role;

public LoginMember(Long id, String name, String email, String role) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}

public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getRole() { return role; }
}
65 changes: 65 additions & 0 deletions src/main/java/roomescape/login/LoginMemberArgumentResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package roomescape.login;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import roomescape.member.Member;
import roomescape.member.MemberDao;

public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

private final String secretKey;
private final MemberDao memberDao;

public LoginMemberArgumentResolver(String secretKey, MemberDao memberDao) {
this.secretKey = secretKey;
this.memberDao = memberDao;
}

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

@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {

HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
if (request == null || request.getCookies() == null) {
return null;
}

String token = "";
for (Cookie cookie : request.getCookies()) {
if ("token".equals(cookie.getName())) {
token = cookie.getValue();
break;
}
}
if (token.isBlank()) {
return null;
}

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

Long memberId = Long.valueOf(claims.getSubject());
Member member = memberDao.findById(memberId)
.orElseThrow(() -> new IllegalStateException("회원을 찾을 수 없습니다"));

return new LoginMember(member.getId(), member.getName(), member.getEmail(), member.getRole());
}
}
14 changes: 14 additions & 0 deletions src/main/java/roomescape/login/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package roomescape.login;

public class LoginRequest {
private String email;
private String password;

public String getEmail() {
return email;
}

public String getPassword() {
return password;
}
}
31 changes: 31 additions & 0 deletions src/main/java/roomescape/member/MemberDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,44 @@
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public class MemberDao {
private JdbcTemplate jdbcTemplate;

public MemberDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Optional<Member> findByEmail(String email) {
List<Member> results = jdbcTemplate.query(
"SELECT id, name, email, role FROM member WHERE email = ?",
(rs, rowNum) -> new Member(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email"),
rs.getString("role")
),
email
);
return results.stream().findFirst();
}


public Optional<Member> findById(Long id) {
List<Member> results = jdbcTemplate.query(
"SELECT id, name, email, role FROM member WHERE id = ?",
(rs, rowNum) -> new Member(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email"),
rs.getString("role")
),
id
);
return results.stream().findFirst();
}

public Member save(Member member) {
KeyHolder keyHolder = new GeneratedKeyHolder();
Expand Down
15 changes: 13 additions & 2 deletions src/main/java/roomescape/member/MemberService.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
package roomescape.member;

import org.springframework.stereotype.Service;
import roomescape.auth.JwtProvider;

@Service
public class MemberService {
private MemberDao memberDao;
private final MemberDao memberDao;
private final JwtProvider jwtProvider;

public MemberService(MemberDao memberDao) {
public MemberService(MemberDao memberDao, JwtProvider jwtProvider) {
this.memberDao = memberDao;
this.jwtProvider = jwtProvider;
}

public MemberResponse createMember(MemberRequest memberRequest) {
Member member = memberDao.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), "USER"));
return new MemberResponse(member.getId(), member.getName(), member.getEmail());
}

public Member findByToken(String token) {
String email = jwtProvider.getEmailFromToken(token);
if (email == null) {
return null;
}
return memberDao.findByEmail(email).orElse(null);
}
Comment on lines +21 to +27
Copy link

Choose a reason for hiding this comment

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

Member 라는 도메인만 보면 토큰이라는 정보를 모르는데요! Member가 토큰을 받아서 그 토큰 안에 이메일이라는 것이 있다는 것까지 알고 이메일을 파싱해서 조회해도 괜찮은걸까요? 회원이라는 도메인이 어디까지 책임을 가져야 하는가에 대해서 고민해보면 좋을 것 같아요!

}
Loading