diff --git "a/10\354\243\274\354\260\250 \355\202\244\354\233\214\353\223\234 155328105aa680d18254c22e7537c8a7.md" "b/10\354\243\274\354\260\250 \355\202\244\354\233\214\353\223\234 155328105aa680d18254c22e7537c8a7.md" new file mode 100644 index 0000000..114e25a --- /dev/null +++ "b/10\354\243\274\354\260\250 \355\202\244\354\233\214\353\223\234 155328105aa680d18254c22e7537c8a7.md" @@ -0,0 +1,10 @@ +# 10주차 키워드 + +**Spring Security** + +- Spring Security는 애플리케이션 보안을 처리하는 강력한 프레임워크로, 인증(Authentication)과 인가(Authorization)을 쉽게 구현할 수 있도록 다양한 기능과 확장성을 제공 + +**인증(Authentication)과 인가(Authorization)** + +- **인증**은 사용자가 누구인지 확인하는 과정입니다. 사용자 이름(Username)과 비밀번호(Password) 등을 기반으로 사용자가 시스템에 접근할 수 있는지 검증 +- **인가**는 인증된 사용자가 특정 리소스나 기능에 접근할 권한이 있는지 확인하는 과정 \ No newline at end of file diff --git a/chap05/mission/umc7/build.gradle b/chap05/mission/umc7/build.gradle index 30959a5..5a412e6 100644 --- a/chap05/mission/umc7/build.gradle +++ b/chap05/mission/umc7/build.gradle @@ -37,6 +37,17 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + // 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' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/apipayload/code/status/ErrorStatus.java b/chap05/mission/umc7/src/main/java/com/example/umc7/apipayload/code/status/ErrorStatus.java index a9c19a8..b0b10d6 100644 --- a/chap05/mission/umc7/src/main/java/com/example/umc7/apipayload/code/status/ErrorStatus.java +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/apipayload/code/status/ErrorStatus.java @@ -22,6 +22,8 @@ public enum ErrorStatus implements BaseErrorCode { // Member Error MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4001", "사용자가 없습니다."), NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "MEMBER4002", "닉네임은 필수 입니다."), + MEMBER_ALREADY_LOGOUT(HttpStatus.BAD_REQUEST, "MEMBER4003", "이미 로그아웃한 사용자입니다."), + // Article Error ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."), @@ -44,6 +46,10 @@ public enum ErrorStatus implements BaseErrorCode { PAGE_NUM_NOT_VALID(HttpStatus.BAD_REQUEST, "PAGENUM4001", "page가 0이하일 수 없습니다."), + TOKEN_MISMATCH(HttpStatus.BAD_REQUEST, "TOKEN4001", "토큰 종류가 올바르지 않습니다."), + + MEMBER_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "TOKEN4002", "사용자의 토큰을 찾을 수 없습니다."), + ; private final HttpStatus httpStatus; diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/CustomUserDetails.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/CustomUserDetails.java new file mode 100644 index 0000000..5725ce8 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/CustomUserDetails.java @@ -0,0 +1,39 @@ +package com.example.umc7.domain.auth; + +import com.example.umc7.domain.member.Member; +import java.util.ArrayList; +import java.util.Collection; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +@AllArgsConstructor +public class CustomUserDetails implements UserDetails { + + private Member member; + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return member.getRole().name(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return member.getClientId(); + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/controller/AuthController.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..1494428 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/controller/AuthController.java @@ -0,0 +1,100 @@ +package com.example.umc7.domain.auth.controller; + +import com.example.umc7.apipayload.ApiResponse; +import com.example.umc7.domain.auth.dto.TokenResponseDto; +import com.example.umc7.domain.auth.service.AuthService; +import com.example.umc7.domain.member.Member; +import com.example.umc7.domain.member.dto.LoginSuccessDto; +import com.example.umc7.domain.member.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@Tag(name = "Auth", description = "인증 인가 관련 API") +public class AuthController { + + private final AuthService authService; + private final MemberService memberService; + + private final static String LOGIN_SUCCESS_REDIRECT_URL_LOCAL = "http://localhost:5173"; + + @Operation( + summary = "카카오 로그인 페이지로 리다이렉트 API", + description = "카카오 로그인 페이지로 리다이렉트하는 API입니다." + ) + @GetMapping("/oauth2/kakao") + public void getLoginUrl(HttpServletResponse response) throws IOException { + String redirectUrl = authService.getRedirectUrl(); + log.info("{}", redirectUrl); + response.sendRedirect(redirectUrl); + } + + @Operation( + summary = "인가 코드를 받아 처리하는 API", + description = "인가 코드 받아 처리하는 API입니다." + ) + @GetMapping("/oauth2/kakao/code") + public void callBack( + @RequestParam(required = false) String code, HttpServletResponse response) + throws IOException { + Map memberInfo = authService.getMemberInfo(code); + LoginSuccessDto loginSuccessDto = memberService.login(memberInfo); + + String redirectUrl = + LOGIN_SUCCESS_REDIRECT_URL_LOCAL + "?isFirstLogin=" + loginSuccessDto.getIsFirstLogin() + + "&accessToken=" + loginSuccessDto.getTokenResponseDto().getAccessToken() + + "&refreshToken=" + loginSuccessDto.getTokenResponseDto().getRefreshToken(); + + response.sendRedirect(redirectUrl); + } + + @Operation( + summary = "access token 재발급 API", + description = "access token 재발급하는 API입니다. 해당 API는 헤더에 refreshToken과 함께 요청해야합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "재발급된 acessToken과 기존의 refreshToken을 응답", + content = @Content(schema = @Schema(implementation = TokenResponseDto.class)) + ) + } + ) + @GetMapping("/reissue") + public ResponseEntity> reissue( + @RequestAttribute("refresh") String refreshToken) { + return ResponseEntity.ok(ApiResponse.onSuccess(authService.reissue(refreshToken))); + } + + @Operation( + summary = "로그아웃 API", + description = "로그아웃 API입니다. 해당 API는 사용자 인증이 요구됩니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "로그아웃 결과 반환", + content = @Content(schema = @Schema(implementation = String.class)) + ) + } + ) + @PostMapping("/logout") + public ResponseEntity> logout(@AuthenticationPrincipal Member member) { + authService.deleteRefreshToken(member); + return ResponseEntity.ok(ApiResponse.onSuccess("로그아웃 성공")); + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/dto/TokenResponseDto.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/dto/TokenResponseDto.java new file mode 100644 index 0000000..3d6a2cb --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/dto/TokenResponseDto.java @@ -0,0 +1,16 @@ +package com.example.umc7.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@Schema(description = "토큰 재발급 응답에 대한 DTO") +public class TokenResponseDto { + + @Schema(description = "재발급된 accessToken", example = "") + private String accessToken; + @Schema(description = "기존 refreshToken", example = "") + private String refreshToken; +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/enums/TokenType.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/enums/TokenType.java new file mode 100644 index 0000000..2bb10ef --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/enums/TokenType.java @@ -0,0 +1,12 @@ +package com.example.umc7.domain.auth.enums; + +public enum TokenType { + + ACCESS, + REFRESH; + + @Override + public String toString() { + return super.toString(); + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/handler/CustomAccessDeniedHandler.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..af4c93f --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package com.example.umc7.domain.auth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +/** + * 필터를 지나 인증 과정을 거친 후 SecurityContext에 인증 객체는 존재하지만, + * 해당 인증 객체의 권한이 접근하려는 리소스에 접근할 수 있는 권한과 맞지 않으면 AccessDeninedHandler가 호출된다. + * + * 정리 : 인증은 되었지만, 인가(권한)이 맞지 않는 경우 + */ + +@Slf4j +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final HandlerExceptionResolver handlerExceptionResolver; + + public CustomAccessDeniedHandler( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver) { + this.handlerExceptionResolver = handlerExceptionResolver; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.info("CustomAccessDeniendHandler 동작"); + + handlerExceptionResolver.resolveException(request, response, null, accessDeniedException); + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/handler/CustomAuthenticationEntryPoint.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..818f180 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,43 @@ +package com.example.umc7.domain.auth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +/** + * 필터를 지나 인증 과정을 거친 후 SecurityContext에 인증 객체가 없는 경우, + * 리소스에 접근하려할 때 AuthenticationEntryPoint를 호출한다. + */ + +@Slf4j +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final HandlerExceptionResolver handlerExceptionResolver; + + public CustomAuthenticationEntryPoint( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver) { + this.handlerExceptionResolver = handlerExceptionResolver; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + log.info("CustomAuthenticationEntryPoint 동작"); + + // 토큰에 문제 있거나, 토큰에 담긴 clientId의 유저가 우리 db에 없는 경우 + if (request.getAttribute("exception") != null) { + handlerExceptionResolver.resolveException(request, response, null, + (Exception) request.getAttribute("exception")); + } else { // 토큰을 담지 않아서 jwtFilter에서 토큰 검증 로직 없이 다음 필터로 넘어간 경우 + handlerExceptionResolver.resolveException(request, response, null, authException); + } + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/jwt/JwtFilter.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/jwt/JwtFilter.java new file mode 100644 index 0000000..124bdff --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/jwt/JwtFilter.java @@ -0,0 +1,121 @@ +package com.example.umc7.domain.auth.jwt; + +import com.example.umc7.apipayload.code.status.ErrorStatus; +import com.example.umc7.apipayload.exception.GeneralException; +import com.example.umc7.domain.auth.CustomUserDetails; +import com.example.umc7.domain.auth.enums.TokenType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + // 해당 uri는 가장 첫 if문에서 다음 필터로 넘긴다. + // permitAll을 하더라도 필터는 거쳐간다. + // permitAll의 의미는 필터를 거치지 않는 것이 아니라, + // 필터를 모두 거치고 나서 SecurityContext에 Authentication이 없더라도 인가해주는 것임. + private static final List EXCLUDE_URLS = List.of( + "/favicon.ico", "/oauth2/kakao", "/oauth2/kakao/code", "/ws" + ); + + private static final List REFRESH_URLS = List.of("/reissue"); + + public static final String HEADER_ATTRIBUTE_NAME_AUTHORIZATION = "Authorization"; + public final static String TOKEN_PREFIX = "Bearer "; + private static final String REQUEST_ATTRIBUTE_NAME_REFRESH = "refresh"; + + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + log.info("Processing request: {}", request.getRequestURI()); + if (shouldExclude(request)) { + filterChain.doFilter(request, response); // 다음 필터로 진행 + return; + } + + // 요청 헤더에서 Authorization 정보를 가져옴 + String authHeader = request.getHeader(HEADER_ATTRIBUTE_NAME_AUTHORIZATION); + if (authHeader == null || !authHeader.startsWith(TOKEN_PREFIX)) { + filterChain.doFilter(request, response); // Authorization 헤더가 없거나 잘못된 경우, 다음 필터로 진행 + return; + } + + // Authorization 헤더에서 JWT를 추출 + String jwt = authHeader.substring(TOKEN_PREFIX.length()); + + String clientId = ""; + UserDetails userDetails; + try { + // JWT를 검증 + jwtUtil.validateToken(jwt); + + // JWT에서 사용자 이름을 추출하고, 사용자 세부 정보를 로드 + clientId = jwtUtil.extractClientId(jwt); + + // clientId로 db에서 우리 서비스의 사용자인지 확인 후 UserDetails 생성 반환 + userDetails = userDetailsService.loadUserByUsername(clientId); + + } catch (Exception e) { + request.setAttribute("exception", e); + filterChain.doFilter(request, response); + return; + } + + // refresh token이지만, 토큰 재발급 요청 엔드포인트가 아닌 경우 예외 처리 + if (jwtUtil.extractTokenType(jwt).equals(TokenType.REFRESH.toString())) { + if (isNotAllowRefresh(request)) { + log.info("리프레시 토큰이지만, 엔드포인트가 reissue가 아닌 경우"); + request.setAttribute("exception", new GeneralException(ErrorStatus.TOKEN_MISMATCH)); + } + // reissue 엔드포인트라면 Request Attributes에 refresh token 값 추가. 컨트롤러에서 꺼내서 사용 + request.setAttribute(REQUEST_ATTRIBUTE_NAME_REFRESH, jwt); + } + + CustomUserDetails customUserDetails = (CustomUserDetails) userDetails; + + // 사용자 정보와 권한을 설정하고 SecurityContext에 인증 정보를 저장 + // Authentication 객체에 UserDeatils가 아닌 member 객체를 넣는다. + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + customUserDetails.getMember(), + null, + userDetails.getAuthorities() + ); + + // Authentication에 현재 요청 정보를 저장해준다. (상세 설정? 안해도 되긴 할듯) + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + // SecurityContext에 Authentication 등록 + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 다음 필터로 진행 + filterChain.doFilter(request, response); + } + + private boolean shouldExclude(HttpServletRequest request) { + return EXCLUDE_URLS.stream().anyMatch(url -> request.getRequestURI().equals(url)); + } + + private boolean isNotAllowRefresh(HttpServletRequest request) { + return REFRESH_URLS.stream().noneMatch(url -> request.getRequestURI().equals(url)); + } + +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/jwt/JwtUtil.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..836032e --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/jwt/JwtUtil.java @@ -0,0 +1,90 @@ +package com.example.umc7.domain.auth.jwt; + +import com.example.umc7.domain.auth.enums.TokenType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.util.Base64; +import java.util.Date; +import java.util.function.Function; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + + public final static String TOKEN_TYPE_CLAIM_NAME = "tokenType"; + + @Value("${jwt.custom.secretKey}") + private String SECRET_KEY; + + @Value("${jwt.refresh-token.expire-length}") + private Long REFRESH_EXPIRATION; + + @Value("${jwt.access-token.expire-length}") + private Long ACCESS_EXPIRATION; + + public String generateAccessToken(String clientId) { + return buildToken(TokenType.ACCESS, clientId, ACCESS_EXPIRATION); + } + + public String generateRefreshToken(String clientId) { + return buildToken(TokenType.REFRESH, clientId, REFRESH_EXPIRATION); + } + + private String buildToken(TokenType tokenType, String clientId, Long expiration) { + return Jwts + .builder() + .setSubject(clientId) + .claim(TOKEN_TYPE_CLAIM_NAME, tokenType.toString()) + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + //서명 키 반환, 토큰 생성하고 검증할 때 사용 + private SecretKey getSignInKey() { + String key = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes()); + byte[] keyBytes = Decoders.BASE64.decode(key); + return Keys.hmacShaKeyFor(keyBytes); + } + + public void validateToken(String token) { + Jwts.parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token); + } + + public String extractClientId(String token) { + return extractClaim(token, Claims::getSubject); + } + + //토큰에서 특정 클레임을 추출 + private T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + //토큰에서 모든 클레임을 추출 + private Claims extractAllClaims(String token) { + return Jwts + .parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + + // 토큰 타입 추출 + public String extractTokenType(String token) { + return extractClaim(token, claims -> claims.get(TOKEN_TYPE_CLAIM_NAME, String.class)); + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/repository/RefreshTokenRepository.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..87757e1 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,34 @@ +package com.example.umc7.domain.auth.repository; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepository { + + @Value("${jwt.refresh-token.expire-length}") + private Long REFRESH_EXPIRATION; + private static final String REFRESH_TOKEN_KEY_NAME_PREFIX = "REFRESH:"; + + private final RedisTemplate redisTemplate; + + public void save(String clientId, String refreshToken) { + redisTemplate.opsForValue().set(REFRESH_TOKEN_KEY_NAME_PREFIX + clientId, + refreshToken, REFRESH_EXPIRATION, TimeUnit.MILLISECONDS); + } + + public String findById(String clientId) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + String findRefreshToken = valueOperations.get(REFRESH_TOKEN_KEY_NAME_PREFIX + clientId); + return findRefreshToken; + } + + public void deleteById(String clientId) { + redisTemplate.delete(REFRESH_TOKEN_KEY_NAME_PREFIX + clientId); + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/service/AuthService.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/service/AuthService.java new file mode 100644 index 0000000..804e139 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/service/AuthService.java @@ -0,0 +1,213 @@ +package com.example.umc7.domain.auth.service; + +import com.example.umc7.apipayload.code.status.ErrorStatus; +import com.example.umc7.apipayload.exception.GeneralException; +import com.example.umc7.domain.auth.dto.TokenResponseDto; +import com.example.umc7.domain.auth.repository.RefreshTokenRepository; +import com.example.umc7.domain.auth.jwt.JwtUtil; +import com.example.umc7.domain.member.Member; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Service +public class AuthService { + + private static final String QUERY_PARAMETER_NAME_CLIENT_ID = "client_id"; + private static final String QUERY_PARAMETER_NAME_REDIRECT_URI = "redirect_uri"; + private static final String QUERY_PARAMETER_NAME_RESPONSE_TYPE = "response_type"; + private static final String QUERY_PARAMETER_VALUE_CODE = "code"; + + private static final String CONTENT_TYPE_HEADER_NAME = "Content-type"; + private static final String CONTENT_TYPE_HEADER_VALUE = "application/x-www-form-urlencoded;charset=utf-8"; + + private static final String BODY_ATTRIBUTE_NAME_GRANT_TYPE = "grant_type"; + private static final String BODY_ATTRIBUTE_VALUE_AUTH = "authorization_code"; + private static final String BODY_ATTRIBUTE_NAME_CLIENT_SECRET = "client_secret"; + private static final String BODY_ATTRIBUTE_NAME_CODE = "code"; + + private static final String HEADER_ATTRIBUTE_NAME_AUTH = "Authorization"; + private static final String HEADER_TOKEN_PREFIX = "Bearer "; + + private static final String JSON_ATTRIBUTE_NAME_TOKEN = "access_token"; + private static final String JSON_ATTRIBUTE_NAME_ID = "id"; + private static final String JSON_ATTRIBUTE_NAME_PROPERTIES = "properties"; + private static final String JSON_ATTRIBUTE_NAME_NICKNAME = "nickname"; + private static final String JSON_ATTRIBUTE_NAME_PROFILE_IMAGE = "profile_image"; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String KAKAO_CLIENT_ID; + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String KAKAO_REDIRECT_URI; + @Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}") + private String AUTHORIZATION_URI; + @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") + private String KAKAO_CLIENT_SECRET; + @Value("${spring.security.oauth2.client.provider.kakao.token-uri}") + private String TOKEN_URI; + @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") + private String USER_INFO_URI; + + private final RestTemplate restTemplate; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtUtil jwtUtil; + + public AuthService(RestTemplateBuilder restTemplateBuilder, + RefreshTokenRepository refreshTokenRepository, JwtUtil jwtUtil) { + this.restTemplate = restTemplateBuilder.build(); + this.refreshTokenRepository = refreshTokenRepository; + this.jwtUtil = jwtUtil; + } + + public String getRedirectUrl() { + String url = UriComponentsBuilder.fromHttpUrl(AUTHORIZATION_URI) + .queryParam(QUERY_PARAMETER_NAME_CLIENT_ID, KAKAO_CLIENT_ID) + .queryParam(QUERY_PARAMETER_NAME_REDIRECT_URI, KAKAO_REDIRECT_URI) + .queryParam(QUERY_PARAMETER_NAME_RESPONSE_TYPE, QUERY_PARAMETER_VALUE_CODE) + .build() + .toString(); + + return url; + } + + public Map getMemberInfo(String code) throws JsonProcessingException { + // 카카오 측에 access 토큰 요청을 위한 요청 httpEntity 생성 + HttpEntity> tokenRequest = generateTokenRequest(code); + + // accessToken 요청 작업 시작 + ResponseEntity accessTokenResponse = restTemplate.exchange(TOKEN_URI, + HttpMethod.POST, + tokenRequest, String.class); + + // 위 요청에서 받은 응닶 값에서 accessToken 파싱 + String accessToken = parseAccessToken(accessTokenResponse); + + // accessToken으로 사용자 정보 조회를 위한 요청 httpEntity 생성 + HttpEntity> memberInfoRequest = generateMemberInfoRequest( + accessToken); + + // 사용자 정보 요청과 응답 + ResponseEntity memberInfoResponse = restTemplate.exchange(USER_INFO_URI, + HttpMethod.POST, + memberInfoRequest, String.class); + + log.info("로그인 사용자 정보 : {}", memberInfoResponse); + + return getClientId(memberInfoResponse); + } + + public TokenResponseDto reissue(String refreshToken) { + String clientId = jwtUtil.extractClientId(refreshToken); + + // redis에서 해당 유저의 refresh token이 있는지 조회 + String findRefreshToken = refreshTokenRepository.findById(clientId); + + // redis에서 조회된 결과 없으면 예외 + if (Objects.isNull(findRefreshToken)) { + throw new GeneralException(ErrorStatus.MEMBER_TOKEN_NOT_FOUND); + } + + String accessToken = jwtUtil.generateAccessToken(clientId); + + // refresh token 존재하면 accessToken 재발급 + refreshToken은 원래 토큰 전달 + return TokenResponseDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public void deleteRefreshToken(Member member) { + String clientId = member.getClientId(); + String findRefreshToken = refreshTokenRepository.findById(clientId); + + if (Objects.isNull(findRefreshToken)) { + throw new GeneralException(ErrorStatus.MEMBER_ALREADY_LOGOUT); + } + + refreshTokenRepository.deleteById(clientId); + } + + private HttpEntity> generateTokenRequest(String code) { + // HTTP Header + HttpHeaders headers = new HttpHeaders(); + headers.add(CONTENT_TYPE_HEADER_NAME, CONTENT_TYPE_HEADER_VALUE); + + // HTTP Body + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add(BODY_ATTRIBUTE_NAME_GRANT_TYPE, BODY_ATTRIBUTE_VALUE_AUTH); + body.add(QUERY_PARAMETER_NAME_CLIENT_ID, KAKAO_CLIENT_ID); + body.add(QUERY_PARAMETER_NAME_REDIRECT_URI, KAKAO_REDIRECT_URI); + body.add(BODY_ATTRIBUTE_NAME_CODE, code); + body.add(BODY_ATTRIBUTE_NAME_CLIENT_SECRET, KAKAO_CLIENT_SECRET); + + return new HttpEntity<>(body, headers); + } + + private String parseAccessToken(ResponseEntity response) + throws JsonProcessingException { + String responseBody = parseResponseBody(response); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(responseBody); + + return jsonNode.get(JSON_ATTRIBUTE_NAME_TOKEN).asText(); + } + + private HttpEntity> generateMemberInfoRequest( + String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HEADER_ATTRIBUTE_NAME_AUTH, HEADER_TOKEN_PREFIX + accessToken); + headers.add(CONTENT_TYPE_HEADER_NAME, CONTENT_TYPE_HEADER_VALUE); + + return new HttpEntity<>(headers); + } + + private Map getClientId(ResponseEntity response) + throws JsonProcessingException { + String responseBody = parseResponseBody(response); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(responseBody); + + String id = jsonNode.get(JSON_ATTRIBUTE_NAME_ID).asText(); + String clientId = generateClientId(id); + + String nickname = jsonNode.get(JSON_ATTRIBUTE_NAME_PROPERTIES) + .get(JSON_ATTRIBUTE_NAME_NICKNAME).asText(); + + String profileImage = jsonNode.get(JSON_ATTRIBUTE_NAME_PROPERTIES) + .get(JSON_ATTRIBUTE_NAME_PROFILE_IMAGE).asText(); + + HashMap memberInfoMap = new HashMap<>(); + memberInfoMap.put(JSON_ATTRIBUTE_NAME_ID, clientId); + memberInfoMap.put(JSON_ATTRIBUTE_NAME_NICKNAME, nickname); + memberInfoMap.put(JSON_ATTRIBUTE_NAME_PROFILE_IMAGE, profileImage); + + return memberInfoMap; + } + + private String parseResponseBody(ResponseEntity response) { + String responseBody = response.getBody(); + return responseBody; + } + + private String generateClientId(String id) { + return id + ":KAKAO"; + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/service/CustomUserDetailsService.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..c4f0db5 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/auth/service/CustomUserDetailsService.java @@ -0,0 +1,31 @@ +package com.example.umc7.domain.auth.service; + +import com.example.umc7.domain.auth.CustomUserDetails; +import com.example.umc7.domain.member.Member; +import com.example.umc7.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String clientId) throws UsernameNotFoundException { + + // db에 jwt 토큰에서 꺼낸 clientId의 유저가 없으면 예외 + Member member = memberRepository.findByClientId(clientId).orElseThrow( + () -> new UsernameNotFoundException("User not found with clientId: " + clientId)); + + // 존재하면 UserDetails 객체 생성해서 반환 -> SecurityContextHolder에 저장할 것임. + log.info("{}", member); + return new CustomUserDetails(member); + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/Member.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/Member.java index 51c0444..c85a2f0 100644 --- a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/Member.java +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/Member.java @@ -1,6 +1,7 @@ package com.example.umc7.domain.member; import com.example.umc7.domain.member.enums.Gender; +import com.example.umc7.domain.member.enums.Role; import com.example.umc7.domain.member.enums.SocialType; import com.example.umc7.global.BaseTime; import jakarta.persistence.Entity; @@ -41,4 +42,17 @@ public class Member extends BaseTime { private String email; private Integer point; + + // 카카오에서 제공해주는 유저 id + :KAKAO + private String clientId; + + private String kakaoProfileNickname; + + @Enumerated(EnumType.STRING) + private Role role; + + public void updateKakaoProfileNickname(String name) { + this.kakaoProfileNickname = name; + } + } \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/dto/LoginSuccessDto.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/dto/LoginSuccessDto.java new file mode 100644 index 0000000..c5f64c1 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/dto/LoginSuccessDto.java @@ -0,0 +1,17 @@ +package com.example.umc7.domain.member.dto; + +import com.example.umc7.domain.auth.dto.TokenResponseDto; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@JsonPropertyOrder({"isFirstLogin", "tokenResponseDto"}) +public class LoginSuccessDto { + + @JsonProperty("isFirstLogin") + private Boolean isFirstLogin; + private TokenResponseDto tokenResponseDto; +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/enums/Role.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/enums/Role.java new file mode 100644 index 0000000..e15a044 --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/enums/Role.java @@ -0,0 +1,5 @@ +package com.example.umc7.domain.member.enums; + +public enum Role { + USER, ADMIN +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/repository/MemberRepository.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/repository/MemberRepository.java index 4787cbf..c6b3705 100644 --- a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/repository/MemberRepository.java +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/repository/MemberRepository.java @@ -2,8 +2,10 @@ import com.example.umc7.domain.member.Member; import com.example.umc7.domain.member.repository.querydsl.MemberQueryRepository; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository, MemberQueryRepository { + Optional findByClientId(String clientId); } \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/service/MemberService.java b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/service/MemberService.java index a68ac13..2e32ee2 100644 --- a/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/service/MemberService.java +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/domain/member/service/MemberService.java @@ -1,7 +1,14 @@ package com.example.umc7.domain.member.service; +import com.example.umc7.domain.auth.dto.TokenResponseDto; +import com.example.umc7.domain.auth.repository.RefreshTokenRepository; +import com.example.umc7.domain.auth.jwt.JwtUtil; import com.example.umc7.domain.member.Member; +import com.example.umc7.domain.member.dto.LoginSuccessDto; +import com.example.umc7.domain.member.enums.Role; import com.example.umc7.domain.member.repository.MemberRepository; +import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,9 +19,70 @@ public class MemberService { private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + private final RefreshTokenRepository refreshTokenRepository; + + private static final String JSON_ATTRIBUTE_NAME_NICKNAME = "nickname"; + private static final String JSON_ATTRIBUTE_NAME_ID = "id"; + private static final String JSON_ATTRIBUTE_NAME_PROFILE_IMAGE = "profile_image"; @Transactional public Member findMember(Long memberId) { return memberRepository.findMemberById(memberId); } + + public LoginSuccessDto login(Map memberInfo) { + String clientId = memberInfo.get(JSON_ATTRIBUTE_NAME_ID); + String kakaoProfileNickname = memberInfo.get(JSON_ATTRIBUTE_NAME_NICKNAME); + + String accessToken = jwtUtil.generateAccessToken(clientId); + String refreshToken = jwtUtil.generateRefreshToken(clientId); + + TokenResponseDto tokenResponseDto = TokenResponseDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + + //redis에 refresh token 저장 + //key: clientId, value: refresh token + refreshTokenRepository.save(clientId, refreshToken); + + Optional findMember = memberRepository.findByClientId(clientId); + + if (findMember.isPresent()) { + // 최초 로그인이 아닌 사용자의 경우 카카오에서 사용하는 닉네임을 변경했을 가능성이 있기 때문에 로그인 시 업데이트 해준다. + Member member = findMember.get(); + member.updateKakaoProfileNickname(kakaoProfileNickname); + + // 추가 정보 입력한 사용자일 경우 + if (member.getName() != null) { + // jwt 엑세스 토큰 응답 + return LoginSuccessDto.builder() + .isFirstLogin(false) + .tokenResponseDto(tokenResponseDto) + .build(); + } + + // 소셜 로그인을 시도한 적이 있지만, 추가 정보 화면에서 나갔던 경우 + return LoginSuccessDto.builder() + .isFirstLogin(true) + .tokenResponseDto(tokenResponseDto) + .build(); + } + + // 최초 로그인 하는 사용자의 경우 db에 사용자 정보를 저장한다. + Member member = Member.builder() + .clientId(clientId) + .kakaoProfileNickname(kakaoProfileNickname) + .role(Role.USER) + .build(); + + memberRepository.save(member); + + // jwt 엑세스 토큰 응답 + return LoginSuccessDto.builder() + .isFirstLogin(true) + .tokenResponseDto(tokenResponseDto) + .build(); + } } \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/global/config/RedisConfig.java b/chap05/mission/umc7/src/main/java/com/example/umc7/global/config/RedisConfig.java new file mode 100644 index 0000000..bd2571b --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/global/config/RedisConfig.java @@ -0,0 +1,42 @@ +package com.example.umc7.global.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + @Value("${ec2.redis_url}") + private String redisUrl; + + @Value("${ec2.redis_port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + String host = redisUrl; + int port = redisPort; + + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/chap05/mission/umc7/src/main/java/com/example/umc7/global/config/SecurityConfig.java b/chap05/mission/umc7/src/main/java/com/example/umc7/global/config/SecurityConfig.java new file mode 100644 index 0000000..893171a --- /dev/null +++ b/chap05/mission/umc7/src/main/java/com/example/umc7/global/config/SecurityConfig.java @@ -0,0 +1,97 @@ +package com.example.umc7.global.config; + +import com.example.umc7.domain.auth.jwt.JwtFilter; +import com.example.umc7.domain.auth.jwt.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Collections; +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.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + private final AuthenticationEntryPoint authenticationEntryPoint; + private final AccessDeniedHandler accessDeniedHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity + .cors((cors) -> cors + .configurationSource(new CorsConfigurationSource() { + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins( + Arrays.asList("http://localhost:5173")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + + return configuration; + } + })); + + //csrf disable + httpSecurity + .csrf((auth) -> auth.disable()); + + //Form login 방식 disable + httpSecurity + .formLogin((auth) -> auth.disable()); + + //http basic 인증 방식 disable + httpSecurity + .httpBasic((auth) -> auth.disable()); + + //session 설정 (jwt 사용 -> stateless) + httpSecurity + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + //경로별 인가 작업 + httpSecurity + .authorizeHttpRequests((auth) -> auth + .requestMatchers( + "/" + , "/swagger-ui/**" + , "/v3/api-docs/**" + , "/v2/swagger-config" + , "/swagger-resources/**") + .permitAll() + .requestMatchers("/oauth2/kakao/**").permitAll() + .anyRequest().hasAnyAuthority("USER", "ADMIN") + ); + + // jwt필터 추가 + httpSecurity + .addFilterBefore(new JwtFilter(jwtUtil, userDetailsService), + UsernamePasswordAuthenticationFilter.class); + + httpSecurity + .exceptionHandling((exceptions) -> exceptions + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint) + ); + + return httpSecurity.build(); + } +} \ No newline at end of file