diff --git a/build.gradle b/build.gradle index 2568a840..0c2a1a94 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -34,7 +35,11 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + //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' // slack logback implementation 'com.github.maricn:logback-slack-appender:1.4.0' @@ -42,6 +47,11 @@ dependencies { // s3 implementation("software.amazon.awssdk:bom:2.21.0") implementation("software.amazon.awssdk:s3:2.21.0") + + // s3 + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") + } tasks.named('test') { diff --git a/src/main/java/com/tiki/server/auth/config/SecurityConfig.java b/src/main/java/com/tiki/server/auth/config/SecurityConfig.java new file mode 100644 index 00000000..037dede6 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.tiki.server.auth.config; + +import com.tiki.server.auth.exception.handler.CustomAccessDeniedHandler; +import com.tiki.server.auth.exception.handler.CustomAuthenticationEntryPointHandler; +import com.tiki.server.auth.filter.ExceptionHandlerFilter; +import com.tiki.server.auth.filter.JwtAuthenticationFilter; +import com.tiki.server.auth.jwt.JwtProvider; +import com.tiki.server.auth.jwt.JwtValidator; +import lombok.RequiredArgsConstructor; +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.DefaultSecurityFilterChain; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagementConfigurer -> + sessionManagementConfigurer + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptionHandlingConfigurer -> + exceptionHandlingConfigurer + .accessDeniedHandler(customAccessDeniedHandler) + .authenticationEntryPoint(customAuthenticationEntryPointHandler)) + .authorizeHttpRequests(request -> + request + .requestMatchers("/api/v1/auth/login").permitAll() + .requestMatchers("/api/v1/auth/password").permitAll() + .requestMatchers("/api/v1/member/password").permitAll() + .requestMatchers("/api/v1/member").permitAll() + .anyRequest() + .authenticated()) + .addFilterBefore( + jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class + ) + .addFilterBefore( + new ExceptionHandlerFilter(), JwtAuthenticationFilter.class + ) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/auth/exception/handler/CustomAccessDeniedHandler.java b/src/main/java/com/tiki/server/auth/exception/handler/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..4f85cc7c --- /dev/null +++ b/src/main/java/com/tiki/server/auth/exception/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,28 @@ +package com.tiki.server.auth.exception.handler; + +import com.tiki.server.auth.info.AuthenticationResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static com.tiki.server.auth.message.ErrorCode.UNAUTHORIZED_USER; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private AuthenticationResponse authenticationResponse; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + authenticationResponse.makeFailureResponse(response, UNAUTHORIZED_USER); + } +} diff --git a/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java b/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java new file mode 100644 index 00000000..a7e13246 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java @@ -0,0 +1,32 @@ +package com.tiki.server.auth.exception.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tiki.server.auth.info.AuthenticationResponse; +import com.tiki.server.auth.message.ErrorCode; +import com.tiki.server.common.dto.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static com.tiki.server.auth.message.ErrorCode.UNAUTHENTICATED_USER; + +@Component +public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint { + + AuthenticationResponse authenticationResponse; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + authenticationResponse.makeFailureResponse(response, UNAUTHENTICATED_USER); + } +} diff --git a/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java b/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java new file mode 100644 index 00000000..6dc77ed5 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java @@ -0,0 +1,28 @@ +package com.tiki.server.auth.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + } +} diff --git a/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..ad1ef5b6 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,53 @@ +package com.tiki.server.auth.filter; + + +import com.tiki.server.auth.exception.AuthException; +import com.tiki.server.auth.jwt.JwtProvider; +import com.tiki.server.auth.jwt.JwtValidator; +import com.tiki.server.auth.jwt.UserAuthentication; +import com.tiki.server.common.Constants; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +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; + +import java.io.IOException; + +import static com.tiki.server.auth.jwt.JwtValidationType.VALID_JWT; +import static com.tiki.server.auth.message.ErrorCode.INVALID_KEY; +import static io.jsonwebtoken.lang.Strings.hasText; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final JwtValidator jwtValidator; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws IOException { + try { + val token = jwtProvider.getAccessTokenFromRequest(request); + if (hasText(token) && jwtValidator.validateToken(token) == VALID_JWT) { + val authentication = new UserAuthentication(jwtProvider.getUserFromJwt(token), null, null); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + log.error(e.getMessage()); + } + } +} diff --git a/src/main/java/com/tiki/server/auth/info/AuthenticationResponse.java b/src/main/java/com/tiki/server/auth/info/AuthenticationResponse.java new file mode 100644 index 00000000..89e29521 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/info/AuthenticationResponse.java @@ -0,0 +1,24 @@ +package com.tiki.server.auth.info; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tiki.server.auth.message.ErrorCode; +import com.tiki.server.common.dto.ErrorResponse; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +@RequiredArgsConstructor +public class AuthenticationResponse { + + private final ObjectMapper objectMapper; + + public void makeFailureResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(APPLICATION_JSON_VALUE); + response.setStatus(errorCode.getHttpStatus().value()); + response.getWriter().println(objectMapper.writeValueAsString(ErrorResponse.of(errorCode.getMessage()))); + } +} diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java b/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java new file mode 100644 index 00000000..0169aa79 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java @@ -0,0 +1,17 @@ +package com.tiki.server.auth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import lombok.val; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; + +import static io.jsonwebtoken.security.Keys.hmacShaKeyFor; +import static java.util.Base64.getEncoder; + +@Component +public class JwtGenerator { + +} diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java b/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java new file mode 100644 index 00000000..c6c0251f --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java @@ -0,0 +1,52 @@ +package com.tiki.server.auth.jwt; + +import com.tiki.server.auth.exception.AuthException; +import com.tiki.server.common.Constants; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.SecretKey; + +import static com.tiki.server.auth.message.ErrorCode.INVALID_KEY; +import static io.jsonwebtoken.security.Keys.hmacShaKeyFor; +import static java.util.Base64.getEncoder; + +@RequiredArgsConstructor +@Component +public class JwtProvider { + + @Value("${jwt.secret}") + private String secretKey; + + public String getAccessTokenFromRequest(HttpServletRequest request) { + val accessToken = request.getHeader(Constants.AUTHORIZATION); + if (!StringUtils.hasText(accessToken) || !accessToken.startsWith(Constants.BEARER)) { + throw new AuthException(INVALID_KEY); + } + return accessToken.substring(Constants.BEARER.length()); + } + + public long getUserFromJwt(String token) { + val claims = getBodyFromJwt(token); + return Long.parseLong(claims.get("memberId").toString()); + } + + public Claims getBodyFromJwt(final String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private SecretKey getSigningKey() { + val encodedKey = getEncoder().encodeToString(secretKey.getBytes()); + return hmacShaKeyFor(encodedKey.getBytes()); + } +} diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtValidationType.java b/src/main/java/com/tiki/server/auth/jwt/JwtValidationType.java new file mode 100644 index 00000000..d64e8832 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/JwtValidationType.java @@ -0,0 +1,9 @@ +package com.tiki.server.auth.jwt; + +public enum JwtValidationType { + VALID_JWT, + INVALID_JWT_TOKEN, + EXPIRED_JWT_TOKEN, + UNSUPPORTED_JWT_TOKEN, + EMPTY_JWT +} diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java b/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java new file mode 100644 index 00000000..4a35637d --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java @@ -0,0 +1,38 @@ +package com.tiki.server.auth.jwt; + +import com.tiki.server.auth.exception.AuthException; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static com.tiki.server.auth.jwt.JwtValidationType.*; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtValidator { + + private final JwtProvider jwtProvider; + + public JwtValidationType validateToken(String token) { + try { + jwtProvider.getBodyFromJwt(token); + return VALID_JWT; + } catch (MalformedJwtException exception) { + log.error(exception.getMessage()); + return INVALID_JWT_TOKEN; + } catch (ExpiredJwtException exception) { + log.error(exception.getMessage()); + return EXPIRED_JWT_TOKEN; + } catch (UnsupportedJwtException exception) { + log.error(exception.getMessage()); + return UNSUPPORTED_JWT_TOKEN; + } catch (IllegalArgumentException exception) { + log.error(exception.getMessage()); + return EMPTY_JWT; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java b/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java new file mode 100644 index 00000000..813e7f3d --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java @@ -0,0 +1,16 @@ +package com.tiki.server.auth.jwt; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + public UserAuthentication( + Object principal, + Object credentials, + Collection authorities + ) { + super(principal, credentials, authorities); + } +} diff --git a/src/main/java/com/tiki/server/auth/message/ErrorCode.java b/src/main/java/com/tiki/server/auth/message/ErrorCode.java index 8a67caa1..0256c9b4 100644 --- a/src/main/java/com/tiki/server/auth/message/ErrorCode.java +++ b/src/main/java/com/tiki/server/auth/message/ErrorCode.java @@ -1,6 +1,7 @@ package com.tiki.server.auth.message; import static org.springframework.http.HttpStatus.UNAUTHORIZED; +import static org.springframework.http.HttpStatus.FORBIDDEN; import org.springframework.http.HttpStatus; @@ -11,9 +12,13 @@ @AllArgsConstructor public enum ErrorCode { - /* 401 UNAUTHORIZED : 권한 없음 */ - INVALID_TOKEN(UNAUTHORIZED, "유효하지 않은 토큰입니다."); + /* 401 UNAUTHORIZED : 인증 없음 */ + UNAUTHENTICATED_USER(UNAUTHORIZED, "인증되지 않은 사용자입니다."), + INVALID_KEY(UNAUTHORIZED, "유효하지 않은 키입니다."), - private final HttpStatus httpStatus; - private final String message; + /* 403 FORBIDDEN : 인가 없음 */ + UNAUTHORIZED_USER(FORBIDDEN, "권한이 없는 사용자입니다."); + + private final HttpStatus httpStatus; + private final String message; } diff --git a/src/main/java/com/tiki/server/common/Constants.java b/src/main/java/com/tiki/server/common/Constants.java new file mode 100644 index 00000000..7981f498 --- /dev/null +++ b/src/main/java/com/tiki/server/common/Constants.java @@ -0,0 +1,6 @@ +package com.tiki.server.common; + +public class Constants { + public static final String AUTHORIZATION = "Authorization"; + public static final String BEARER = "Bearer "; +} diff --git a/src/main/java/com/tiki/server/team/controller/TeamController.java b/src/main/java/com/tiki/server/team/controller/TeamController.java index 0169ec1c..113e80c9 100644 --- a/src/main/java/com/tiki/server/team/controller/TeamController.java +++ b/src/main/java/com/tiki/server/team/controller/TeamController.java @@ -1,5 +1,7 @@ package com.tiki.server.team.controller; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -13,4 +15,5 @@ public class TeamController { private final TeamService teamService; + } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 73c530a1..fc79e267 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -28,9 +28,14 @@ logging: webhook_url: ${SLACK.WEBHOOK_URL.dev} config: classpath:logback-spring.xml +jwt: + secret: + ${JWT.SECRET} + aws-property: access-key: ${AWS_PROPERTY.ACCESS_KEY} secret-key: ${AWS_PROPERTY.SECRET_KEY} bucket: ${AWS_PROPERTY.BUCKET} aws-region: ap-northeast-2 - s3-url: ${AWS_PROPERTY.S3_URL} \ No newline at end of file + s3-url: ${AWS_PROPERTY.S3_URL} +