diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index c0b123f3..fb796643 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -22,6 +22,7 @@ jobs: AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} + SPRING_JWT_TOKEN_VALIDITY: ${{ secrets.SPRING_JWT_TOKEN_VALIDITY }} permissions: pull-requests: write diff --git a/build.gradle b/build.gradle index 0a1ccfe0..368ca8b0 100644 --- a/build.gradle +++ b/build.gradle @@ -27,8 +27,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' - //implementation 'org.springframework.boot:spring-boot-starter-security' - //implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + implementation 'org.springframework.boot:spring-boot-starter-security' +// implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' // https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-java implementation group: 'io.github.cdimascio', name: 'dotenv-java', version: '3.0.0' // https://mvnrepository.com/artifact/org.springdoc/springdoc-openapi-starter-webmvc-ui @@ -39,6 +39,13 @@ dependencies { implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1' implementation 'com.sun.xml.bind:jaxb-impl:2.3.3' + // JSON Web Token (JWT) Core API + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + + // Implementation and JSON support (runtime dependencies) + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/seveneleven/devlens/domain/member/controller/MemberController.java b/src/main/java/com/seveneleven/devlens/domain/member/controller/MemberController.java new file mode 100644 index 00000000..cc55b784 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/domain/member/controller/MemberController.java @@ -0,0 +1,99 @@ +package com.seveneleven.devlens.domain.member.controller; + +import com.seveneleven.devlens.domain.admin.dto.CompanyDto; +import com.seveneleven.devlens.domain.member.dto.MemberJoinDto; +import com.seveneleven.devlens.domain.member.dto.TokenDto; +import com.seveneleven.devlens.domain.member.entity.Member; +import com.seveneleven.devlens.domain.member.service.MemberService; +import com.seveneleven.devlens.global.config.Annotation.AdminAuthorize; +import com.seveneleven.devlens.global.config.Annotation.UserAuthorize; +import com.seveneleven.devlens.global.config.JwtFilter; +import com.seveneleven.devlens.global.config.TokenProvider; +import com.seveneleven.devlens.global.exception.BusinessException; +import com.seveneleven.devlens.global.response.APIResponse; +import com.seveneleven.devlens.global.response.ErrorCode; +import com.seveneleven.devlens.global.response.SuccessCode; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.annotation.*; + +/* +* security, jwt 테스트 확인을 위해 임시로 만든 controller +* +* */ +@RestController +@RequestMapping("/api") +@AllArgsConstructor +public class MemberController { + + private final TokenProvider tokenProvider; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final MemberService memberService; + + + @PostMapping("/login") + public ResponseEntity> login(MemberJoinDto dto) { + + Member member = memberService.getUserWithAuthorities(dto.getUserid()).get(); + + if (!ObjectUtils.isEmpty(member)) { + + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(dto.getUserid(), dto.getPw()); + + // authenticate 메소드가 실행이 될 때 CustomUserDetailsService class의 loadUserByUsername 메소드가 실행 + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + // authentication 객체를 createToken 메소드를 통해서 JWT Token을 생성 + String jwt = tokenProvider.createToken(authentication); + + HttpHeaders httpHeaders = new HttpHeaders(); + // response header에 jwt token에 넣어줌 + httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt); + + + // tokenDto를 이용해 response body에도 넣어서 리턴 + // return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK); + + + return ResponseEntity.status(SuccessCode.OK.getStatus()) + .headers(httpHeaders) // 헤더 추가 + .build(); // APIResponse 반환 + + } + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + + @PostMapping("/join") + public ResponseEntity> join(@RequestBody MemberJoinDto dto) { + try { + // memberService.join(dto.getUserid(), dto.getPw()); + return ResponseEntity.status(SuccessCode.OK.getStatus()) + .body(APIResponse.success(SuccessCode.OK)); + } catch (Exception e) { + throw new BusinessException(ErrorCode.DUPLICATE_USER_ID); + } + } + + + // 테스트용 입니다. (삭제 예정) + +// @GetMapping("/setting/admin") +// @AdminAuthorize +// public String adminSettingPage() { +// return "admin_setting"; +// } +// +// @GetMapping("/setting/user") +// @UserAuthorize +// public String userSettingPage() { +// return "user_setting"; +// } +} \ No newline at end of file diff --git a/src/main/java/com/seveneleven/devlens/domain/member/dto/MemberDto.java b/src/main/java/com/seveneleven/devlens/domain/member/dto/MemberDto.java new file mode 100644 index 00000000..606dd40c --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/domain/member/dto/MemberDto.java @@ -0,0 +1,31 @@ +package com.seveneleven.devlens.domain.member.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; + +/* + * security, jwt 테스트 확인을 위해 임시로 만든 dto + * + * */ +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MemberDto { + + @NotBlank + @Size(min = 3, max = 50) + private String username; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @NotBlank + @Size(min = 3, max = 100) + private String password; + + @NotBlank + @Size(min = 3, max = 50) + private String nickname; +} diff --git a/src/main/java/com/seveneleven/devlens/domain/member/dto/MemberJoinDto.java b/src/main/java/com/seveneleven/devlens/domain/member/dto/MemberJoinDto.java new file mode 100644 index 00000000..4cab765c --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/domain/member/dto/MemberJoinDto.java @@ -0,0 +1,16 @@ +package com.seveneleven.devlens.domain.member.dto; + +import lombok.Getter; +import lombok.Setter; + +/* + * security, jwt 테스트 확인을 위해 임시로 만든 dto + * + * */ +@Getter +@Setter +public class MemberJoinDto { + + private String userid; + private String pw; +} diff --git a/src/main/java/com/seveneleven/devlens/domain/member/dto/TokenDto.java b/src/main/java/com/seveneleven/devlens/domain/member/dto/TokenDto.java new file mode 100644 index 00000000..9d354588 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/domain/member/dto/TokenDto.java @@ -0,0 +1,15 @@ +package com.seveneleven.devlens.domain.member.dto; + +import lombok.*; + +/* + * jwt 토큰 생성 확인을 위해 임시로 만든 dto (삭제 예정) + * + * */ +@Getter +@Builder +@AllArgsConstructor +public class TokenDto { + + private final String token; +} diff --git a/src/main/java/com/seveneleven/devlens/domain/member/entity/Member.java b/src/main/java/com/seveneleven/devlens/domain/member/entity/Member.java index d514c2ac..0f2416fb 100644 --- a/src/main/java/com/seveneleven/devlens/domain/member/entity/Member.java +++ b/src/main/java/com/seveneleven/devlens/domain/member/entity/Member.java @@ -1,11 +1,13 @@ package com.seveneleven.devlens.domain.member.entity; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.seveneleven.devlens.domain.member.constant.MemberStatus; import com.seveneleven.devlens.domain.member.constant.Role; import com.seveneleven.devlens.domain.member.constant.YN; import com.seveneleven.devlens.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; import java.time.LocalDateTime; @@ -13,6 +15,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) @Table(name = "member") public class Member extends BaseEntity { @@ -21,7 +24,7 @@ public class Member extends BaseEntity { @Column(name = "id") private Long id; // 회원 ID - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "company_id", nullable = false, referencedColumnName = "id") private Company company; // 회사 ID (연관관계) @@ -40,7 +43,7 @@ public class Member extends BaseEntity { @Column(name = "profile_image_exists", nullable = false) @Enumerated(EnumType.STRING) - private YN profileImageExists; // 프로필 이미지 유무 + private YN profileImageExists = YN.N; // 프로필 이미지 유무 @Column(name = "name", nullable = false, length = 100) private String name; // 이름 @@ -62,15 +65,15 @@ public class Member extends BaseEntity { // 생성 메서드 - public static Member createMember(String loginId, String password, Company companyId, Role role, String name, String email, - LocalDate birthDate, String phoneNumber, Long departmentId, Long positionId) { + public static Member createMember(String loginId, String password, Company company, Role role, String name, String email, + LocalDate birthDate, String phoneNumber, Long departmentId, Long positionId, PasswordEncoder pwdEncoder) { Member member = new Member(); member.name = name; member.role = role; member.email = email; member.loginId = loginId; - member.password = password; - member.company = companyId; + member.password = pwdEncoder.encode(password); + member.company = company; member.birthDate = birthDate; member.phoneNumber = phoneNumber; member.positionId = positionId; @@ -79,9 +82,9 @@ public static Member createMember(String loginId, String password, Company compa } // 업데이트 메서드 - public void updateMember(String password, String phoneNumber, Company company, Long departmentId, Long positionId, YN profileImageExists) { + public void updateMember(String password, String phoneNumber, Company company, Long departmentId, Long positionId, YN profileImageExists, PasswordEncoder pwdEncoder) { this.company = company; - this.password = password; + this.password = pwdEncoder.encode(password); this.phoneNumber = phoneNumber; this.departmentId = departmentId; this.positionId = positionId; diff --git a/src/main/java/com/seveneleven/devlens/domain/member/repository/MemberRepository.java b/src/main/java/com/seveneleven/devlens/domain/member/repository/MemberRepository.java new file mode 100644 index 00000000..6397835f --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/domain/member/repository/MemberRepository.java @@ -0,0 +1,16 @@ +package com.seveneleven.devlens.domain.member.repository; + + +import com.seveneleven.devlens.domain.member.entity.Member; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + @EntityGraph(attributePaths = {"role"}) + Optional findOneWithAuthoritiesByLoginId(String loginId); +} \ No newline at end of file diff --git a/src/main/java/com/seveneleven/devlens/domain/member/service/MemberService.java b/src/main/java/com/seveneleven/devlens/domain/member/service/MemberService.java new file mode 100644 index 00000000..a7d9f13b --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/domain/member/service/MemberService.java @@ -0,0 +1,51 @@ +package com.seveneleven.devlens.domain.member.service; + +import com.seveneleven.devlens.domain.member.entity.Member; +import com.seveneleven.devlens.domain.member.repository.MemberRepository; +import com.seveneleven.devlens.global.util.security.SecurityUtil; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class MemberService { + @Autowired + PasswordEncoder passwordEncoder; + + @Autowired + MemberRepository memberRepository; + + + public Optional findOne(String loginId) { + return memberRepository.findByLoginId(loginId); + } + +// public Long join(String userid, String pw) { +// Optional companyOptional = companyRepository.findById(1L); +// +// Company company = companyOptional.orElseThrow(() -> +// new IllegalStateException("Company with ID 1L not found") +// ); +// +// Member member = Member.createMember(userid, pw, company, Role.USER, "박철수", "admin@admin.com", LocalDate.now(),"010-111-1111",1L,1L,passwordEncoder); +// repository.save(member); +// +// return member.getId(); +// } + + // 유저,권한 정보를 가져오는 메소드 + @Transactional + public Optional getUserWithAuthorities(String memberId) { + return memberRepository.findOneWithAuthoritiesByLoginId(memberId); + } + + // 현재 securityContext에 저장된 username의 정보만 가져오는 메소드 + @Transactional + public Optional getMyUserWithAuthorities() { + return SecurityUtil.getCurrentUsername() + .flatMap(memberRepository::findOneWithAuthoritiesByLoginId); + } +} diff --git a/src/main/java/com/seveneleven/devlens/domain/member/service/MyUserDetailsService.java b/src/main/java/com/seveneleven/devlens/domain/member/service/MyUserDetailsService.java new file mode 100644 index 00000000..58e43f4d --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/domain/member/service/MyUserDetailsService.java @@ -0,0 +1,29 @@ +package com.seveneleven.devlens.domain.member.service; + +import com.seveneleven.devlens.domain.member.entity.Member; +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.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +@Component +public class MyUserDetailsService implements UserDetailsService { + private final MemberService memberService; + + public MyUserDetailsService(MemberService memberService) { + this.memberService = memberService; + } + + @Override + public UserDetails loadUserByUsername(String insertedUserId){ + Member member = memberService.findOne(insertedUserId) + .orElseThrow(() -> new UsernameNotFoundException("User with ID '" + insertedUserId + "' not found.")); + + return User.builder() + .username(member.getLoginId()) + .password(member.getPassword()) + .roles(member.getRole().toString()) + .build(); + } +} diff --git a/src/main/java/com/seveneleven/devlens/global/config/Annotation/AdminAuthorize.java b/src/main/java/com/seveneleven/devlens/global/config/Annotation/AdminAuthorize.java new file mode 100644 index 00000000..2929227c --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/Annotation/AdminAuthorize.java @@ -0,0 +1,14 @@ +package com.seveneleven.devlens.global.config.Annotation; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("hasAnyRole('ADMIN')") +public @interface AdminAuthorize { +} \ No newline at end of file diff --git a/src/main/java/com/seveneleven/devlens/global/config/Annotation/UserAuthorize.java b/src/main/java/com/seveneleven/devlens/global/config/Annotation/UserAuthorize.java new file mode 100644 index 00000000..d8299875 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/Annotation/UserAuthorize.java @@ -0,0 +1,14 @@ +package com.seveneleven.devlens.global.config.Annotation; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("hasAnyRole('USER')") +public @interface UserAuthorize { +} \ No newline at end of file diff --git a/src/main/java/com/seveneleven/devlens/global/config/JwtAccessDeniedHandler.java b/src/main/java/com/seveneleven/devlens/global/config/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..56ea0cc3 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/JwtAccessDeniedHandler.java @@ -0,0 +1,35 @@ +package com.seveneleven.devlens.global.config; + +import com.seveneleven.devlens.global.response.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 인증된 사용자가 필요한 권한 없이 보호된 리소스에 접근하려고 할 때 처리하는 클래스 + * + * - Spring Security에서 권한 부족으로 인한 접근 거부 시 403 (Forbidden) 에러를 반환합니다. + */ +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + /** + * 권한이 없는 사용자가 요청을 보낼 때 호출됩니다. + * + * @param request 클라이언트 요청 (HttpServletRequest) + * @param response 서버 응답 (HttpServletResponse) + * @param accessDeniedException 발생한 접근 거부 예외 (AccessDeniedException) + * @throws IOException 입출력 예외 발생 시 + */ + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) + throws IOException { + // 403 Forbidden 상태 코드 반환 + response.sendError(ErrorCode.FORBIDDEN.getCode()); + } +} + diff --git a/src/main/java/com/seveneleven/devlens/global/config/JwtAuthenticationEntryPoint.java b/src/main/java/com/seveneleven/devlens/global/config/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..39b77de5 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/JwtAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.seveneleven.devlens.global.config; + +import com.seveneleven.devlens.global.response.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 인증되지 않은 사용자가 보호된 리소스에 접근하려고 할 때 처리하는 클래스 + * + * - Spring Security에서 인증 실패 시 401 (Unauthorized) 에러를 반환합니다. + */ +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + /** + * 인증되지 않은 사용자가 요청을 보낼 때 호출됩니다. + * + * @param request 클라이언트 요청 (HttpServletRequest) + * @param response 서버 응답 (HttpServletResponse) + * @param authException 인증 예외 (AuthenticationException) + * @throws IOException 입출력 예외 발생 시 + */ + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + // 401 Unauthorized 상태 코드 반환 + response.sendError(ErrorCode.UNAUTHORIZED.getCode()); + } +} + diff --git a/src/main/java/com/seveneleven/devlens/global/config/JwtFilter.java b/src/main/java/com/seveneleven/devlens/global/config/JwtFilter.java new file mode 100644 index 00000000..117efcef --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/JwtFilter.java @@ -0,0 +1,85 @@ +package com.seveneleven.devlens.global.config; + +import com.seveneleven.devlens.global.response.ErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +/** + * JWT 필터 클래스 + * + * - HTTP 요청에서 JWT 토큰을 추출하고, 해당 토큰의 유효성을 검증합니다. + * - 유효한 JWT 토큰이 있을 경우, 인증 정보를 Security Context에 저장합니다. + */ +@RequiredArgsConstructor +public class JwtFilter extends GenericFilterBean { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + private final TokenProvider tokenProvider; + + /** + * HTTP 요청에서 JWT 토큰을 추출하여 인증을 수행하고, Security Context에 저장합니다. + * + * @param servletRequest 클라이언트 요청 (ServletRequest) + * @param servletResponse 서버 응답 (ServletResponse) + * @param filterChain 필터 체인 (FilterChain) + * @throws IOException 입출력 예외 발생 시 + * @throws ServletException 서블릿 처리 예외 발생 시 + */ + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; + HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse; + String jwt = resolveToken(httpServletRequest); + String requestURI = httpServletRequest.getRequestURI(); + + try { + if (StringUtils.hasText(jwt)) { + + if (tokenProvider.validateToken(jwt)) { + Authentication authentication = tokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + httpServletResponse.sendError(ErrorCode.UNAUTHORIZED.getCode()); + return; + } + } else { + logger.info("JWT 토큰이 없습니다, URI: " + requestURI); + } + + filterChain.doFilter(servletRequest, servletResponse); + + } catch (Exception e) { + logger.info("필터 처리 중 예외 발생: " + e.getMessage()); + httpServletResponse.sendError(ErrorCode.JWT_FILTER_ERROR.getCode()); + } + } + + /** + * HTTP 요청 헤더에서 JWT 토큰을 추출합니다. + * + * @param request 클라이언트 요청 (HttpServletRequest) + * @return 추출된 JWT 토큰. 없거나 형식이 올바르지 않은 경우 null 반환. + */ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } +} + diff --git a/src/main/java/com/seveneleven/devlens/global/config/JwtSecurityConfig.java b/src/main/java/com/seveneleven/devlens/global/config/JwtSecurityConfig.java new file mode 100644 index 00000000..125a8547 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/JwtSecurityConfig.java @@ -0,0 +1,32 @@ +package com.seveneleven.devlens.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * JWT 기반의 Security 설정을 적용하기 위한 클래스 + * + * TokenProvider를 통해 생성된 JWT 토큰을 검증하는 필터를 Security Filter Chain에 추가합니다. + */ +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + + private final TokenProvider tokenProvider; + + /** + * JWT 필터를 Security Filter Chain에 추가합니다. + * + * @param http HttpSecurity 객체로 보안 필터를 구성합니다. + */ + @Override + public void configure(HttpSecurity http) { + JwtFilter jwtFilter = new JwtFilter(tokenProvider); + // JWT 필터를 UsernamePasswordAuthenticationFilter 전에 추가 + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + } + +} + diff --git a/src/main/java/com/seveneleven/devlens/global/config/SpringSecurityConfig.java b/src/main/java/com/seveneleven/devlens/global/config/SpringSecurityConfig.java new file mode 100644 index 00000000..1f811605 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/SpringSecurityConfig.java @@ -0,0 +1,114 @@ +package com.seveneleven.devlens.global.config; + +import jakarta.servlet.DispatcherType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +/** + * Spring Security 설정을 정의하는 클래스 + * + * - JWT 기반 인증을 사용하도록 구성합니다. + * - CORS, CSRF, 인증 및 권한 관리, 세션 정책 등을 설정합니다. + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SpringSecurityConfig { + + @Autowired + TokenProvider tokenProvider; + @Autowired + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + @Autowired + JwtAccessDeniedHandler jwtAccessDeniedHandler; + + /** + * Spring Security 필터 체인을 구성합니다. + * + * @param http HttpSecurity 객체로 보안 설정을 정의합니다. + * @return 구성된 SecurityFilterChain 객체 + * @throws Exception 보안 설정 중 예외가 발생할 경우 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 + .csrf(csrf -> csrf.disable()) // CSRF 비활성화 + .exceptionHandling(exceptionHandling -> // 인증 및 접근 거부 처리 + exceptionHandling + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + .headers(headers -> // H2-console 허용 + headers.frameOptions(frameOptions -> frameOptions.sameOrigin()) + ) + .sessionManagement(sessionManagement -> // 세션 정책 설정 (STATELESS) + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(request -> request // 모든 요청 허용 + .anyRequest().permitAll() + ); +// .authorizeHttpRequests(request -> request // URL별 접근 권한 설정 +// .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll() +// .requestMatchers( +// "/status", +// "/images/**", +// "/api/login", +// "/swagger-ui/**", +// "/v3/api-docs/**", +// "/swagger-resources/**", +// "/webjars/**" +// ).permitAll() +// .anyRequest().authenticated() +// ); + + // JWT 필터를 추가 + JwtSecurityConfig jwtSecurityConfig = new JwtSecurityConfig(tokenProvider); + jwtSecurityConfig.configure(http); + + return http.build(); + } + + /** + * 비밀번호 암호화를 위한 PasswordEncoder를 Bean으로 등록합니다. + * + * @return BCryptPasswordEncoder 인스턴스 + */ + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * CORS 설정을 정의합니다. + * + * - 모든 Origin, 메서드, 헤더를 허용합니다. + * - 자격 증명 허용 설정을 활성화합니다. + * + * @return CORS 설정을 포함하는 CorsConfigurationSource 객체 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOriginPattern("*"); // 모든 Origin 허용 + configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 + configuration.addAllowedHeader("*"); // 모든 헤더 허용 + configuration.setAllowCredentials(true); // 자격 증명 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 경로에 적용 + return source; + } + +} + diff --git a/src/main/java/com/seveneleven/devlens/global/config/TokenProvider.java b/src/main/java/com/seveneleven/devlens/global/config/TokenProvider.java new file mode 100644 index 00000000..4da99683 --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/config/TokenProvider.java @@ -0,0 +1,124 @@ +package com.seveneleven.devlens.global.config; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +/** + * JWT 토큰의 생성, 인증, 유효성 검증 등을 담당하는 클래스 + */ +@Component +public class TokenProvider implements InitializingBean { + + private final Logger logger = LoggerFactory.getLogger(TokenProvider.class); + private static final String AUTHORITIES_KEY = "auth"; + private final String secret; + private final long tokenValidityInMilliseconds; + private Key key; + + /** + * 생성자: JWT 토큰의 비밀키와 유효 시간을 초기화합니다. + * + * @param secret Base64로 인코딩된 JWT 비밀키 + * @param tokenValidityInSeconds JWT 토큰의 유효 기간 (초 단위) + */ + public TokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.token-validity}") long tokenValidityInSeconds) { + this.secret = secret; + this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000; + } + + /** + * Bean이 초기화된 후 Base64로 인코딩된 secret 값을 디코딩하여 Key 객체를 생성합니다. + */ + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 인증 정보를 기반으로 JWT 토큰을 생성합니다. + * + * @param authentication Spring Security의 Authentication 객체 + * @return 생성된 JWT 토큰 + */ + public String createToken(Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + Date validity = new Date(now + this.tokenValidityInMilliseconds); + + return Jwts.builder() + .setSubject(authentication.getName()) + .claim(AUTHORITIES_KEY, authorities) + .signWith(key, SignatureAlgorithm.HS512) + .setExpiration(validity) + .compact(); + } + + /** + * JWT 토큰을 파싱하여 Authentication 객체를 생성합니다. + * + * @param token JWT 토큰 + * @return Authentication 객체 + */ + public Authentication getAuthentication(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + User principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } + + /** + * JWT 토큰의 유효성을 검증합니다. + * + * @param token 검증할 JWT 토큰 + * @return 토큰이 유효하면 true, 그렇지 않으면 false + */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + logger.info("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + logger.info("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + logger.info("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + logger.info("JWT 토큰이 잘못되었습니다."); + } + return false; + } +} + diff --git a/src/main/java/com/seveneleven/devlens/global/exception/GlobalExceptionHandler.java b/src/main/java/com/seveneleven/devlens/global/exception/GlobalExceptionHandler.java index 93f7cdea..8482cee8 100644 --- a/src/main/java/com/seveneleven/devlens/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/seveneleven/devlens/global/exception/GlobalExceptionHandler.java @@ -2,7 +2,6 @@ import com.seveneleven.devlens.global.response.APIResponse; import com.seveneleven.devlens.global.response.ErrorCode; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; diff --git a/src/main/java/com/seveneleven/devlens/global/response/ErrorCode.java b/src/main/java/com/seveneleven/devlens/global/response/ErrorCode.java index 4f6f7c0f..579be95c 100644 --- a/src/main/java/com/seveneleven/devlens/global/response/ErrorCode.java +++ b/src/main/java/com/seveneleven/devlens/global/response/ErrorCode.java @@ -10,9 +10,17 @@ public enum ErrorCode { // 1000번대 코드 : 회원 관련 UNAUTHORIZED(1000, HttpStatus.UNAUTHORIZED, "사용자 인증이 필요합니다."), + FORBIDDEN(1001, HttpStatus.FORBIDDEN, "사용 권한이 없습니다."), + NOT_FOUND_TOKEN(1002, HttpStatus.INTERNAL_SERVER_ERROR, "유효하지 않은 JWT 토큰입니다."), + EXPIRED_TOKEN(1003, HttpStatus.INTERNAL_SERVER_ERROR, "만료된 JWT 토큰입니다."), + JWT_FILTER_ERROR(1004, HttpStatus.INTERNAL_SERVER_ERROR, "필터 처리 중 예외가 발생했습니다."), + DUPLICATE_USER_ID(1005, HttpStatus.BAD_REQUEST,"이미 존재하는 ID 입니다."), + USER_NOT_FOUND(1006, HttpStatus.BAD_REQUEST,"존재하지 않는 사용자입니다."), + COMPANY_DUPLICATED_NUMBER(1051, HttpStatus.BAD_REQUEST,"이미 등록된 회사입니다."), COMPANY_IS_DEACTIVATED(1052, HttpStatus.BAD_REQUEST,"비활성화된 회사 정보입니다."), COMPANY_IS_NOT_FOUND(1053,HttpStatus.BAD_REQUEST,"회사 정보를 찾을 수 없습니다."), + // 2000번대 코드 : 프로젝트 관련 diff --git a/src/main/java/com/seveneleven/devlens/global/util/security/SecurityUtil.java b/src/main/java/com/seveneleven/devlens/global/util/security/SecurityUtil.java new file mode 100644 index 00000000..2f3ccfce --- /dev/null +++ b/src/main/java/com/seveneleven/devlens/global/util/security/SecurityUtil.java @@ -0,0 +1,44 @@ +package com.seveneleven.devlens.global.util.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import java.util.Optional; + +/** + * Security Context에서 인증 정보를 관리하고, 현재 사용자 정보를 제공하는 유틸리티 클래스 + */ +public class SecurityUtil { + + private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class); + + /** + * Security Context에서 현재 사용자 이름(username)을 가져옵니다. + * + * @return Optional에 감싼 사용자 이름(username). + * 인증 정보가 없거나 username을 확인할 수 없는 경우 Optional.empty()를 반환합니다. + */ + public static Optional getCurrentUsername() { + // Security Context에서 Authentication 객체를 가져옴 + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 인증 정보가 없는 경우 로그 출력 후 빈 Optional 반환 + if (authentication == null) { + logger.debug("Security Context에 인증 정보가 없습니다."); + return Optional.empty(); + } + + String username = null; + + // Authentication 객체에서 username 추출 + if (authentication.getPrincipal() instanceof UserDetails springSecurityUser) { + username = springSecurityUser.getUsername(); + } else if (authentication.getPrincipal() instanceof String) { + username = (String) authentication.getPrincipal(); + } + + return Optional.ofNullable(username); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 095b04cd..cb9921a1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -26,3 +26,10 @@ cloud.aws.stack.auto=${AWS_STACK_AUTO} cloud.aws.region.static=${AWS_REGION} cloud.aws.credentials.accessKey=${AWS_ACCESS_KEY} cloud.aws.credentials.secretKey=${AWS_SECRET_KEY} + +# JWT Setting +jwt.secret=${SPRING_JWT_SECRET} +jwt.token-validity=${SPRING_JWT_TOKEN_VALIDITY} + +# Security on/off +spring.security.enabled=false \ No newline at end of file