Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.11'

runtimeOnly 'com.h2database:h2'

// JWT 의존성 추가
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

// Query DSL 설정
Expand Down
31 changes: 26 additions & 5 deletions src/main/java/com/issueDive/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
package com.issueDive.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

import com.issueDive.security.CustomUserDetailsService;
import com.issueDive.security.JwtAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final CustomUserDetailsService userDetailsService; // DB 기반 인증
private final JwtAuthenticationFilter jwtAuthenticationFilter; // JWT 필터


// 공개적으로 접근 가능한 URL 목록
private static final String[] PUBLIC_URLS = {
"/swagger-ui/**",
Expand All @@ -35,15 +48,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정
.csrf(AbstractHttpConfigurer::disable)
// 9월1일 변경 - 세션 관리 정책 (JWT는 stateless)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
// 모든 요청(/**)을 허용 (임시로)
.anyRequest().permitAll() // 개발 단계에서는 전체 허용
// .requestMatchers(PUBLIC_URLS).permitAll() // 공개 URL은 모두 허용
// .anyRequest().authenticated() // 나머지는 인증 필요
//.anyRequest().permitAll() // 개발 단계에서는 전체 허용
.requestMatchers(PUBLIC_URLS).permitAll() // 공개 URL은 모두 허용
.anyRequest().authenticated() // 나머지는 인증 필요
)
.formLogin(formLogin -> formLogin.disable()) // 폼 로그인 비활성화 (필요 시)
.logout(logout -> logout.disable()); // 로그아웃 비활성화 (필요 시)
.logout(logout -> logout.disable());
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);// 로그아웃 비활성화 (필요 시)
return http.build();
}

Expand Down Expand Up @@ -82,4 +98,9 @@ public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(user);
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}

}
57 changes: 48 additions & 9 deletions src/main/java/com/issueDive/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
package com.issueDive.controller;

import com.issueDive.dto.LoginRequestDTO;
import com.issueDive.dto.*;
import com.issueDive.service.UserService;
import com.issueDive.dto.ApiResponse;
import com.issueDive.dto.UserRequestDTO;
import com.issueDive.dto.UserResponseDTO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

import com.issueDive.util.JwtUtil;

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;

/**
* Create User (회원가입)
Expand All @@ -35,13 +40,47 @@ public ResponseEntity<ApiResponse<UserResponseDTO>> signUp(@Valid @RequestBody U
/**
* Login
* @param request 로그인 요청 DTO (email, password)
* @return 공통 응답 포맷 + 사용자 DTO (또는 인증 토큰)
* @return JWT 토큰과 사용자 정보
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<UserResponseDTO>> login(@Valid @RequestBody LoginRequestDTO request){
UserResponseDTO user = userService.login(request.getUsername(), request.getPassword());
ApiResponse<UserResponseDTO> response = ApiResponse.ok(user);
return new ResponseEntity<>(response, HttpStatus.OK);
public ResponseEntity<ApiResponse<JwtResponse>> login(@Valid @RequestBody LoginRequestDTO request) {
try {
// 인증된 사용자 정보 조회
UserResponseDTO userResponse = userService.findUserByEmail(request.getEmail());

// JWT AccessToken만 생성 (RefreshToken 제거)
String accessToken = jwtUtil.generateAccessToken(userResponse.getId(), userResponse.getEmail());

// JWT 응답 생성 (RefreshToken 제거)
JwtResponse jwtResponse = JwtResponse.of(
accessToken,
"Bearer",
14400L, // 9월1일 변경 - 4시간 (초 단위)
userResponse
);
return ResponseEntity.ok(ApiResponse.ok(jwtResponse));
} catch (Exception e) {
//인증 실패시 예외 던지기 (GlobalExceptionHandler에서 처리)
throw new com.issueDive.exception.AuthenticationFailedException();
}
}


/**
* 9월1일 변경 - 로그아웃 (JWT 기반에서는 클라이언트에서 토큰 삭제)
* @return 로그아웃 안내 메시지
*/
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Map<String, String>>> logout() {
//JWT는 stateless하므로 서버에서 특별한 로그아웃 처리 불필요

Map<String, String> responseData = Map.of(
"message", "로그아웃되었습니다. 클라이언트에서 토큰을 삭제해주세요.",
"instruction", "localStorage에서 accessToken을 제거하세요."
);

ApiResponse<Map<String, String>> response = ApiResponse.ok(responseData);
return ResponseEntity.ok(response);
}

/**
Expand Down
14 changes: 13 additions & 1 deletion src/main/java/com/issueDive/controller/IssueController.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,22 @@ public ResponseEntity<ApiResponse<IssueResponse>> getIssue(@PathVariable Long id
/**
* Update
* @param id 수정할 이슈 id
* @param request title, description, assignee(uid)
* @param request title, description, assignee(uid), labelIds
* @return 공통 응답 포맷 + 수정된 이슈 dto
*/
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<IssueResponse>> patchIssue(@PathVariable Long id,
@RequestBody UpdateIssueRequest request) {
return ResponseEntity.ok(ApiResponse.ok(issueService.updateIssue(id, request)));
}

/**
* Update (부분 수정)
* @param id 수정할 이슈 id
* @param request title, description, assigneeId, labelIds
* @return 공통 응답 포맷 + 수정된 이슈 dto
*/
@PatchMapping("/{id}")
public ResponseEntity<ApiResponse<IssueResponse>> updateIssue(@PathVariable Long id,
@RequestBody UpdateIssueRequest request) {
return ResponseEntity.ok(ApiResponse.ok(issueService.updateIssue(id, request)));
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/com/issueDive/dto/JwtResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.issueDive.dto;

import lombok.*;

@Getter
@AllArgsConstructor
@Builder
public class JwtResponse {
private String accessToken;
private String tokenType;
private Long expiresIn;
private UserResponseDTO user;

public static JwtResponse of (String accessToken, String tokenType, Long expiresIn, UserResponseDTO user){
return JwtResponse.builder()
.accessToken(accessToken)
.tokenType(tokenType)
.expiresIn(expiresIn)
.user(user)
.build();
}
}
7 changes: 4 additions & 3 deletions src/main/java/com/issueDive/dto/LoginRequestDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
@Getter
@Setter
public class LoginRequestDTO {
@Email(message = "올바른 username이 아닙니다.")
@NotBlank(message = "username은 필수입니다.")
private String username;

@Email(message = "올바른 email이 아닙니다.")
@NotBlank(message = "email은 필수입니다.")
private String email;

@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
Expand Down
5 changes: 4 additions & 1 deletion src/main/java/com/issueDive/dto/UpdateIssueRequest.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.issueDive.dto;

import java.util.List;

public record UpdateIssueRequest(
String title,
String description,
Long assigneeId
Long assigneeId,
List<Long> labelIds
) {}

3 changes: 2 additions & 1 deletion src/main/java/com/issueDive/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public enum ErrorCode {
LabelNotFound, IssueLabelNotFound, DuplicateLabel,

CommentNotFound, InvalidParentComment,
UserNotFound, DuplicateEmail, AuthenticationFailed
UserNotFound, DuplicateEmail, AuthenticationFailed,

}
32 changes: 32 additions & 0 deletions src/main/java/com/issueDive/exception/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.issueDive.exception;

import com.issueDive.dto.ErrorResponse;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.MethodArgumentNotValidException;

import javax.naming.AuthenticationException;

@RestControllerAdvice
public class GlobalExceptionHandler {

Expand Down Expand Up @@ -83,4 +88,31 @@ public ResponseEntity<ErrorResponse> handleIssueLabelNotFound(IssueLabelNotFound
.body(ErrorResponse.of(ErrorCode.IssueLabelNotFound, e.getMessage()));
}

/**
* WT 토큰 만료 예외 처리
*/
@ExceptionHandler(ExpiredJwtException.class)
public ResponseEntity<ErrorResponse> handleJwtExpired(ExpiredJwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.of(ErrorCode.AuthenticationFailed, "JWT 토큰이 만료되었습니다. 다시 로그인해주세요."));
}

/**
* JWT 토큰 형식 오류 예외 처리
*/
@ExceptionHandler(MalformedJwtException.class)
public ResponseEntity<ErrorResponse> handleJwtMalformed(MalformedJwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.of(ErrorCode.AuthenticationFailed, "잘못된 형식의 JWT 토큰입니다."));
}

/**
* JWT 지원되지 않는 토큰 예외 처리
*/
@ExceptionHandler(UnsupportedJwtException.class)
public ResponseEntity<ErrorResponse> handleJwtUnsupported(UnsupportedJwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.of(ErrorCode.AuthenticationFailed, "지원되지 않는 JWT 토큰입니다."));
}

}
37 changes: 37 additions & 0 deletions src/main/java/com/issueDive/security/CustomUserDetailsService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.issueDive.security;

import com.issueDive.entity.User;
import com.issueDive.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;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
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 org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail()) // 이메일을 username으로 사용
.password(user.getPassword())
.authorities(new ArrayList<>()) // 현재는 권한 없이 진행
.build();
}

// 9월1일 변경 - 사용자 ID로 User 엔티티 조회 (JWT에서 사용)
public User findUserEntityByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email));
}
}
Loading
Loading