diff --git a/build.gradle b/build.gradle index 00d685b..f352d78 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,9 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // Database & Cache implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/ExampleController.java b/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/ExampleController.java index 864fe9a..e4e7ff7 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/ExampleController.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/ExampleController.java @@ -6,11 +6,11 @@ import com.whereyouad.WhereYouAd.domain.example.presentation.docs.ExampleControllerDocs; import com.whereyouad.WhereYouAd.global.response.DataResponse; import com.whereyouad.WhereYouAd.global.response.DefaultIdResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponses; +import com.whereyouad.WhereYouAd.global.security.jwt.CustomUserDetails; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -35,4 +35,21 @@ public ResponseEntity> findById(@PathVariable Long DataResponse.from(exampleService.findById(id)) ); } + + //@AuthenticationPrincipal 예시 메서드 + //회원가입 및 로그인 진행한 뒤, + //Postman에서 Authorization 탭 -> Bearer Token -> AccessToken 값 붙여넣기 + //or Headers 에서 Authorization 추가하여 Bearer {AccessToken} 붙여넣기 + //해당 회원의 DB에 저장된 Id 값 반환됨. + @GetMapping("/userId") + public String userIdTest(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return customUserDetails.getUserId().toString(); + } + + //@AuthenticationPrincipal 예시 메서드 + //CustomUserDetails 내부에 getUserId() 메서드를 통해 회원의 DB 저장된 Id 값 바로 뽑아내기도 가능 + @GetMapping("/userId2") + public String userIdTest2(@AuthenticationPrincipal(expression = "userId") Long userId) { + return userId.toString(); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/docs/ExampleControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/docs/ExampleControllerDocs.java index 50d9938..53e3b26 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/docs/ExampleControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domain/example/presentation/docs/ExampleControllerDocs.java @@ -4,9 +4,12 @@ import com.whereyouad.WhereYouAd.domain.example.application.dto.response.ExampleResponse; import com.whereyouad.WhereYouAd.global.response.DataResponse; import com.whereyouad.WhereYouAd.global.response.DefaultIdResponse; +import com.whereyouad.WhereYouAd.global.security.jwt.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -30,4 +33,22 @@ public interface ExampleControllerDocs { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "실패") }) public ResponseEntity> findById(@PathVariable Long id); + + @Operation( + summary = "@AuthenticationPrincipal 사용법 예시 API", + description = "회원가입 -> 로그인 후 AccessToken 을 가지고 해당 메서드 호출 시 DB 내부 회원의 Id 반환" + ) + @ApiResponses( + @ApiResponse(responseCode = "200", description = "성공") + ) + public String userIdTest(@AuthenticationPrincipal CustomUserDetails customUserDetails); + + @Operation( + summary = "@AuthenticationPrincipal 사용법 예시 API", + description = "회원가입 -> 로그인 후 AccessToken 을 가지고 해당 메서드 호출 시 DB 내부 회원의 Id 반환, CustomUserDetails 내부 편의 메서드 사용 예시" + ) + @ApiResponses( + @ApiResponse(responseCode = "200", description = "성공") + ) + public String userIdTest2(@AuthenticationPrincipal(expression = "userId") Long userId); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/LoginRequest.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/LoginRequest.java new file mode 100644 index 0000000..abaef98 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/LoginRequest.java @@ -0,0 +1,12 @@ +package com.whereyouad.WhereYouAd.domains.user.application.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) {} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/AuthService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/AuthService.java new file mode 100644 index 0000000..a5d40a5 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/AuthService.java @@ -0,0 +1,86 @@ +package com.whereyouad.WhereYouAd.domains.user.domain.service; + +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.LoginRequest; +import com.whereyouad.WhereYouAd.domains.user.exception.code.AuthErrorCode; +import com.whereyouad.WhereYouAd.domains.user.persistence.entity.RefreshToken; +import com.whereyouad.WhereYouAd.domains.user.persistence.repository.RefreshTokenRepository; +import com.whereyouad.WhereYouAd.global.exception.AppException; +import com.whereyouad.WhereYouAd.global.security.jwt.CustomUserDetailsService; +import com.whereyouad.WhereYouAd.global.security.jwt.JwtTokenProvider; +import com.whereyouad.WhereYouAd.global.security.jwt.dto.TokenResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final CustomUserDetailsService customUserDetailsService; + + //최초 로그인을 통해 AccessToken 과 RefreshToken 발급 받는 메서드 + @Transactional + public TokenResponse login(LoginRequest request) { + //email, password 기반 Spring Security가 사용할 인증 객체(AuthenticationToken) 생성 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(request.email(), request.password()); + + //실제 password 검증 + // authenticate()가 실행되면 CustomUserDetailsService.loadUserByUsername이 호출되어 DB의 유저 정보와 비교합니다. + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + //인증 정보 기반 JWT 토큰(Access & Refresh) 생성 + TokenResponse tokenResponse = jwtTokenProvider.generateToken(authentication); + + //RefreshToken 저장 -> 없으면 생성, 이미 있으면 update + RefreshToken refreshToken = refreshTokenRepository.findByKeyId(request.email()) + .map(entity -> entity.updateValue(tokenResponse.refreshToken())) + .orElse(RefreshToken.builder() + .keyId(request.email()) + .value(tokenResponse.refreshToken()). + build()); + + refreshTokenRepository.save(refreshToken); + + return tokenResponse; + } + + //기존 AccessToken 만료 시 RefreshToken을 통해 AccessToken & RefreshToken 을 재발급 받는 메서드 + @Transactional + public TokenResponse reIssue(String refreshToken) { + jwtTokenProvider.validateToken(refreshToken); //refreshToken 자체에 문제가 있을 시 해당 부분에서 예외 발생 + + String email = jwtTokenProvider.getSubject(refreshToken); //refreshToken 에서 사용자 email 값 추출 + + //기존에 해당 email의 RefreshToken 이 있는지 조회 + RefreshToken savedRefreshToken = refreshTokenRepository.findByKeyId(email) + .orElseThrow(() -> new AppException(AuthErrorCode.TOKEN_EXPIRED)); + + //해당 저장된 RefreshToken 과 입력받은 RefreshToken 이 동일한지 확인 + if (!savedRefreshToken.getValue().equals(refreshToken)) { + throw new AppException(AuthErrorCode.INVALID_TOKEN_FORMAT); + } + + //새로운 토큰 생성을 위해 유저 최신 정보 조회 + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + + // Spring Security 인증 객체 생성 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + //새로운 Access & RefreshToken 생성 및 저장 + TokenResponse tokenResponse = jwtTokenProvider.generateToken(authentication); + savedRefreshToken.updateValue(tokenResponse.refreshToken()); + refreshTokenRepository.save(savedRefreshToken); + + //새로운 Access & RefreshToken 반환 + return tokenResponse; + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/AuthErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/AuthErrorCode.java new file mode 100644 index 0000000..49f61ba --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/AuthErrorCode.java @@ -0,0 +1,24 @@ +package com.whereyouad.WhereYouAd.domains.user.exception.code; + +import com.whereyouad.WhereYouAd.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + //토큰 관련 + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH_401_2", "토큰이 만료되었습니다."), + INVALID_TOKEN_FORMAT(HttpStatus.UNAUTHORIZED, "AUTH_401_3", "잘못된 토큰 형식입니다."), + + // 로그인 실패 (비밀번호 틀림 or 계정 없음) + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_401_1", "아이디 또는 비밀번호가 일치하지 않습니다."), + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH_401_4", "해당 이메일의 회원이 존재하지 않습니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/entity/RefreshToken.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/entity/RefreshToken.java new file mode 100644 index 0000000..f629c8d --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/entity/RefreshToken.java @@ -0,0 +1,29 @@ +package com.whereyouad.WhereYouAd.domains.user.persistence.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "refresh_tokens") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + + @Id + private String keyId; //유저의 이메일(Key) + + private String value; //Refresh Token 값(Value) + + //토큰 갱신 메서드 + public RefreshToken updateValue(String token) { + this.value = token; + return this; + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/repository/RefreshTokenRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..40ddfb1 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.whereyouad.WhereYouAd.domains.user.persistence.repository; + +import com.whereyouad.WhereYouAd.domains.user.persistence.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + @Query("select r from RefreshToken r where r.keyId = :keyId") + Optional findByKeyId(@Param("keyId") String keyId); +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/AuthController.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/AuthController.java new file mode 100644 index 0000000..e34e374 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/AuthController.java @@ -0,0 +1,64 @@ +package com.whereyouad.WhereYouAd.domains.user.presentation; + +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.LoginRequest; +import com.whereyouad.WhereYouAd.domains.user.domain.service.AuthService; +import com.whereyouad.WhereYouAd.domains.user.presentation.docs.AuthControllerDocs; +import com.whereyouad.WhereYouAd.global.response.DataResponse; +import com.whereyouad.WhereYouAd.global.security.jwt.dto.TokenResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity> login(@RequestBody LoginRequest request) { + + TokenResponse tokenResponse = authService.login(request); + + ResponseCookie httpOnlyCookie = ResponseCookie.from("refresh_token", tokenResponse.refreshToken()) + .httpOnly(true) +// .secure(true) //<-- HTTPS 에서만 쿠키 전송하도록 설정 + .secure(false) //<-- Postman 테스트 용이를 위해 false + .path("/") + .maxAge(60 * 60 * 24 * 7) // 7일 +// .sameSite("None") //<-- 크로스 사이트 전송 정책, 프론트와 연동시 해당 코드 활성화 + .sameSite("Strict") //<-- 개발 or 테스트 or Postman 을 위해 임시 Strict + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, httpOnlyCookie.toString()) //생성한 RefreshToken 쿠키를 헤더에 설정 + .header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.accessToken()) //AccessToken 을 Authorization 헤더에도 추가(편의사항) + .body(DataResponse.from(tokenResponse)); + } + + @PostMapping("/reissue") + public ResponseEntity> reIssue( + @CookieValue(name = "refresh_token") String refreshToken + ) + { + TokenResponse tokenResponse = authService.reIssue(refreshToken); + + ResponseCookie httpOnlyCookie = ResponseCookie.from("refresh_token", tokenResponse.refreshToken()) + .httpOnly(true) +// .secure(true) + .secure(false) + .path("/") + .maxAge(60 * 60 * 24 * 7) // 7일 +// .sameSite("None") + .sameSite("Strict") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, httpOnlyCookie.toString()) //생성한 RefreshToken 쿠키를 헤더에 설정 + .header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.accessToken()) //AccessToken 을 Authorization 헤더에도 추가(편의사항) + .body(DataResponse.from(tokenResponse)); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/AuthControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/AuthControllerDocs.java new file mode 100644 index 0000000..87e941b --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/AuthControllerDocs.java @@ -0,0 +1,34 @@ +package com.whereyouad.WhereYouAd.domains.user.presentation.docs; + +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.LoginRequest; +import com.whereyouad.WhereYouAd.global.response.DataResponse; +import com.whereyouad.WhereYouAd.global.security.jwt.dto.TokenResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestBody; + +public interface AuthControllerDocs { + @Operation( + summary = "로그인 API", + description = "이메일, 비밀번호를 입력받아 로그인 진행, AccessToken 을 body 로 반환 & RefreshToken 은 쿠키로 반환" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "401_1", description = "실패") + }) + public ResponseEntity> login(@RequestBody LoginRequest request); + + @Operation( + summary = "AccessToken 재발급 API", + description = "AccessToken 만료 시 쿠키에 있는 RefreshToken 을 사용해 AccessToken & RefreshToken 을 재발급" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "401_2", description = "실패(RefreshToken 만료, 재로그인 필요)"), + @ApiResponse(responseCode = "401_3", description = "실패(RefreshToken 옳지 않은 값)") + }) + public ResponseEntity> reIssue(@CookieValue(name = "refresh_token") String refreshToken); +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java b/src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java index 88ba27b..92b3fb7 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java @@ -1,8 +1,12 @@ package com.whereyouad.WhereYouAd.global.exception; +import com.whereyouad.WhereYouAd.domains.user.exception.code.AuthErrorCode; import com.whereyouad.WhereYouAd.global.response.ErrorResponse; +import io.jsonwebtoken.JwtException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -62,4 +66,42 @@ public ResponseEntity handleNotValidException(MethodArgumentNotVa .status(HttpStatus.BAD_REQUEST) .body(errorResponse); } + + /** + * 로그인 실패 처리 (비밀번호 틀림, 이메일 없음 등) + * Spring Security에서 발생하는 BadCredentialsException을 잡아서 + * AUTH_401_3 에러 코드로 반환 + */ + @ExceptionHandler({BadCredentialsException.class, InternalAuthenticationServiceException.class}) + public ResponseEntity handleLoginException(Exception e, HttpServletRequest request) { + log.error("로그인 실패: {}", e.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.of( + AuthErrorCode.LOGIN_FAILED, + request + ); + + return ResponseEntity + .status(AuthErrorCode.LOGIN_FAILED.getHttpStatus()) + .body(errorResponse); + } + + /** + * JWT 토큰 유효성 검사 실패 처리 (reissue 과정에서 조작된 토큰 등) + * SignatureException, MalformedJwtException, UnsupportedJwtException 등을 + * JwtTokenProvider에서 catch하여 JwtException("Invalid Token")으로 던지고 있음 + */ + @ExceptionHandler({JwtException.class, IllegalArgumentException.class}) + public ResponseEntity handleJwtException(Exception e, HttpServletRequest request) { + log.warn("유효하지 않은 JWT 토큰입니다: {}", e.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.of( + AuthErrorCode.INVALID_TOKEN_FORMAT, + request + ); + + return ResponseEntity + .status(AuthErrorCode.INVALID_TOKEN_FORMAT.getHttpStatus()) + .body(errorResponse); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java index c3a0606..6f0ab6d 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java @@ -1,22 +1,45 @@ package com.whereyouad.WhereYouAd.global.security; +import com.whereyouad.WhereYouAd.global.security.jwt.*; +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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration +@RequiredArgsConstructor +@EnableWebSecurity public class SecurityConfig { + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) //CSRF 보호 비활성화 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 관리 정책을 STATELESS -> JWT 사용하므로 + ) + .exceptionHandling(exception -> exception //예외 처리 + .authenticationEntryPoint(jwtAuthenticationEntryPoint) //인증 실패 시(로그인 미진행, 토큰 만료 등) + .accessDeniedHandler(jwtAccessDeniedHandler) //인가 실패 시(권한 부족등)(현재 로직에서는 동작 X -> 모두 ROLE_USER 이므로) + ) .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() //우선 모든 접근 허용 -> 추후 로그인 구현 이후 접근 제한 예정 - ); + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() //swagger 접근 허용 + .requestMatchers("/api/users/signup", "/api/auth/**").permitAll() //로그인, 회원가입 접근 허용 + .anyRequest().authenticated() //이외 접근은 인증 필요 + ) + //Spring Security 의 기본 UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter 등록 + //Spring Security 가 기본 로그인을 수행하기 전에, JWT 토큰을 먼저 검사해서 유효하면 바로 인증 처리 하기 위해 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/CustomUserDetails.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/CustomUserDetails.java new file mode 100644 index 0000000..196adb8 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/CustomUserDetails.java @@ -0,0 +1,67 @@ +package com.whereyouad.WhereYouAd.global.security.jwt; + +import com.whereyouad.WhereYouAd.domains.user.domain.constant.UserStatus; +import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final User user; + + // 일단 User 엔티티에 권한 구분(ADMIN / USER) 가 없기도 하고, + // 팀장, 멤버등의 역할 구분은 2차 MVP 에서 진행한다. + // 따라서 일단은 편의를 위해 User 를 모두 사용자(ROLE_USER) 권한을 갖도록 진행 + // 개발자용 페이지를 따로 만든다면 해당 메서드 및 User 엔티티 변경 필요 + @Override + public Collection getAuthorities() { +// return List.of(); + return Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + // 계정 잠김 여부 (true: 안 잠김) + @Override + public boolean isAccountNonLocked() { + return true; // 나중에 로그인 실패 5회 시 잠금 로직 등이 필요하면 user.isLocked() 등으로 교체 + } + + // 비밀번호 만료 여부 (true: 만료 안 됨) + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return user.getStatus() == UserStatus.ACTIVE; + } + + // 편의 메서드: 컨트롤러에서 @AuthenticationPrincipal로 ID만 바로 꺼내 쓸 때 유용 + public Long getUserId() { + return user.getId(); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/CustomUserDetailsService.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/CustomUserDetailsService.java new file mode 100644 index 0000000..21f2578 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.whereyouad.WhereYouAd.global.security.jwt; + +import com.whereyouad.WhereYouAd.domains.user.exception.code.AuthErrorCode; +import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User; +import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; +import com.whereyouad.WhereYouAd.global.exception.AppException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String email) { + User user = userRepository.findUserByEmail(email) + .orElseThrow(() -> new AppException(AuthErrorCode.USER_NOT_FOUND)); + + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..73e249b --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,40 @@ +package com.whereyouad.WhereYouAd.global.security.jwt; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { +// 권한 관련 (ADMIN / USER) 비인가 접근 시도시 발생하는 예외를 처리하는 Handler +// 1차 MVP 에서는 권한 구분을 하지 않으므로 1차에서는 해당 Handler 동작 X -> 추후 확장을 위한 코드 + + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException + { + log.warn("Forbidden Error: {}", accessDeniedException.getMessage()); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + // JSON 응답 작성 + response.getWriter().write( + "{" + + "\"status\": 403," + + "\"error\": \"Forbidden\"," + + "\"message\": \"해당 리소스에 접근할 권한이 없습니다.\"," + + "\"path\": \"" + request.getRequestURI() + "\"" + + "}" + ); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..4c176cb --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,49 @@ +package com.whereyouad.WhereYouAd.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whereyouad.WhereYouAd.domains.user.exception.code.AuthErrorCode; +import com.whereyouad.WhereYouAd.global.response.ErrorResponse; +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.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 인증 실패 시 처리 핸들러 (401 Unauthorized) + * 역할: 사용자가 인증 없이(혹은 유효하지 않은 자격 증명으로) + * 보호된 리소스(API)에 접근하려 할 때 동작. + * SecurityConfig에서 .authenticationEntryPoint() 로 등록하여 사용 + */ +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private ObjectMapper objectMapper = new ObjectMapper(); + + //인증 실패 시, + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException + { + log.error("인증 실패 (EntryPoint): {}", authException.getMessage()); + + //응답 헤더 설정 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401 상태코드 설정 + + //INVALID_TOKEN_FORMAT 으로 응답 생성 및 반환 + AuthErrorCode errorCode = AuthErrorCode.INVALID_TOKEN_FORMAT; + ErrorResponse errorResponse = ErrorResponse.of(errorCode, request); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..317bade --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.whereyouad.WhereYouAd.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whereyouad.WhereYouAd.domains.user.exception.code.AuthErrorCode; +import com.whereyouad.WhereYouAd.global.response.ErrorResponse; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +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.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final CustomUserDetailsService customUserDetailService; //DB 에서 User 정보 가져오는 Service 클래스 + private final ObjectMapper objectMapper; + + //들어오는 로직에 대한 JWT 토큰 기반 실제 필터링 메서드 + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException + { + //Request 헤더에서 JWT Token(AccessToken) 추출 + String token = resolveToken(request); + try { + + //AccessToken 공백 확인 + if (StringUtils.hasText(token)) { + + //AccessToken 유효성 확인 + jwtTokenProvider.validateToken(token); //예외 발생 가능 구간 -> ExpiredJwtException 등 + + //토큰에서 email 값 추출 + String email = jwtTokenProvider.getSubject(token); + //& email 값으로 DB 내 해당 email 로 가입한 회원 존재하는지 확인 + UserDetails userDetails = customUserDetailService.loadUserByUsername(email); + + //Spring Security 가 인식 가능한 인증 객체(Authentication) 생성 + //이미 인증된 상태에서 Security 가 인식 가능하게 만드는 것 임으로 비밀번호(credentials) 필드는 null + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + //SecurityContextHolder 에 인증 객체 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (ExpiredJwtException e) { //토큰 만료시 예외 처리 + setErrorResponse(response, AuthErrorCode.TOKEN_EXPIRED, request); + return; + } catch (JwtException | IllegalArgumentException e) { //토큰 위조 or 손상 시 예외 처리 + setErrorResponse(response, AuthErrorCode.INVALID_TOKEN_FORMAT, request); + return; + } + + filterChain.doFilter(request, response); + } + + //Request Header 에서 토큰 정보를 꺼내오는 메서드 + //Authorization: Bearer {token} 형태 파싱하는 메서드 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } + + //JSON 에러 응답을 직접 작성하는 메서드 + //Filter 에서 발생한 예외는 GlobalHandler 로 처리 불가 -> 여기서 예외 응답 처리 + private void setErrorResponse(HttpServletResponse response, AuthErrorCode errorCode, HttpServletRequest request) throws IOException { + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + + ErrorResponse errorResponse = ErrorResponse.of(errorCode, request); + + // 객체를 JSON 문자열로 변환하여 출력 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..9a92f5e --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,105 @@ +package com.whereyouad.WhereYouAd.global.security.jwt; + +import com.whereyouad.WhereYouAd.global.security.jwt.dto.TokenResponse; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class JwtTokenProvider { + + //JWT 토큰 내 권한 정보를 담을 때 사용하는 key 값 + private static final String AUTHORITIES_KEY = "auth"; + //HTTP 헤더에 붙일 타입(Bearer {token}) + private static final String BEARER_TYPE = "Bearer"; + //AccessToken 만료 시간 + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; //30분 + //RefreshToken 만료 시간 + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; //7일 + + //암,복호화에 사용하는 키 값 + private final Key key; + + //application.yml 에 jwt.secret 값 설정 필요 + public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + //AccessToken, RefreshToken 생성 메서드 + public TokenResponse generateToken(Authentication authentication) { + //사용자 권한(ROLE_USER) 가져와서 문자열로 반환 + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = new Date().getTime(); + Date accessTokenExpireIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME); + + //AccessToken 생성 + String accessToken = Jwts.builder() + .setSubject(authentication.getName()) // Payload "sub": 유저의 이메일(ID) + .claim(AUTHORITIES_KEY, authorities) // Payload "auth": "ROLE_USER" + .setExpiration(accessTokenExpireIn) // Payload "exp": 만료 시간 + .signWith(key, SignatureAlgorithm.HS512) // Header "alg": HS512 알고리즘으로 서명 + .compact(); + + //RefreshToken 생성 + Date refreshTokenExpireIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME); + //RefreshToken 은 권한 정보(claims) 는 담지 않고, 누구인지 구별하기 위한 Subject(email) 만 추가 + String refreshToken = Jwts.builder() + .setSubject(authentication.getName()) //sub: email + .setExpiration(refreshTokenExpireIn) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + + return TokenResponse.builder() + .grantType(BEARER_TYPE) + .accessToken(accessToken) + .accessTokenExpireIn(accessTokenExpireIn.getTime()) + .refreshToken(refreshToken) + .build(); + } + + //토큰 정보 검증 메서드 + public void validateToken(String token) { + try { + //서명 키(Key) 통한 토큰 복호화 + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + } catch (ExpiredJwtException e) { + // 만료된 토큰인 경우: 재발급(reissue)을 위해 구체적인 예외 덩지기 + throw e; + + } catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) { + // 그 외 잘못된 토큰 형식은 모두 "잘못된 토큰 형식 입니다." 으로 통칭하여 예외 발생 + throw new JwtException("잘못된 토큰 형식 입니다."); + } + } + + //AccessToken 또는 RefreshToken 을 받아 복호화 하여 Subject 인 이메일 값 추출 + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } + + //토큰 복호화 하여 Claims 부분을 반환 + //만료된 토큰이라도 정보 꺼낼 수 있도록 처리 + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); + } catch (ExpiredJwtException e) { + //만료 토큰이라도 재발급(reissue) 시에는 누구인지 알아야 한다. + return e.getClaims(); + } + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/dto/TokenResponse.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/dto/TokenResponse.java new file mode 100644 index 0000000..13107f6 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/jwt/dto/TokenResponse.java @@ -0,0 +1,13 @@ +package com.whereyouad.WhereYouAd.global.security.jwt.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Builder; + +@Builder +public record TokenResponse( + String grantType, //Bearer + String accessToken, + Long accessTokenExpireIn, + @JsonIgnore //RefreshToken 은 본문으로 반환하지 않고 쿠키 값으로 반환 + String refreshToken +) {} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..8ff5a60 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,3 @@ +jwt: + secret: "rEYzQhGBcEalhSkn2Fh4KaH+7qgWC/jsHGtQkr9/6IZT5irDbSJeyIW+Iq0UV2k1vX5Z72lEL28LfcDA+szesA==" + #PR 빌드 통과를 위한 임의의 jwt.secret 값 -> 이 값이 아닌 추후에 협의한 랜덤값 사용 \ No newline at end of file