diff --git a/build.gradle b/build.gradle index 09350fa..5955c14 100644 --- a/build.gradle +++ b/build.gradle @@ -29,8 +29,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // Spring Security -// implementation 'org.springframework.boot:spring-boot-starter-security' -// testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' // Spring Web implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/src/main/java/com/example/triptalk/TriptalkApplication.java b/src/main/java/com/example/triptalk/TriptalkApplication.java index fc34f72..a44eac9 100644 --- a/src/main/java/com/example/triptalk/TriptalkApplication.java +++ b/src/main/java/com/example/triptalk/TriptalkApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class TriptalkApplication { diff --git a/src/main/java/com/example/triptalk/domain/user/controller/AuthController.java b/src/main/java/com/example/triptalk/domain/user/controller/AuthController.java new file mode 100644 index 0000000..f11106e --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/controller/AuthController.java @@ -0,0 +1,47 @@ +package com.example.triptalk.domain.user.controller; + +import com.example.triptalk.domain.user.dto.AuthRequest; +import com.example.triptalk.domain.user.dto.AuthResponse; +import com.example.triptalk.domain.user.service.AuthService; +import com.example.triptalk.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Auth", description = "인증 관련 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "회원가입", description = "이메일, 비밀번호, 닉네임으로 회원가입합니다.") + @PostMapping("/signup") + public ApiResponse signUp(@RequestBody AuthRequest.SignUpDTO request) { + return ApiResponse.onSuccess(authService.signUp(request)); + } + + @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인하여 토큰을 발급받습니다.") + @PostMapping("/login") + public ApiResponse login(@RequestBody AuthRequest.LoginDTO request) { + return ApiResponse.onSuccess(authService.login(request)); + } + + @Operation(summary = "토큰 재발급", description = "Refresh Token으로 Access Token을 재발급받습니다.") + @PostMapping("/refresh") + public ApiResponse reissue(@RequestBody AuthRequest.ReissueDTO request) { + return ApiResponse.onSuccess(authService.reissue(request)); + } + + @Operation(summary = "로그아웃", description = "로그아웃하여 Refresh Token을 삭제합니다.") + @PostMapping("/logout") + public ApiResponse logout(@AuthenticationPrincipal UserDetails userDetails) { + authService.logout(userDetails.getUsername()); + return ApiResponse.onSuccess(null); + } +} + diff --git a/src/main/java/com/example/triptalk/domain/user/converter/AuthConverter.java b/src/main/java/com/example/triptalk/domain/user/converter/AuthConverter.java new file mode 100644 index 0000000..dc0fc35 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/converter/AuthConverter.java @@ -0,0 +1,36 @@ +package com.example.triptalk.domain.user.converter; + +import com.example.triptalk.domain.user.dto.AuthRequest; +import com.example.triptalk.domain.user.dto.AuthResponse; +import com.example.triptalk.domain.user.entity.User; +import org.springframework.stereotype.Component; + +@Component +public class AuthConverter { + + public User toUser(AuthRequest.SignUpDTO request, String encodedPassword) { + return User.builder() + .email(request.getEmail()) + .password(encodedPassword) + .nickName(request.getNickName()) + .completedTravelCount(0) + .plannedTravelCount(0) + .build(); + } + + public AuthResponse.SignUpDTO toSignUpResponse(User user) { + return AuthResponse.SignUpDTO.builder() + .userId(user.getId()) + .email(user.getEmail()) + .nickName(user.getNickName()) + .build(); + } + + public AuthResponse.TokenDTO toTokenResponse(String accessToken, String refreshToken) { + return AuthResponse.TokenDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} + diff --git a/src/main/java/com/example/triptalk/domain/user/dto/AuthRequest.java b/src/main/java/com/example/triptalk/domain/user/dto/AuthRequest.java new file mode 100644 index 0000000..91dcefc --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/dto/AuthRequest.java @@ -0,0 +1,46 @@ +package com.example.triptalk.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class AuthRequest { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "회원가입 요청") + public static class SignUpDTO { + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "비밀번호", example = "password123") + private String password; + + @Schema(description = "닉네임", example = "여행자") + private String nickName; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "로그인 요청") + public static class LoginDTO { + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "비밀번호", example = "password123") + private String password; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "토큰 재발급 요청") + public static class ReissueDTO { + @Schema(description = "리프레시 토큰") + private String refreshToken; + } +} + diff --git a/src/main/java/com/example/triptalk/domain/user/dto/AuthResponse.java b/src/main/java/com/example/triptalk/domain/user/dto/AuthResponse.java new file mode 100644 index 0000000..fcb9625 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/dto/AuthResponse.java @@ -0,0 +1,37 @@ +package com.example.triptalk.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +public class AuthResponse { + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "토큰 응답") + public static class TokenDTO { + @Schema(description = "액세스 토큰") + private String accessToken; + + @Schema(description = "리프레시 토큰") + private String refreshToken; + } + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "회원가입 응답") + public static class SignUpDTO { + @Schema(description = "사용자 ID", example = "1") + private Long userId; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "닉네임", example = "여행자") + private String nickName; + } +} + diff --git a/src/main/java/com/example/triptalk/domain/user/entity/RefreshToken.java b/src/main/java/com/example/triptalk/domain/user/entity/RefreshToken.java new file mode 100644 index 0000000..61a9d85 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/entity/RefreshToken.java @@ -0,0 +1,32 @@ +package com.example.triptalk.domain.user.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private LocalDateTime expiryDate; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiryDate); + } +} + diff --git a/src/main/java/com/example/triptalk/domain/user/repository/RefreshTokenRepository.java b/src/main/java/com/example/triptalk/domain/user/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..4e29744 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.example.triptalk.domain.user.repository; + +import com.example.triptalk.domain.user.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + Optional findByUserId(Long userId); + void deleteByUserId(Long userId); +} + diff --git a/src/main/java/com/example/triptalk/domain/user/repository/UserRepository.java b/src/main/java/com/example/triptalk/domain/user/repository/UserRepository.java index 001c1ad..9a23000 100644 --- a/src/main/java/com/example/triptalk/domain/user/repository/UserRepository.java +++ b/src/main/java/com/example/triptalk/domain/user/repository/UserRepository.java @@ -10,5 +10,7 @@ public interface UserRepository extends JpaRepository { // email로 유저 조회 Optional findByEmail(String email); + // email 중복 체크 + boolean existsByEmail(String email); } diff --git a/src/main/java/com/example/triptalk/domain/user/service/AuthService.java b/src/main/java/com/example/triptalk/domain/user/service/AuthService.java new file mode 100644 index 0000000..d0e9c24 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/service/AuthService.java @@ -0,0 +1,16 @@ +package com.example.triptalk.domain.user.service; + +import com.example.triptalk.domain.user.dto.AuthRequest; +import com.example.triptalk.domain.user.dto.AuthResponse; + +public interface AuthService { + + AuthResponse.SignUpDTO signUp(AuthRequest.SignUpDTO request); + + AuthResponse.TokenDTO login(AuthRequest.LoginDTO request); + + AuthResponse.TokenDTO reissue(AuthRequest.ReissueDTO request); + + void logout(String email); +} + diff --git a/src/main/java/com/example/triptalk/domain/user/service/AuthServiceImpl.java b/src/main/java/com/example/triptalk/domain/user/service/AuthServiceImpl.java new file mode 100644 index 0000000..330f459 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/user/service/AuthServiceImpl.java @@ -0,0 +1,121 @@ +package com.example.triptalk.domain.user.service; + +import com.example.triptalk.domain.user.converter.AuthConverter; +import com.example.triptalk.domain.user.dto.AuthRequest; +import com.example.triptalk.domain.user.dto.AuthResponse; +import com.example.triptalk.domain.user.entity.RefreshToken; +import com.example.triptalk.domain.user.entity.User; +import com.example.triptalk.domain.user.repository.RefreshTokenRepository; +import com.example.triptalk.domain.user.repository.UserRepository; +import com.example.triptalk.global.apiPayload.code.status.ErrorStatus; +import com.example.triptalk.global.apiPayload.exception.handler.ErrorHandler; +import com.example.triptalk.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthServiceImpl implements AuthService { + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; + private final AuthConverter authConverter; + + @Override + public AuthResponse.SignUpDTO signUp(AuthRequest.SignUpDTO request) { + // 이메일 중복 체크 + if (userRepository.existsByEmail(request.getEmail())) { + throw new ErrorHandler(ErrorStatus.AUTH_DUPLICATE_EMAIL); + } + + // 비밀번호 인코딩 후 유저 생성 + String encodedPassword = passwordEncoder.encode(request.getPassword()); + User user = authConverter.toUser(request, encodedPassword); + User savedUser = userRepository.save(user); + + return authConverter.toSignUpResponse(savedUser); + } + + @Override + public AuthResponse.TokenDTO login(AuthRequest.LoginDTO request) { + // 유저 조회 + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.USER_NOT_FOUND)); + + // 비밀번호 검증 + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new ErrorHandler(ErrorStatus.AUTH_INVALID_PASSWORD); + } + + // 토큰 생성 + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail()); + + // RefreshToken 저장 + saveRefreshToken(user.getId(), refreshToken); + + return authConverter.toTokenResponse(accessToken, refreshToken); + } + + @Override + public AuthResponse.TokenDTO reissue(AuthRequest.ReissueDTO request) { + // RefreshToken 유효성 검증 + if (!jwtTokenProvider.validateToken(request.getRefreshToken())) { + throw new ErrorHandler(ErrorStatus.AUTH_INVALID_TOKEN); + } + + // DB에서 RefreshToken 조회 + RefreshToken refreshToken = refreshTokenRepository.findByToken(request.getRefreshToken()) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.AUTH_TOKEN_NOT_FOUND)); + + // 만료 여부 확인 + if (refreshToken.isExpired()) { + refreshTokenRepository.delete(refreshToken); + throw new ErrorHandler(ErrorStatus.AUTH_EXPIRED_TOKEN); + } + + // 유저 조회 + User user = userRepository.findById(refreshToken.getUserId()) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.USER_NOT_FOUND)); + + // 새로운 토큰 생성 + String newAccessToken = jwtTokenProvider.createAccessToken(user.getEmail()); + String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getEmail()); + + // 기존 RefreshToken 삭제 후 새로운 토큰 저장 + refreshTokenRepository.delete(refreshToken); + saveRefreshToken(user.getId(), newRefreshToken); + + return authConverter.toTokenResponse(newAccessToken, newRefreshToken); + } + + @Override + public void logout(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.USER_NOT_FOUND)); + refreshTokenRepository.deleteByUserId(user.getId()); + } + + private void saveRefreshToken(Long userId, String token) { + // 기존 토큰 삭제 + refreshTokenRepository.deleteByUserId(userId); + + // 새 토큰 저장 + RefreshToken refreshToken = RefreshToken.builder() + .token(token) + .userId(userId) + .expiryDate(LocalDateTime.now().plusSeconds( + jwtTokenProvider.getRefreshTokenExpiration() / 1000)) + .build(); + + refreshTokenRepository.save(refreshToken); + } +} + diff --git a/src/main/java/com/example/triptalk/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/example/triptalk/global/apiPayload/code/status/ErrorStatus.java index cd5fa75..b09faa7 100644 --- a/src/main/java/com/example/triptalk/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/example/triptalk/global/apiPayload/code/status/ErrorStatus.java @@ -20,6 +20,13 @@ public enum ErrorStatus implements BaseErrorCode { // 유저 관련 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER401", "아이디와 일치하는 사용자가 없습니다."), + // 인증 관련 에러 + AUTH_DUPLICATE_EMAIL(HttpStatus.CONFLICT, "AUTH401", "이미 존재하는 이메일입니다."), + AUTH_INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "AUTH402", "비밀번호가 일치하지 않습니다."), + AUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH403", "유효하지 않은 토큰입니다."), + AUTH_EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH404", "만료된 토큰입니다."), + AUTH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH405", "저장되지 않은 Refresh Token입니다."), + // 여행 계획 관련 에러 TRIP_PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "TRIP_PLAN401", "존재하지 않는 여행 계획입니다."), TRIP_PLAN_ALREADY_TRAVELED(HttpStatus.BAD_REQUEST, "TRIP_PLAN402", "이미 완료된(Traveled) 여행 계획입니다."); diff --git a/src/main/java/com/example/triptalk/global/config/SecurityConfig.java b/src/main/java/com/example/triptalk/global/config/SecurityConfig.java new file mode 100644 index 0000000..324b556 --- /dev/null +++ b/src/main/java/com/example/triptalk/global/config/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.example.triptalk.global.config; + +import com.example.triptalk.global.security.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // 인증 관련 API는 모두 허용 + .requestMatchers("/api/auth/**").permitAll() + // 여행지 조회는 비회원도 가능 + .requestMatchers("/api/trip-places/**").permitAll() + // Swagger UI 접근 허용 + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + // 나머지는 인증 필요 + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/example/triptalk/global/security/CustomUserDetails.java b/src/main/java/com/example/triptalk/global/security/CustomUserDetails.java new file mode 100644 index 0000000..137fe64 --- /dev/null +++ b/src/main/java/com/example/triptalk/global/security/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.example.triptalk.global.security; + +import com.example.triptalk.domain.user.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; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public Long getUserId() { + return user.getId(); + } +} + diff --git a/src/main/java/com/example/triptalk/global/security/CustomUserDetailsService.java b/src/main/java/com/example/triptalk/global/security/CustomUserDetailsService.java new file mode 100644 index 0000000..b8fc2ca --- /dev/null +++ b/src/main/java/com/example/triptalk/global/security/CustomUserDetailsService.java @@ -0,0 +1,24 @@ +package com.example.triptalk.global.security; + +import com.example.triptalk.domain.user.entity.User; +import com.example.triptalk.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +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.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email)); + return new CustomUserDetails(user); + } +} + diff --git a/src/main/java/com/example/triptalk/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/triptalk/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..dbecd0f --- /dev/null +++ b/src/main/java/com/example/triptalk/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,66 @@ +package com.example.triptalk.global.security.jwt; + +import com.example.triptalk.global.security.CustomUserDetailsService; +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.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; + +// 모든 HTTP 요청을 가로채서 JWT 토큰을 검증하고 인증 처리하는 필터 +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + // Request Header에서 JWT 토큰 추출 + String jwt = getJwtFromRequest(request); + + // 토큰 유효성 검증 + if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { + // 토큰에서 이메일 추출 + String email = jwtTokenProvider.getEmailFromToken(jwt); + + // UserDetails 조회 + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + + // Authentication 객체 생성 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // SecurityContext에 Authentication 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ex) { + logger.error("Could not set user authentication in security context", ex); + } + + filterChain.doFilter(request, response); + } + + // Request Header에서 토큰 정보 추출 + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} + diff --git a/src/main/java/com/example/triptalk/global/security/jwt/JwtProperties.java b/src/main/java/com/example/triptalk/global/security/jwt/JwtProperties.java new file mode 100644 index 0000000..db29323 --- /dev/null +++ b/src/main/java/com/example/triptalk/global/security/jwt/JwtProperties.java @@ -0,0 +1,18 @@ +package com.example.triptalk.global.security.jwt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + private String secret; + private long accessTokenExpiration; + private long refreshTokenExpiration; +} + + diff --git a/src/main/java/com/example/triptalk/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/example/triptalk/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..530a638 --- /dev/null +++ b/src/main/java/com/example/triptalk/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,80 @@ +package com.example.triptalk.global.security.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +@RequiredArgsConstructor +@Getter +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + + // SecretKey 생성 + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + } + + // Access Token 생성 + public String createAccessToken(String email) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenExpiration()); + + return Jwts.builder() + .subject(email) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + // Refresh Token 생성 + public String createRefreshToken(String email) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenExpiration()); + + return Jwts.builder() + .subject(email) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + // 토큰에서 이메일 추출 + public String getEmailFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims.getSubject(); + } + + // 토큰 유효성 검증(서명, 만료시간) + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + // Refresh Token 만료 시간 조회 + public long getRefreshTokenExpiration() { + return jwtProperties.getRefreshTokenExpiration(); + } +} +