Skip to content

Commit

Permalink
Merge pull request #8 from Kusitms-28th-Meetup-D/feature/7-security
Browse files Browse the repository at this point in the history
카카오 소셜로그인, 시큐리티를 통한 인가 인증
  • Loading branch information
OJOJIN authored Oct 26, 2023
2 parents 853452d + 2fc2e88 commit 8d1990f
Show file tree
Hide file tree
Showing 38 changed files with 996 additions and 10 deletions.
15 changes: 15 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ dependencies {
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'

// security
implementation 'org.springframework.boot:spring-boot-starter-security'

// security ( jwt token )
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// open feign
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.0.3'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.kusithm.meetupd.common.auth;

public final class AuthConstants {
public static final String AUTH_HEADER = "Authorization";
public static final String TOKEN_TYPE = "Bearer ";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.kusithm.meetupd.common.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kusithm.meetupd.common.error.ErrorCode;
import com.kusithm.meetupd.common.error.dto.ErrorResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
response.setStatus(ErrorCode.UNAUTHORIZED.getStatus().value());
response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(ErrorCode.UNAUTHORIZED)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.kusithm.meetupd.common.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kusithm.meetupd.common.error.ErrorCode;
import com.kusithm.meetupd.common.error.ForbiddenException;
import com.kusithm.meetupd.common.error.UnauthorizedException;
import com.kusithm.meetupd.common.error.dto.ErrorResponse;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
public class ExceptionHandlerFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (UnauthorizedException e) {
handleUnauthorizedException(response, e);
} catch (ForbiddenException e) {
handleForbiddenException(response, e);
} catch (Exception ee) {
handleException(response);
}
}

private void handleUnauthorizedException(HttpServletResponse response, Exception e) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
if (e instanceof UnauthorizedException ue) {
response.setStatus(ue.getError().getStatus().value());;
response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(ue.getError())));
}
}


private void handleForbiddenException(HttpServletResponse response, Exception e) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
if (e instanceof ForbiddenException fe) {
response.setStatus(fe.getError().getStatus().value());;
response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(fe.getError())));
}
}

private void handleException(HttpServletResponse response) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
response.setStatus(ErrorCode.INTERNAL_SERVER_ERROR.getStatus().value());
response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.kusithm.meetupd.common.auth;

import com.kusithm.meetupd.common.error.ErrorCode;
import com.kusithm.meetupd.common.error.ForbiddenException;
import com.kusithm.meetupd.common.jwt.TokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final TokenProvider tokenProvider;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = getAccessTokenFromHttpServletRequest(request);
tokenProvider.validateAccessToken(accessToken);
Long userId = tokenProvider.getTokenSubject(accessToken);
setAuthentication(request, userId);
filterChain.doFilter(request, response);
}


private String getAccessTokenFromHttpServletRequest(HttpServletRequest request) {
String accessToken = request.getHeader(AuthConstants.AUTH_HEADER);
if (StringUtils.hasText(accessToken) && accessToken.startsWith(AuthConstants.TOKEN_TYPE)) {
return accessToken.split(" ")[1];
}
throw new ForbiddenException(ErrorCode.UNAUTHORIZED);
}


private void setAuthentication(HttpServletRequest request, Long userId) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, null);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
22 changes: 22 additions & 0 deletions src/main/java/com/kusithm/meetupd/common/auth/UserId.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.kusithm.meetupd.common.auth;


import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/*
@Target(ElementType.PARAMETER)
Annotation 을 생성할 수 있는 위치를 지정
PARAMETER 로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있다.
@Retention(RetentionPolicy.RUNTIME)
Annotation 의 라이프 사이크를 지정
SOURCE vs CLASS vs RUNTIME
소스코드까지 | 바이트코드까지 | 런타임까지(안 사라진다)
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface UserId {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.kusithm.meetupd.common.auth;

import org.springframework.core.MethodParameter;
import org.springframework.security.core.context.SecurityContextHolder;
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 UserIdArgumentResolver implements HandlerMethodArgumentResolver {

@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasUserIdAnnotation = parameter.hasParameterAnnotation(UserId.class);
boolean hasLongType = Long.class.isAssignableFrom(parameter.getParameterType());
return hasUserIdAnnotation && hasLongType;
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.kusithm.meetupd.common.config;

import com.kusithm.meetupd.MeetupDApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;

@EnableFeignClients(basePackageClasses = MeetupDApplication.class)
@Configuration
public class FeignClientConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.kusithm.meetupd.common.config;

import com.kusithm.meetupd.common.auth.CustomAuthenticationEntryPoint;
import com.kusithm.meetupd.common.auth.ExceptionHandlerFilter;
import com.kusithm.meetupd.common.auth.JwtAuthenticationFilter;
import com.kusithm.meetupd.common.jwt.TokenProvider;
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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final TokenProvider tokenProvider;

// TODO api 추가될 때 white list url 확인해서 추가하기.
private static final String[] WHITE_LIST_URL = {"/api/health","/api/auth/register","/api/auth/login", "/api/auth/reissue", "/"};

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers(WHITE_LIST_URL);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// .exceptionHandling(exceptionHandlingConfigurer ->
// exceptionHandlingConfigurer.authenticationEntryPoint(customAuthenticationEntryPoint))
.addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class)
.build();
}

@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
15 changes: 14 additions & 1 deletion src/main/java/com/kusithm/meetupd/common/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package com.kusithm.meetupd.common.config;

import com.kusithm.meetupd.common.auth.UserIdArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

private final UserIdArgumentResolver userIdArgumentResolver;

@Override
public void addCorsMappings(CorsRegistry registry) {
// 모든 경로에 앞으로 만들 모든 CORS 정보를 적용한다
Expand All @@ -19,6 +27,11 @@ public void addCorsMappings(CorsRegistry registry) {
.allowedHeaders("*")
// 자격증명(쿠키) 요청을 허용한다.
// 해당 옵션 true시 allowedOrigins를 * (전체)로 설정할 수 없다. -> 전체 origin을 뚫어줄 경우 보안이 너무나도 약해져서
.allowCredentials(false);
.allowCredentials(true);
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userIdArgumentResolver);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public enum SuccessCode {
/**
* 201 CREATED SUCCESS
*/
CREATED(HttpStatus.CREATED, "생성 요청이 성공했습니다.");
CREATED(HttpStatus.CREATED, "생성 요청이 성공했습니다."),
USER_CREATED(HttpStatus.CREATED, "유저 회원가입이 성공했습니다.");

private final HttpStatus status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package com.kusithm.meetupd.common.error;

import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
public class ApplicationException extends RuntimeException {
private final HttpStatus status;
private final ErrorCode error;

public ApplicationException(ErrorCode error) {
super(error.getMessage());
this.status = error.getStatus();
this.error = error;
}
}
13 changes: 12 additions & 1 deletion src/main/java/com/kusithm/meetupd/common/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,33 @@ public enum ErrorCode {
/**
* 401 Unauthorized
*/
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "접근할 수 있는 권한이 없습니다. access token을 확인하세요."),
INVALID_JWT_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 ACCESS TOKEN 입니다"),
EXPIRED_JWT_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "ACCESS TOKEN이 만료되었습니다. 재발급 받아주세요."),
INVALID_JWT_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 REFRESH TOKEN 입니다."),
EXPIRED_JWT_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "REFRESH TOKEN이 만료되었습니다. 다시 로그인해주세요"),

/**
* 403 Forbidden
*/
WRONG_USER_PASSWORD(HttpStatus.FORBIDDEN, "입력하신 비밀번호가 올바르지 않습니다."),


/**
* 404 Not Found
*/
ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "요청하신 엔티티를 찾을 수 없습니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "요청하신 유저를 찾을 수 없습니다."),
NOT_SIGN_IN_KAKAO_ID(HttpStatus.NOT_FOUND, "회원가입되지 않은 KAKAO 계정입니다. 회원가입을 진행해 주세요"),



/**
* 409 Conflict
*/
DUPLICATE_SAMPLE_TEXT(HttpStatus.CONFLICT, "이미 존재하는 TEXT입니다."),

DUPLICATE_USER_NAME(HttpStatus.CONFLICT, "이미 존재하는 유저 이름 입니다."),
DUPLICATE_KAKAO_ID(HttpStatus.CONFLICT, "이미 회원가입 된 카카오 계정 입니다."),

/**
* 500 INTERNAL SERVER ERROR
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.kusithm.meetupd.common.error;

public class ForbiddenException extends ApplicationException{
public ForbiddenException(ErrorCode error) {
super(error);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.kusithm.meetupd.common.error;

public class UnauthorizedException extends ApplicationException{
public UnauthorizedException(ErrorCode error) {
super(error);
}
}
Loading

0 comments on commit 8d1990f

Please sign in to comment.