diff --git a/build.gradle b/build.gradle index bf63fec..ff4ba02 100644 --- a/build.gradle +++ b/build.gradle @@ -27,11 +27,23 @@ dependencies { //Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.projectlombok:lombok:1.18.26' // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // 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' + + // MySQL + runtimeOnly 'com.mysql:mysql-connector-j' + } tasks.named('test') { diff --git a/src/main/java/org/harang/server/annotation/MemberId.java b/src/main/java/org/harang/server/annotation/MemberId.java new file mode 100644 index 0000000..c72a64c --- /dev/null +++ b/src/main/java/org/harang/server/annotation/MemberId.java @@ -0,0 +1,11 @@ +package org.harang.server.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MemberId { +} diff --git a/src/main/java/org/harang/server/config/WebMVCConfig.java b/src/main/java/org/harang/server/config/WebMVCConfig.java new file mode 100644 index 0000000..f89a27b --- /dev/null +++ b/src/main/java/org/harang/server/config/WebMVCConfig.java @@ -0,0 +1,22 @@ +package org.harang.server.config; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.harang.server.interceptor.pre.MemberIdArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@EnableWebMvc +@RequiredArgsConstructor +public class WebMVCConfig implements WebMvcConfigurer { + private final MemberIdArgumentResolver memberIdArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + WebMvcConfigurer.super.addArgumentResolvers(resolvers); + resolvers.add(memberIdArgumentResolver); + } +} diff --git a/src/main/java/org/harang/server/constants/jwt/JwtProperties.java b/src/main/java/org/harang/server/constants/jwt/JwtProperties.java new file mode 100644 index 0000000..60ddee1 --- /dev/null +++ b/src/main/java/org/harang/server/constants/jwt/JwtProperties.java @@ -0,0 +1,7 @@ +package org.harang.server.constants.jwt; + +public class JwtProperties { + public static final String MEMBER_ID_CLAIM_NAME = "mid"; + public static final String MEMBER_ROLE_CLAIM_NAME = "mtype"; + public static final String BEARER = "Bearer "; +} diff --git a/src/main/java/org/harang/server/domain/MemberInfo.java b/src/main/java/org/harang/server/domain/MemberInfo.java index f263ad3..4dc4339 100644 --- a/src/main/java/org/harang/server/domain/MemberInfo.java +++ b/src/main/java/org/harang/server/domain/MemberInfo.java @@ -32,10 +32,10 @@ public class MemberInfo { private String refreshToken; /* Relation Parent Mapping */ - @OneToMany(mappedBy = "member_info") + @OneToMany(mappedBy = "memberInfo") private List memberHelpList = new ArrayList<>(); - @OneToMany(mappedBy = "member_info") + @OneToMany(mappedBy = "memberInfo") private List certificationList = new ArrayList<>(); @Builder diff --git a/src/main/java/org/harang/server/dto/response/JwtTokenResponse.java b/src/main/java/org/harang/server/dto/response/JwtTokenResponse.java new file mode 100644 index 0000000..083950c --- /dev/null +++ b/src/main/java/org/harang/server/dto/response/JwtTokenResponse.java @@ -0,0 +1,15 @@ +package org.harang.server.dto.response; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record JwtTokenResponse(@NotNull String accessToken, + @NotNull String refreshToken) { + public static JwtTokenResponse of(String accessToken, String refreshToken) { + return JwtTokenResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/org/harang/server/dto/type/ErrorMessage.java b/src/main/java/org/harang/server/dto/type/ErrorMessage.java index cba0f93..a147a17 100644 --- a/src/main/java/org/harang/server/dto/type/ErrorMessage.java +++ b/src/main/java/org/harang/server/dto/type/ErrorMessage.java @@ -15,11 +15,21 @@ public enum ErrorMessage { // bad request - 400 BAD_REQUEST("40001", HttpStatus.BAD_REQUEST, "올바르지 않은 요청입니다."), - // unauthorized - 403 - UNAUTHORIZED("40301", HttpStatus.UNAUTHORIZED, "토큰이 유효하지 않습니다."), + // unauthorized - 401 + UNAUTHORIZED("40101", HttpStatus.UNAUTHORIZED, "토큰이 유효하지 않습니다."), + + // JWT Error - 401 + INVALID_JWT("40102", HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + EXPIRED_JWT("40103", HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + UNSUPPORTED_JWT("40104", HttpStatus.UNAUTHORIZED, "지원하지 않는 토큰입니다."), + JWT_IS_EMPTY("40105", HttpStatus.UNAUTHORIZED, "토큰이 비어있습니다."), + INVALID_TOKEN_TYPE("40106", HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰 타입입니다."), + + // Principal error - 401 + PRINCIPAL_IS_EMPTY("40107", HttpStatus.UNAUTHORIZED, "컨텍스트로부터 유저 정보를 가져올 수 없습니다."), // forbidden - 403 - FORBIDDEN("40302", HttpStatus.FORBIDDEN, "권한이 없습니다."), + FORBIDDEN("40301", HttpStatus.FORBIDDEN, "권한이 없습니다."), // not found - 404 NOT_FOUND("40401", HttpStatus.NOT_FOUND, "리소스가 존재하지 않습니다."), diff --git a/src/main/java/org/harang/server/interceptor/pre/MemberIdArgumentResolver.java b/src/main/java/org/harang/server/interceptor/pre/MemberIdArgumentResolver.java new file mode 100644 index 0000000..4bf9b7d --- /dev/null +++ b/src/main/java/org/harang/server/interceptor/pre/MemberIdArgumentResolver.java @@ -0,0 +1,31 @@ +package org.harang.server.interceptor.pre; + +import java.security.Principal; +import org.harang.server.annotation.MemberId; +import org.harang.server.dto.type.ErrorMessage; +import org.harang.server.exception.CustomException; +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.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class MemberIdArgumentResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(Long.class) + && parameter.hasParameterAnnotation(MemberId.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + final Principal principal = webRequest.getUserPrincipal(); + if (principal == null) { + throw new CustomException(ErrorMessage.PRINCIPAL_IS_EMPTY); + } + return Long.valueOf(principal.getName()); + } +} diff --git a/src/main/java/org/harang/server/security/config/SecurityConfig.java b/src/main/java/org/harang/server/security/config/SecurityConfig.java new file mode 100644 index 0000000..98144e5 --- /dev/null +++ b/src/main/java/org/harang/server/security/config/SecurityConfig.java @@ -0,0 +1,36 @@ +package org.harang.server.security.config; + +import lombok.RequiredArgsConstructor; +import org.harang.server.security.filter.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http.httpBasic(AbstractHttpConfigurer::disable).cors(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable) + + .sessionManagement(sessionManagementConfigurer -> sessionManagementConfigurer.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) + + .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> + authorizationManagerRequestMatcherRegistry + // TODO: 이후에 구현되는 로그인 api 엔드포인트랑 동일한지 확인 + .requestMatchers("/v1/login").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/org/harang/server/security/filter/JwtAuthenticationFilter.java b/src/main/java/org/harang/server/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ed87618 --- /dev/null +++ b/src/main/java/org/harang/server/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,73 @@ +package org.harang.server.security.filter; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.harang.server.constants.jwt.JwtProperties; +import org.harang.server.domain.enums.Type; +import org.harang.server.dto.type.ErrorMessage; +import org.harang.server.exception.CustomException; +import org.harang.server.util.JwtUtil; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTH_HEADER = "Authorization"; + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(@NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain) + throws ServletException, IOException { + final String token = getTokenFromHeader(request); + + log.debug(String.valueOf(request)); + + if (StringUtils.hasText(token) && jwtUtil.verifyToken(token)) { + Claims claims = jwtUtil.getClaim(token); + Long memberId = claims.get(JwtProperties.MEMBER_ID_CLAIM_NAME, Long.class); + Type role = claims.get(JwtProperties.MEMBER_ROLE_CLAIM_NAME, Type.class); + if (role == null) { + throw new CustomException(ErrorMessage.INVALID_TOKEN_TYPE); + } + // type 값을 GrantedAuthority 객체 콜렉션으로 변환 + List roles = Collections.singletonList(new SimpleGrantedAuthority(role.getValue())); + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(memberId, null, roles); + + // TODO: 왜 쓰는지 알아보기 + auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // security context에 authentication 객체 저장 + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + + private String getTokenFromHeader(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTH_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtProperties.BEARER)) { + return bearerToken.substring(JwtProperties.BEARER.length()); + } + log.debug("요청 헤더에 토큰이 존재하지 않습니다."); + return null; + } +} diff --git a/src/main/java/org/harang/server/util/JwtUtil.java b/src/main/java/org/harang/server/util/JwtUtil.java new file mode 100644 index 0000000..3653e4a --- /dev/null +++ b/src/main/java/org/harang/server/util/JwtUtil.java @@ -0,0 +1,111 @@ +package org.harang.server.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; +import org.harang.server.constants.jwt.JwtProperties; +import org.harang.server.domain.enums.Type; +import org.harang.server.dto.response.JwtTokenResponse; +import org.harang.server.dto.type.ErrorMessage; +import org.harang.server.exception.CustomException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class JwtUtil implements InitializingBean { + + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-expire-period}") + private Integer accessTokenExpirePeriod; + + @Value("${jwt.refresh-token-expire-period}") + private Integer refreshTokenExpirePeriod; + + private Key key; + + + @Override + public void afterPropertiesSet() throws Exception { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public JwtTokenResponse generateTokens(Long id, Type type) { + return JwtTokenResponse.of( // Bearer prefix 추가 + JwtProperties.BEARER + generateAccessToken(id, type), + JwtProperties.BEARER + generateRefreshToken(id) + ); + } + + public boolean verifyToken(String token) { + try { + Jws claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return claims.getBody() + .getExpiration() + .after(new Date(System.currentTimeMillis())); + }catch (Exception e) { + return false; + } + } + + public Claims getClaim(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + // JWS - 서버의 PK로 인증정보를 서명한 것 + .parseClaimsJws(token) + .getBody(); + } + catch (MalformedJwtException e) { + throw new CustomException(ErrorMessage.INVALID_JWT); + }catch (ExpiredJwtException e) { + throw new CustomException(ErrorMessage.EXPIRED_JWT); + }catch (UnsupportedJwtException e) { + throw new CustomException(ErrorMessage.UNSUPPORTED_JWT); + }catch (IllegalArgumentException e) { + throw new CustomException(ErrorMessage.JWT_IS_EMPTY); + } + } + + private String generateAccessToken(Long id, Type type) { + Claims claims = Jwts.claims(); + claims.put(JwtProperties.MEMBER_ID_CLAIM_NAME, id); + claims.put(JwtProperties.MEMBER_ROLE_CLAIM_NAME, type); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpirePeriod)) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + } + + private String generateRefreshToken(Long id) { + Claims claims = Jwts.claims(); + claims.put(JwtProperties.MEMBER_ID_CLAIM_NAME, id); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpirePeriod)) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..95e384d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,24 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${DB_USER} + password: ${DB_PASSWORD} + + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + +logging: + level: + org.hibernate.SQL: debug + +jwt: + secret-key: ${JWT_SECRET_KEY}] + access-token-expire-period: ${ACCESS_TOKEN_EXPIRE_PERIOD} + refresh-token-expire-period: ${REFRESH_TOKEN_EXPIRE_PERIOD} \ No newline at end of file diff --git a/src/test/java/org/harang/server/JwtTest.java b/src/test/java/org/harang/server/JwtTest.java new file mode 100644 index 0000000..4d8a41e --- /dev/null +++ b/src/test/java/org/harang/server/JwtTest.java @@ -0,0 +1,51 @@ +package org.harang.server; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.security.Principal; +import org.harang.server.domain.enums.Type; +import org.harang.server.dto.response.JwtTokenResponse; +import org.harang.server.interceptor.pre.MemberIdArgumentResolver; +import org.harang.server.util.JwtUtil; +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.core.MethodParameter; +import org.springframework.web.context.request.NativeWebRequest; + +@SpringBootTest +public class JwtTest { + + // TODO: 다시 보기 + + @Autowired + JwtUtil jwtUtil; + + @Test + @DisplayName("토큰이 생성되는지 확인하는 테스트") + void generateTokenTest() { + JwtTokenResponse tokens = jwtUtil.generateTokens(Long.valueOf(1), Type.SPROUT); + System.out.println(tokens.toString()); + } + + @Test + @DisplayName("memberIdResolver 테스트") + public void memberIdResolveTest() throws Exception { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + Principal principal = () -> "123"; + when(webRequest.getUserPrincipal()).thenReturn(principal); + + MethodParameter methodParameter = mock(MethodParameter.class); + + MemberIdArgumentResolver resolver = new MemberIdArgumentResolver(); + + Long resolvedMemberId = (Long) resolver.resolveArgument(methodParameter, null, webRequest, null); + + assertEquals(Long.valueOf(123), resolvedMemberId); + } + +}