Skip to content

Commit

Permalink
Merge pull request #36 from kakao-tech-campus-2nd-step3/Master
Browse files Browse the repository at this point in the history
9조 BE 코드리뷰 2회차
  • Loading branch information
leaf-upper authored Oct 6, 2024
2 parents 10acf67 + 0eace57 commit 5617e33
Show file tree
Hide file tree
Showing 50 changed files with 1,582 additions and 19 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
HELP.md
.gradle

build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/main/resource/application.yaml
!**/src/test/**/build/

### STS ###
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@

## 리뷰사항
연휴 이후 다음주 부터 본격적인 개발에 들어가려 합니다. 열심히하겠습니다! 감사합니다.



# 5주차 github 코드리뷰 질문
(윤재용)
몇몇 컨트롤러에 대한 E2E 테스트를 작성하였습니다.
처음에는 @WithMockUser 를 사용해서 테스트를 진행하려고 했는데, Header를 검증하다보니 불가능했습니다.
저희의 요구사항이 특정 url이 아니라면 헤더에 토큰이 필요하다보니 사용이 불가능하였기에 JwtTestUtils 클래스를 통해 테스트 유저를 사용하였습니다.

이런 전체 테스트를 처음 구현하다 보니
현재 작성한 테스트가 E2E 테스트라고 불려도 될 지 잘 모르겠습니다..!
추가로 테스트코드의 개선점이 있을지 궁금합니다.
37 changes: 32 additions & 5 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
buildscript {
ext {
spring_boot_version = '3.3.3'
spring_dependency_management = '1.1.6'
}

repositories {
mavenCentral()
}
}

plugins {
id 'java'
id 'org.springframework.boot' version '3.3.3'
id 'io.spring.dependency-management' version '1.1.6'
id 'org.springframework.boot' version "${spring_boot_version}"
id 'io.spring.dependency-management' version "${spring_dependency_management}"
}

group = 'com.helpmeCookies'
Expand All @@ -24,17 +35,33 @@ repositories {
}

dependencies {

// Spring
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-security'

// Lombok
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'

// DB
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// Spring docs
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// Monitoring
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
}

tasks.named('test') {
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/helpmeCookies/global/config/JpaConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.helpmeCookies.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
125 changes: 125 additions & 0 deletions src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.helpmeCookies.global.jwt;

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

import javax.crypto.spec.SecretKeySpec;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

@Component
public class JwtProvider implements InitializingBean {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expire-time}")
private long accessTokenExpireTime;
@Value("${jwt.refresh-token-expire-time}")
private long refreshTokenExpireTime;
private Key secretKey;
private static final String ROLE = "role";
private static final String IS_ACCESS_TOKEN = "isAccessToken";
private static final String HEADER_PREFIX = "Bearer ";

public String parseHeader(String header) {
if (header == null || header.isEmpty()) {
throw new IllegalArgumentException("Authorization 헤더가 없습니다.");
} else if (!header.startsWith(HEADER_PREFIX)) {
throw new IllegalArgumentException("Authorization 올바르지 않습니다.");
} else if (header.split(" ").length != 2) {
throw new IllegalArgumentException("Authorization 올바르지 않습니다.");
}

return header.split(" ")[1];
}

public JwtToken createToken(JwtUser jwtUser) {
String accessToken = generateToken(jwtUser, true);
String refreshToken = generateToken(jwtUser, false);
return JwtToken.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}

// 유요한 토큰인지 확인
public boolean validateToken(String rawToken, boolean isAccessToken) {
try {
// 엑세스 토큰인지 확인
Claims claims = extractClaims(rawToken);
if (claims.get(IS_ACCESS_TOKEN, Boolean.class) != isAccessToken) {
return false;
}
// 만료시간 확인
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}

/**
* refreshToken을 통해, accessToken을 재발급하는 메서드.
* refreshToken의 유효성을 검사하고, isAccessToken이 true일때만 accessToken을 재발급한다.
* TODO: refreshToken을 저장하고, 저장된 refreshToken과 비교하는 로직 필요
*/
public String reissueAccessToken(String refreshToken) {
Claims claims = extractClaims(refreshToken);
if (claims.get(IS_ACCESS_TOKEN, Boolean.class)) {
throw new IllegalArgumentException("리프레시 토큰이 아닙니다.");
}
JwtUser jwtUser = claimsToJwtUser(claims);
return generateToken(jwtUser, true);
}

/**
* [validateToken] 이후 호출하는 메서드.
* rawToken을 통해 JwtUser를 추출한다.
* [jwtUser]는 userId와 role을 가지고 있다. 즉 JWT에 저장된 정보를 추출한다.
*/
public JwtUser getJwtUser(String rawToken) {
Claims claims = extractClaims(rawToken);
return claimsToJwtUser(claims);
}

private JwtUser claimsToJwtUser(Claims claims) {
String userId = claims.getSubject();
return JwtUser.of(Long.parseLong(userId));
}

/**
* Jwt 토큰생성
* accessToken과 refreshToken의 다른점은 만료시간과, isAccessToken이다.
*/
private String generateToken(JwtUser jwtUser, boolean isAccessToken) {
long expireTime = isAccessToken ? accessTokenExpireTime : refreshTokenExpireTime;
Date expireDate = new Date(System.currentTimeMillis() + expireTime);
return Jwts.builder()
.signWith(secretKey)
.claim(IS_ACCESS_TOKEN, isAccessToken)
.setSubject(jwtUser.getId().toString())
.setExpiration(expireDate)
.compact();
}


private Claims extractClaims(String rawToken) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(rawToken)
.getBody();
}

/**
* HS256방식의 키를 생성한다.
*/
@Override
public void afterPropertiesSet() {
secretKey = new SecretKeySpec(secret.getBytes(), SignatureAlgorithm.HS256.getJcaName());
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/helpmeCookies/global/jwt/JwtToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.helpmeCookies.global.jwt;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class JwtToken {
private String accessToken;
private String refreshToken;
}
62 changes: 62 additions & 0 deletions src/main/java/com/helpmeCookies/global/jwt/JwtUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.helpmeCookies.global.jwt;

import java.util.Collection;
import java.util.List;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.Builder;
import lombok.Getter;

@Builder
@Getter
public class JwtUser implements UserDetails {
private Long id;
private String username;
private Collection<? extends GrantedAuthority> authorities;

public static JwtUser of(Long id) {
return JwtUser.builder()
.id(id)
.build();
}

@Override
// 임시 기본 권한을 USER로 설정
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + "USER"));
}

@Override
public String getPassword() {
return null;
}

@Override
public String getUsername() {
return this.username;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.helpmeCookies.global.security;

import java.io.IOException;
import java.io.PrintWriter;

import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;

@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) {
log.error("Token : {}", request.getHeader("Authorization"));
// TODO: 에러코드 추가
response.setStatus(403);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.helpmeCookies.global.security;

import java.io.IOException;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
log.debug("Token : {}", request.getHeader("Authorization"));
response.setStatus(401);
}
}
Loading

0 comments on commit 5617e33

Please sign in to comment.