diff --git a/build.gradle b/build.gradle index 9314656..c174a87 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,11 @@ dependencies { // Spring-security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + + // Jwt Token + implementation group:'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + implementation group:'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + implementation group:'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' } tasks.named('compileJava') { diff --git a/src/main/java/com/likelion/trendithon/domain/user/controller/AuthController.java b/src/main/java/com/likelion/trendithon/domain/user/controller/AuthController.java new file mode 100644 index 0000000..544f612 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/controller/AuthController.java @@ -0,0 +1,92 @@ +package com.likelion.trendithon.domain.user.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.likelion.trendithon.domain.user.repository.UserRepository; +import com.likelion.trendithon.global.auth.JwtUtil; + +import io.jsonwebtoken.ExpiredJwtException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "User") +public class AuthController { + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + + @Operation( + summary = "[ 토큰 O | Refresh Token 재발급 ]", + description = "전송된 Refresh Token이 만료되었을 경우 재발급") + @PostMapping("/refresh-token") + public ResponseEntity refresh(@RequestHeader("Authorization") String refreshToken) { + try { + // Bearer 검증 + if (refreshToken == null || !refreshToken.startsWith("Bearer ")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("Refresh Token이 필요합니다.")); + } + + String token = refreshToken.substring(7); + // Refresh Token에서 loginId 추출 + String loginId = jwtUtil.extractLoginId(token); + + if (loginId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("유효하지 않은 Refresh Token입니다.")); + } + + // refresh Token 만료 여부 확인 + if (jwtUtil.isTokenExpired(token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("Refresh Token이 만료되었습니다. 다시 로그인해주세요.")); + } + + // User 조회 + userRepository + .findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + + // 새로운 Access Token 발급 + String newAccessToken = jwtUtil.createAccessToken(loginId); + return ResponseEntity.ok(new TokenResponse(newAccessToken)); + + } + // refresh Token 파싱 실패 (만료된 토큰) + catch (ExpiredJwtException e) { + log.error("만료된 Refresh Token입니다."); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("Refresh Token이 만료되었습니다. 다시 로그인해주세요.")); + + } catch (Exception e) { + log.error("Token 갱신 중 오류 발생: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("토큰 갱신 중 서버 오류가 발생했습니다. 잠시 후 다시시도해주세요.")); + } + } +} + +@Getter +@AllArgsConstructor +class TokenResponse { + private String accessToken; +} + +@Getter +@AllArgsConstructor +class ErrorResponse { + private String message; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java b/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java index 6bbd394..76a593b 100644 --- a/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java +++ b/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java @@ -1,3 +1,57 @@ package com.likelion.trendithon.domain.user.controller; -public class UserController {} +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.likelion.trendithon.domain.user.dto.request.DuplicateCheckRequest; +import com.likelion.trendithon.domain.user.dto.request.LoginRequest; +import com.likelion.trendithon.domain.user.dto.request.SignUpRequest; +import com.likelion.trendithon.domain.user.dto.response.DuplicateCheckResponse; +import com.likelion.trendithon.domain.user.service.UserService; +import com.likelion.trendithon.domain.user.util.NicknameGenerator; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Tag(name = "User", description = "User 관리 API") +public class UserController { + + private final UserService userService; + + @Operation(summary = "[ 토큰 X | 회원가입 ]", description = "사용자 회원가입") + @PostMapping("/register") + public ResponseEntity register( + @Parameter(description = "회원가입 정보") @RequestBody SignUpRequest signUpRequest) { + String nickname = NicknameGenerator.generateNickname(); + + return userService.register(signUpRequest, nickname); + } + + @Operation(summary = "[ 토큰 X | 랜덤 닉네임 생성 ]", description = "랜덤 닉네임 생성") + @PostMapping("/generate-nickname") + public String generateNickname() { + return NicknameGenerator.generateNickname(); + } + + @Operation(summary = "[ 토큰 X | 로그인 ]", description = "사용자 로그인") + @PostMapping("/login") + public ResponseEntity login( + @Parameter(description = "로그인 정보") @RequestBody LoginRequest loginRequest) { + return userService.login(loginRequest); + } + + @Operation(summary = "[ 토큰 X | 아이디 중복 검사 ]", description = "아이디 중복 검사") + @PostMapping("/check-duplicate") + public ResponseEntity checkLoginIdDuplicate( + @Parameter(description = "중복 검사할 아이디") @RequestBody DuplicateCheckRequest request) { + return userService.checkLoginIdDuplicate(request); + } +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/dto/request/DuplicateCheckRequest.java b/src/main/java/com/likelion/trendithon/domain/user/dto/request/DuplicateCheckRequest.java new file mode 100644 index 0000000..78ca1bc --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/dto/request/DuplicateCheckRequest.java @@ -0,0 +1,14 @@ +package com.likelion.trendithon.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class DuplicateCheckRequest { + @Schema(description = "아이디", example = "cardoteam0226") + private String loginId; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/dto/request/LoginRequest.java b/src/main/java/com/likelion/trendithon/domain/user/dto/request/LoginRequest.java new file mode 100644 index 0000000..d244cde --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/dto/request/LoginRequest.java @@ -0,0 +1,17 @@ +package com.likelion.trendithon.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class LoginRequest { + @Schema(description = "아이디", example = "cardoteam0226") + private String loginId; + + @Schema(description = "비밀번호") + private String password; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/likelion/trendithon/domain/user/dto/request/SignUpRequest.java new file mode 100644 index 0000000..126a6de --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/dto/request/SignUpRequest.java @@ -0,0 +1,20 @@ +package com.likelion.trendithon.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class SignUpRequest { + @Schema(description = "아이디", example = "cardoteam0226") + private String loginId; + + @Schema(description = "비밀번호") + private String password; + + @Schema(description = "닉네임", example = "용감한 사자") + private String nickname; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/dto/response/DuplicateCheckResponse.java b/src/main/java/com/likelion/trendithon/domain/user/dto/response/DuplicateCheckResponse.java new file mode 100644 index 0000000..9362ded --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/dto/response/DuplicateCheckResponse.java @@ -0,0 +1,15 @@ +package com.likelion.trendithon.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class DuplicateCheckResponse { + @Schema(description = "아이디 중복 검사 결과", example = "true") + private boolean result; + + @Schema(description = "응답 메세지", example = "사용 가능한 아이디입니다.") + private String message; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/dto/response/LoginResponse.java b/src/main/java/com/likelion/trendithon/domain/user/dto/response/LoginResponse.java new file mode 100644 index 0000000..40d7df3 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/dto/response/LoginResponse.java @@ -0,0 +1,18 @@ +package com.likelion.trendithon.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class LoginResponse { + @Schema(description = "로그인 결과", example = "true") + private boolean success; + + @Schema(description = "응답 메세지", example = "로그인에 성공하였습니다.") + private String message; + + @Schema(description = "JWT 액세스 토큰") + private String accessToken; // JWT 액세스 토큰 +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/dto/response/SignUpResponse.java b/src/main/java/com/likelion/trendithon/domain/user/dto/response/SignUpResponse.java new file mode 100644 index 0000000..6fe27d3 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/dto/response/SignUpResponse.java @@ -0,0 +1,18 @@ +package com.likelion.trendithon.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class SignUpResponse { + @Schema(description = "회원가입 결과", example = "true") + private boolean success; + + @Schema(description = "응답 메세지", example = "회원가입이 완료되었습니다.") + private String message; + + @Schema(description = "닉네임", example = "용감한 사자") + private String nickname; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/entity/User.java b/src/main/java/com/likelion/trendithon/domain/user/entity/User.java index b2167e0..cf957fe 100644 --- a/src/main/java/com/likelion/trendithon/domain/user/entity/User.java +++ b/src/main/java/com/likelion/trendithon/domain/user/entity/User.java @@ -1,3 +1,39 @@ package com.likelion.trendithon.domain.user.entity; -public class User {} +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import com.likelion.trendithon.global.common.BaseTimeEntity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +@Table(name = "users") +public class User extends BaseTimeEntity { + @Id + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "nickname", nullable = false, unique = true) + private String nickname; + + @Column(name = "refresh_token") + private String refreshToken; + + @Column(name = "role") + private String userRole; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/repository/UserRepository.java b/src/main/java/com/likelion/trendithon/domain/user/repository/UserRepository.java index b6df935..f17dcca 100644 --- a/src/main/java/com/likelion/trendithon/domain/user/repository/UserRepository.java +++ b/src/main/java/com/likelion/trendithon/domain/user/repository/UserRepository.java @@ -1,3 +1,11 @@ package com.likelion.trendithon.domain.user.repository; -public class UserRepository {} +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.likelion.trendithon.domain.user.entity.User; + +public interface UserRepository extends JpaRepository { + Optional findByLoginId(String loginId); +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java b/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java index b48d512..600c6cc 100644 --- a/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java +++ b/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java @@ -1,3 +1,130 @@ package com.likelion.trendithon.domain.user.service; -public class UserService {} +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.likelion.trendithon.domain.user.dto.request.DuplicateCheckRequest; +import com.likelion.trendithon.domain.user.dto.request.LoginRequest; +import com.likelion.trendithon.domain.user.dto.request.SignUpRequest; +import com.likelion.trendithon.domain.user.dto.response.DuplicateCheckResponse; +import com.likelion.trendithon.domain.user.dto.response.LoginResponse; +import com.likelion.trendithon.domain.user.dto.response.SignUpResponse; +import com.likelion.trendithon.domain.user.entity.User; +import com.likelion.trendithon.domain.user.repository.UserRepository; +import com.likelion.trendithon.global.auth.JwtUtil; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +@Service +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + @Transactional + public ResponseEntity register(SignUpRequest request, String nickname) { + try { + // 중복 회원 검사 + if (userRepository.findByLoginId(request.getLoginId()).isPresent()) { + log.warn("[POST /api/users/register] 회원가입 실패 - 이미 존재하는 ID: {}", request.getLoginId()); + return ResponseEntity.ok( + SignUpResponse.builder().success(false).message("이미 존재하는 아이디입니다.").build()); + } + + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(request.getPassword()); + + User user = + User.builder() + .loginId(request.getLoginId()) + .password(encodedPassword) + .nickname(nickname) + .userRole("USER") + .build(); + userRepository.save(user); + + log.info( + "[POST /api/users/register] 회원가입 성공 - ID: {}, 닉네임: {}", + user.getLoginId(), + user.getNickname()); + return ResponseEntity.ok( + SignUpResponse.builder().success(true).message("회원가입이 완료되었습니다.").build()); + } catch (Exception e) { + log.error("[POST /api/users/register] 회원가입 실패 - ID: {}", request.getLoginId()); + return ResponseEntity.ok( + SignUpResponse.builder().success(false).message("회원가입 처리 중 오류가 발생했습니다.").build()); + } + } + + // 로그인 + @Transactional + public ResponseEntity login(LoginRequest request) { + try { + // 1. 로그인 아이디로 사용자 찾기 + User user = + userRepository + .findByLoginId(request.getLoginId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 아이디입니다.")); + + // 2. 비밀번호 확인 + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + return ResponseEntity.ok( + LoginResponse.builder().success(false).message("잘못된 비밀번호입니다.").build()); + } + + // 3. 토큰 생성 + String accessToken = jwtUtil.createAccessToken(user.getLoginId()); + String refreshToken = jwtUtil.createRefreshToken(user.getLoginId()); + + // 4. refreshToken DB에 저장 + user.setRefreshToken(refreshToken); + userRepository.save(user); + + // 5. 로그인 성공 응답 생성 + log.info( + "[POST /api/users/login] 로그인 성공 - 아이디: {} 닉네임: {}", + user.getLoginId(), + user.getNickname()); + return ResponseEntity.ok( + LoginResponse.builder() + .accessToken(accessToken) + .success(true) + .message("로그인에 성공하였습니다.") + .build()); + + } catch (IllegalArgumentException e) { + // 6. 존재하지 않는 아이디인 경우 + log.warn("[POST /api/users/login] 로그인 실패 - 잘못된 아이디: {}", request.getLoginId()); + return ResponseEntity.ok( + LoginResponse.builder().success(false).message(e.getMessage()).build()); + } catch (Exception e) { + // 7. 기타 예외 처리 + log.error( + "[POST /api/users/login] 로그인 오류 발생 - 아이디: {}, 에러: {}", + request.getLoginId(), + e.getMessage()); + return ResponseEntity.ok( + LoginResponse.builder().success(false).message("로그인 처리 중 서버 오류 발생." + e).build()); + } + } + + public ResponseEntity checkLoginIdDuplicate( + DuplicateCheckRequest request) { + // 로그인 아이디(이메일) 존재 여부 확인 + boolean isDuplicate = userRepository.findByLoginId(request.getLoginId()).isPresent(); + + // 응답 생성 + DuplicateCheckResponse response = + DuplicateCheckResponse.builder() + .result(isDuplicate) + .message(isDuplicate ? "이미 사용 중인 이메일입니다." : "사용 가능한 이메일입니다.") + .build(); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/util/NicknameGenerator.java b/src/main/java/com/likelion/trendithon/domain/user/util/NicknameGenerator.java new file mode 100644 index 0000000..18049a1 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/util/NicknameGenerator.java @@ -0,0 +1,27 @@ +package com.likelion.trendithon.domain.user.util; + +import java.util.Random; + +public class NicknameGenerator { + private static final String[] ACTIONS = { + "용감한", "똑똑한", "날쌘", "젠틀한", "대담한", + "사나운", "조용한", "행복한", "열정적인", "강력한", + "현명한", "신비로운", "빠른", "침착한", "힘찬", + "활기찬", "차분한", "긍정적인", "정의로운" + }; + + private static final String[] ANIMALS = { + "사자", "호랑이", "곰", "늑대", "독수리", + "여우", "표범", "돌고래", "매", "용", + "코끼리", "사슴", "판다", "고양이", "강아지", + "토끼", "올빼미", "독수리", "원숭이", "거북이" + }; + + public static String generateNickname() { + long seed = System.currentTimeMillis(); // 현재 시간 + Random random = new Random(seed); // seed 추가 + String action = ACTIONS[random.nextInt(ACTIONS.length)]; + String animal = ANIMALS[random.nextInt(ANIMALS.length)]; + return action + " " + animal; + } +} diff --git a/src/main/java/com/likelion/trendithon/global/auth/JwtFilter.java b/src/main/java/com/likelion/trendithon/global/auth/JwtFilter.java new file mode 100644 index 0000000..52038c8 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/global/auth/JwtFilter.java @@ -0,0 +1,156 @@ +package com.likelion.trendithon.global.auth; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.util.Collections; +import java.util.NoSuchElementException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.likelion.trendithon.domain.user.entity.User; +import com.likelion.trendithon.domain.user.repository.UserRepository; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.SignatureException; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + + try { + // Authorization 헤더에서 JWT 토큰을 가져옴 + final String authorizationHeader = request.getHeader("Authorization"); + + // 인증 헤더가 없는 경우 다음 필터로 + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String jwt = authorizationHeader.substring(7); + String loginId = jwtUtil.extractLoginId(jwt); + + // 토큰은 유효하지만 SecurityContext에 인증 정보가 없는 경우 + if (loginId != null && SecurityContextHolder.getContext().getAuthentication() == null) { + User user = + userRepository + .findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + + if (jwtUtil.validateToken(jwt)) { + UserDetails userDetails = + new org.springframework.security.core.userdetails.User( + user.getLoginId(), + user.getPassword(), + Collections.singletonList( + new SimpleGrantedAuthority("ROLE_" + user.getUserRole()))); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("Security Context에 '{}' 인증 정보를 저장했습니다", loginId); + } + } + + filterChain.doFilter(request, response); + + } catch (ExpiredJwtException e) { + log.warn("만료된 JWT 토큰입니다. URI: {}", request.getRequestURI()); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "만료된 토큰입니다."); + } catch (SignatureException | MalformedJwtException e) { + log.warn("유효하지 않은 JWT 토큰입니다. URI: {}", request.getRequestURI()); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다."); + } catch (UsernameNotFoundException e) { + log.warn("존재하지 않는 사용자입니다. URI: {}", request.getRequestURI()); + sendErrorResponse(response, HttpServletResponse.SC_NOT_FOUND, e.getMessage()); + } catch (ServletException e) { + Throwable cause = e.getCause(); + handleException(cause, request, response); + } catch (Exception e) { + handleException(e, request, response); + } + } + + private void handleException( + Throwable e, HttpServletRequest request, HttpServletResponse response) throws IOException { + int statusCode; + String message = e.getMessage(); + + if (e instanceof IllegalArgumentException || e instanceof NoSuchElementException) { + statusCode = HttpServletResponse.SC_NOT_FOUND; // 404 + if (message == null) message = "요청한 리소스를 찾을 수 없습니다."; + } else if (e instanceof IllegalStateException) { + statusCode = HttpServletResponse.SC_BAD_REQUEST; // 400 + if (message == null) message = "잘못된 요청입니다."; + } else if (e instanceof AccessDeniedException) { + statusCode = HttpServletResponse.SC_FORBIDDEN; // 403 + if (message == null) message = "접근 권한이 없습니다."; + } else if (e instanceof AuthenticationException) { + statusCode = HttpServletResponse.SC_UNAUTHORIZED; // 401 + if (message == null) message = "인증에 실패했습니다."; + } else if (e instanceof ExpiredJwtException) { + statusCode = HttpServletResponse.SC_UNAUTHORIZED; // 401 + message = "만료된 토큰입니다."; + } else if (e instanceof SignatureException || e instanceof MalformedJwtException) { + statusCode = HttpServletResponse.SC_UNAUTHORIZED; // 401 + message = "유효하지 않은 토큰입니다."; + } else { + statusCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR; // 500 + message = "서버 내부 오류가 발생했습니다."; + } + + log.error( + "필터 처리 중 예외 발생 - URI: {}, Method: {}, Error: {}, ErrorType: {}", + request.getRequestURI(), + request.getMethod(), + e.getMessage(), + e.getClass().getSimpleName()); + + sendErrorResponse(response, statusCode, message); + } + + private void sendErrorResponse(HttpServletResponse response, int statusCode, String message) + throws IOException { + response.setStatus(statusCode); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(String.format("{\"isSuccess\":false,\"message\":\"%s\"}", message)); + } + + // 특정 경로는 필터 적용 제외 (예: 로그인, 회원가입) + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/api/auth/"); + } +} diff --git a/src/main/java/com/likelion/trendithon/global/auth/JwtUtil.java b/src/main/java/com/likelion/trendithon/global/auth/JwtUtil.java new file mode 100644 index 0000000..3141e2d --- /dev/null +++ b/src/main/java/com/likelion/trendithon/global/auth/JwtUtil.java @@ -0,0 +1,103 @@ +package com.likelion.trendithon.global.auth; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class JwtUtil { + @Value("${secret-key}") + private String secretKey; + + // access token 만료 기간 1시간 + // private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60; + private static final long ACCESS_TOKEN_EXPIRE_TIME = TimeUnit.DAYS.toMillis(7); + // refresh token 만료 기간 30일 + private static final long REFRESH_TOKEN_EXPIRE_TIME = TimeUnit.DAYS.toMillis(30); + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + // Access Token 발급 부분 + public String createAccessToken(String loginId) { + Date now = new Date(); + Date expireTime = new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME); + Key key = getSigningKey(); + + return Jwts.builder() + .setSubject(loginId) + .setIssuedAt(new Date()) + .setExpiration(expireTime) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + // Refresh Token 발급 부분 + public String createRefreshToken(String userId) { + Date now = new Date(); + Date expireTime = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME); + Key key = getSigningKey(); + return Jwts.builder() + .setSubject(userId) + .setIssuedAt(new Date()) + .setExpiration(expireTime) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + // 토큰 검증 부분 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + } + return false; + } + + // JWT 토큰 Claims에서 모든 정보 추출 + private Claims getAllClaimsFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + // 토큰에서 loginId 추출 + public String extractLoginId(String token) { + return getAllClaimsFromToken(token).getSubject(); + } + + // 토큰에서 만료 시간 추출 + public Date extractExpiration(String token) { + return getAllClaimsFromToken(token).getExpiration(); + } + + // 토큰이 만료되었는지 확인 + public boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } +} diff --git a/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java b/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java index 6f5b8de..e5eb70f 100644 --- a/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java +++ b/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java @@ -2,11 +2,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; 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.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @@ -45,14 +48,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**" // API 문서 ) .permitAll() - // 관리자 권한이 필요한 경로 설정 - // .requestMatchers(HttpMethod.POST, "/api/**") - // .hasRole("ADMIN") // 로그인이 필요한 경로 설정 - // .requestMatchers(HttpMethod.POST, "/api/**") - // .hasRole("GUEST") - .requestMatchers("/api/admin/**") - .hasRole("ADMIN") + .requestMatchers(HttpMethod.POST, "/api/**") + .hasRole("USER") // 그 외 모든 요청은 인증 필요 .anyRequest() .authenticated()); @@ -65,8 +63,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); // 모든 출처 허용 - configuration.addAllowedOrigin("https://likelion-sku.netlify.app"); configuration.addAllowedOrigin("http://localhost:5173"); // 개발 서버 + configuration.addAllowedOrigin("https://localhost:5173"); // 배포 서버 // 모든 HTTP 메서드 허용 configuration.addAllowedMethod("*"); // 모든 헤더 허용 @@ -80,4 +78,10 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } + + /** 비밀번호 인코더 Bean 등록 */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } }