diff --git a/BEConfig b/BEConfig index 785c081..ec0b316 160000 --- a/BEConfig +++ b/BEConfig @@ -1 +1 @@ -Subproject commit 785c0814ce55a5e8458a53c994a5846985967540 +Subproject commit ec0b316cc9149853fe705ad372c363512ceac203 diff --git a/build.gradle b/build.gradle index e1782be..38700c2 100644 --- a/build.gradle +++ b/build.gradle @@ -54,11 +54,21 @@ dependencies { testImplementation 'org.mockito:mockito-inline:5.2.0' // QueryDSL : OpenFeign - implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0" - implementation "io.github.openfeign.querydsl:querydsl-core:7.0" - annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa" - annotationProcessor "jakarta.persistence:jakarta.persistence-api" - annotationProcessor "jakarta.annotation:jakarta.annotation-api" + implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0" + implementation "io.github.openfeign.querydsl:querydsl-core:7.0" + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + // Security + 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' } tasks.named('test') { diff --git a/src/main/java/com/example/umc/domain/member/controller/MemberController.java b/src/main/java/com/example/umc/domain/member/controller/MemberController.java index 1634ccb..5eaa9fe 100644 --- a/src/main/java/com/example/umc/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/umc/domain/member/controller/MemberController.java @@ -23,9 +23,17 @@ public class MemberController { @PostMapping("/signup") @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") public ApiResponse signUp( - @RequestBody @Valid MemberReqDTO.JoinDTO dto - ) { + @RequestBody @Valid MemberReqDTO.JoinDTO dto) { MemberResDTO.JoinDTO response = memberCommandService.signup(dto); return ApiResponse.onSuccess(MemberSuccessCode.MEMBER_CREATED, response); } + + // 로그인 + @PostMapping("/login") + @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다.") + public ApiResponse login( + @RequestBody @Valid MemberReqDTO.LoginDTO dto) { + MemberResDTO.LoginDTO response = memberCommandService.login(dto); + return ApiResponse.onSuccess(MemberSuccessCode.MEMBER_LOGIN_SUCCESS, response); + } } diff --git a/src/main/java/com/example/umc/domain/member/converter/MemberConverter.java b/src/main/java/com/example/umc/domain/member/converter/MemberConverter.java index 35ca04c..b4c318b 100644 --- a/src/main/java/com/example/umc/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/example/umc/domain/member/converter/MemberConverter.java @@ -3,6 +3,7 @@ import com.example.umc.domain.member.dto.MemberReqDTO; import com.example.umc.domain.member.dto.MemberResDTO; import com.example.umc.domain.user.entity.User; +import com.example.umc.global.auth.Role; public class MemberConverter { @@ -14,12 +15,41 @@ public static MemberResDTO.JoinDTO toJoinDTO(User member) { .build(); } + // Entity -> LoginDTO + /* + public static MemberResDTO.LoginDTO toLoginDTO(User member) { + return MemberResDTO.LoginDTO.builder() + .memberId(member.getUserId()) + .email(member.getEmail()) + .name(member.getName()) + .role(member.getRole()) + .createdAt(member.getCreatedAt()) + .build(); + } + */ + + // Entity + AccessToken -> LoginDTO + public static MemberResDTO.LoginDTO toLoginDTO(User member, String accessToken) { + return MemberResDTO.LoginDTO.builder() + .memberId(member.getUserId()) + .email(member.getEmail()) + .name(member.getName()) + .role(member.getRole()) + .accessToken(accessToken) + .createdAt(member.getCreatedAt()) + .build(); + } + // DTO -> Entity - public static User toMember(MemberReqDTO.JoinDTO dto) { + public static User toMember(MemberReqDTO.JoinDTO dto, String salt, Role role) { return User.builder() .name(dto.name()) .birth(dto.birth()) .address(dto.address() != null ? dto.address().toString() : null) + .password(salt) + .role(dto.role()) + .email(dto.email()) + .address(dto.address()) .gender(dto.gender()) .build(); } diff --git a/src/main/java/com/example/umc/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/umc/domain/member/dto/MemberReqDTO.java index 3c2ee60..9a2100c 100644 --- a/src/main/java/com/example/umc/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/umc/domain/member/dto/MemberReqDTO.java @@ -1,6 +1,7 @@ package com.example.umc.domain.member.dto; import com.example.umc.domain.user.enums.Gender; +import com.example.umc.global.auth.Role; import java.time.LocalDate; import java.util.List; @@ -10,8 +11,16 @@ public record JoinDTO( String name, Gender gender, LocalDate birth, + String password, + String email, + Role role, String address, String specAddress, - List preferCategory - ) {} + List preferCategory) { + } + + public record LoginDTO( + String email, + String password) { + } } diff --git a/src/main/java/com/example/umc/domain/member/dto/MemberResDTO.java b/src/main/java/com/example/umc/domain/member/dto/MemberResDTO.java index cf3abee..5bb96cb 100644 --- a/src/main/java/com/example/umc/domain/member/dto/MemberResDTO.java +++ b/src/main/java/com/example/umc/domain/member/dto/MemberResDTO.java @@ -1,5 +1,6 @@ package com.example.umc.domain.member.dto; +import com.example.umc.global.auth.Role; import lombok.Builder; import java.time.LocalDateTime; @@ -8,6 +9,16 @@ public class MemberResDTO { @Builder public record JoinDTO( Long memberId, - LocalDateTime createdAt - ) {} + LocalDateTime createdAt) { + } + + @Builder + public record LoginDTO( + Long memberId, + String email, + String name, + Role role, + String accessToken, + LocalDateTime createdAt) { + } } diff --git a/src/main/java/com/example/umc/domain/member/exception/code/MemberErrorCode.java b/src/main/java/com/example/umc/domain/member/exception/code/MemberErrorCode.java index 2639071..c7da22d 100644 --- a/src/main/java/com/example/umc/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/com/example/umc/domain/member/exception/code/MemberErrorCode.java @@ -11,6 +11,7 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾지 못했습니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "비밀번호가 일치하지 않습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/example/umc/domain/member/exception/code/MemberSuccessCode.java b/src/main/java/com/example/umc/domain/member/exception/code/MemberSuccessCode.java index 41ed101..e4631f2 100644 --- a/src/main/java/com/example/umc/domain/member/exception/code/MemberSuccessCode.java +++ b/src/main/java/com/example/umc/domain/member/exception/code/MemberSuccessCode.java @@ -11,6 +11,7 @@ public enum MemberSuccessCode implements BaseCode { MEMBER_CREATED(HttpStatus.CREATED, "MEMBER201_1", "성공적으로 사용자가 생성되었습니다."), + MEMBER_LOGIN_SUCCESS(HttpStatus.OK, "MEMBER200_1", "로그인에 성공했습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/example/umc/domain/member/repository/MemberRepository.java b/src/main/java/com/example/umc/domain/member/repository/MemberRepository.java index cab6e98..ed4fe66 100644 --- a/src/main/java/com/example/umc/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/umc/domain/member/repository/MemberRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); } diff --git a/src/main/java/com/example/umc/domain/member/service/MemberCommandService.java b/src/main/java/com/example/umc/domain/member/service/MemberCommandService.java index b720c07..5ae09e1 100644 --- a/src/main/java/com/example/umc/domain/member/service/MemberCommandService.java +++ b/src/main/java/com/example/umc/domain/member/service/MemberCommandService.java @@ -6,4 +6,7 @@ public interface MemberCommandService { // 회원가입 MemberResDTO.JoinDTO signup(MemberReqDTO.JoinDTO dto); + + // 로그인 + MemberResDTO.LoginDTO login(MemberReqDTO.LoginDTO dto); } diff --git a/src/main/java/com/example/umc/domain/member/service/MemberCommandServiceImpl.java b/src/main/java/com/example/umc/domain/member/service/MemberCommandServiceImpl.java index 2410fa5..88637da 100644 --- a/src/main/java/com/example/umc/domain/member/service/MemberCommandServiceImpl.java +++ b/src/main/java/com/example/umc/domain/member/service/MemberCommandServiceImpl.java @@ -3,6 +3,8 @@ import com.example.umc.domain.member.converter.MemberConverter; import com.example.umc.domain.member.dto.MemberReqDTO; import com.example.umc.domain.member.dto.MemberResDTO; +import com.example.umc.domain.member.exception.MemberException; +import com.example.umc.domain.member.exception.code.MemberErrorCode; import com.example.umc.domain.member.repository.MemberRepository; import com.example.umc.domain.user.entity.User; import com.example.umc.domain.user.entity.UserPrefer; @@ -11,7 +13,12 @@ import com.example.umc.domain.category.repository.PreferCategoryRepository; import com.example.umc.domain.category.exception.CategoryException; import com.example.umc.domain.category.exception.code.CategoryErrorCode; +import com.example.umc.global.auth.CustomUserDetails; +import com.example.umc.global.auth.JwtUtil; +import com.example.umc.global.auth.Role; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,12 +32,17 @@ public class MemberCommandServiceImpl implements MemberCommandService { private final MemberRepository memberRepository; private final UserPreferRepository userPreferRepository; private final PreferCategoryRepository preferCategoryRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; @Override @Transactional public MemberResDTO.JoinDTO signup(MemberReqDTO.JoinDTO dto) { + + // 비밀번호 암호화 + String salt = passwordEncoder.encode(dto.password()); // 사용자 생성 - User member = MemberConverter.toMember(dto); + User member = MemberConverter.toMember(dto, salt, Role.ROLE_USER); // DB 적용 memberRepository.save(member); @@ -58,4 +70,26 @@ public MemberResDTO.JoinDTO signup(MemberReqDTO.JoinDTO dto) { // 응답 DTO 생성 return MemberConverter.toJoinDTO(member); } + + @Override + @Transactional(readOnly = true) + public MemberResDTO.LoginDTO login(@Valid MemberReqDTO.LoginDTO dto) { + // User 조회 + User user = memberRepository.findByEmail(dto.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 비밀번호 검증 + if (!passwordEncoder.matches(dto.password(), user.getPassword())) { + throw new MemberException(MemberErrorCode.INVALID_PASSWORD); + } + + // JWT 토큰 발급용 UserDetails + CustomUserDetails userDetails = new CustomUserDetails(user); + + // 엑세스 토큰 발급 + String accessToken = jwtUtil.createAccessToken(userDetails); + + // DTO 조립 + return MemberConverter.toLoginDTO(user, accessToken); + } } diff --git a/src/main/java/com/example/umc/domain/user/entity/User.java b/src/main/java/com/example/umc/domain/user/entity/User.java index e7eda8c..1ae7849 100644 --- a/src/main/java/com/example/umc/domain/user/entity/User.java +++ b/src/main/java/com/example/umc/domain/user/entity/User.java @@ -9,6 +9,7 @@ import com.example.umc.domain.review.entity.Review; import com.example.umc.domain.notification.entity.Notification; import com.example.umc.global.common.BaseEntity; +import com.example.umc.global.auth.Role; import jakarta.persistence.*; import lombok.*; import lombok.experimental.SuperBuilder; @@ -67,6 +68,12 @@ public class User extends BaseEntity { @Column(name = "email", length = 255) private String email; + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private Role role; + @Column(name = "phone", length = 100) private String phone; diff --git a/src/main/java/com/example/umc/global/auth/AuthenticationEntryPointImpl.java b/src/main/java/com/example/umc/global/auth/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..ad4720a --- /dev/null +++ b/src/main/java/com/example/umc/global/auth/AuthenticationEntryPointImpl.java @@ -0,0 +1,31 @@ +package com.example.umc.global.auth; + +import com.example.umc.global.apiPayload.ApiResponse; +import com.example.umc.global.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + ApiResponse errorResponse = ApiResponse.onFailure( + ErrorStatus._UNAUTHORIZED.getReasonHttpStatus().getCode(), + ErrorStatus._UNAUTHORIZED.getReasonHttpStatus().getMessage(), + null); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/src/main/java/com/example/umc/global/auth/CustomUserDetails.java b/src/main/java/com/example/umc/global/auth/CustomUserDetails.java new file mode 100644 index 0000000..480eab2 --- /dev/null +++ b/src/main/java/com/example/umc/global/auth/CustomUserDetails.java @@ -0,0 +1,54 @@ +package com.example.umc.global.auth; + +import com.example.umc.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(() -> user.getRole().toString()); + } + + @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 user.getUserStatus().name().equals("ACTIVE"); + } + + public User getUser() { + return user; + } +} diff --git a/src/main/java/com/example/umc/global/auth/CustomUserDetailsService.java b/src/main/java/com/example/umc/global/auth/CustomUserDetailsService.java new file mode 100644 index 0000000..aec6a28 --- /dev/null +++ b/src/main/java/com/example/umc/global/auth/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package com.example.umc.global.auth; + +import com.example.umc.domain.member.repository.MemberRepository; +import com.example.umc.domain.user.entity.User; +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 MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // 검증할 Member 조회 + User user = memberRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("해당 사용자를 찾지 못했습니다.")); + + // CustomUserDetails 반환 + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/example/umc/global/auth/JwtAuthFilter.java b/src/main/java/com/example/umc/global/auth/JwtAuthFilter.java new file mode 100644 index 0000000..87bfdd2 --- /dev/null +++ b/src/main/java/com/example/umc/global/auth/JwtAuthFilter.java @@ -0,0 +1,55 @@ +package com.example.umc.global.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // 토큰이 있는데 유효하지 않으면 401 에러 발생 + if (!jwtUtil.isValid(token)) { + throw new BadCredentialsException("유효하지 않은 토큰입니다."); + } + // AccessToken 검증하기: 올바른 토큰이면 + // 토큰에서 이메일 추출 + String email = jwtUtil.getEmail(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities()); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/example/umc/global/auth/JwtUtil.java b/src/main/java/com/example/umc/global/auth/JwtUtil.java new file mode 100644 index 0000000..aed3627 --- /dev/null +++ b/src/main/java/com/example/umc/global/auth/JwtUtil.java @@ -0,0 +1,93 @@ +package com.example.umc.global.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.secret}") String secret, + @Value("${jwt.expiration}") Long accessExpiration) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(CustomUserDetails user) { + return createToken(user, accessExpiration); + } + + /** + * 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 + } catch (JwtException e) { + return null; + } + } + + /** + * 토큰 유효성 확인 + * + * @param token 유효한지 확인할 토큰 + * @return True, False 반환합니다 + */ + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(CustomUserDetails user, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(user.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", user.getUsername()) + .issuedAt(Date.from(now)) // 언제 발급한지 + .expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지 + .signWith(secretKey) // sign할 Key + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} diff --git a/src/main/java/com/example/umc/global/auth/Role.java b/src/main/java/com/example/umc/global/auth/Role.java new file mode 100644 index 0000000..527013b --- /dev/null +++ b/src/main/java/com/example/umc/global/auth/Role.java @@ -0,0 +1,5 @@ +package com.example.umc.global.auth; + +public enum Role { + ROLE_ADMIN, ROLE_USER +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/global/config/SecurityConfig.java b/src/main/java/com/example/umc/global/config/SecurityConfig.java index bd138b5..b34bffd 100644 --- a/src/main/java/com/example/umc/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc/global/config/SecurityConfig.java @@ -1,36 +1,84 @@ package com.example.umc.global.config; +import com.example.umc.global.auth.AuthenticationEntryPointImpl; +import com.example.umc.global.auth.CustomUserDetailsService; +import com.example.umc.global.auth.JwtAuthFilter; +import com.example.umc.global.auth.JwtUtil; +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.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 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.crypto.password.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final CustomUserDetailsService customUserDetailsService; + private final JwtUtil jwtUtil; + + private String[] allowUris = { + "/api/v1/reviews/**", + "/api/v1/stores/**", + "/api/v1/missions/**", + "/api/v1/members/**", + "/temp/**", + "/swagger-ui/**", + "/v3/api-docs/**", + }; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(authz -> authz - // API 경로 허용 - .requestMatchers("/api/v1/reviews/**").permitAll() - .requestMatchers("/api/v1/stores/**").permitAll() - .requestMatchers("/api/v1/missions/**").permitAll() - .requestMatchers("/api/v1/members/**").permitAll() - .requestMatchers("/temp/**").permitAll() - .requestMatchers("/swagger-ui/**").permitAll() - .requestMatchers("/v3/api-docs/**").permitAll() - .requestMatchers("/swagger-ui.html").permitAll() - .requestMatchers("/actuator/**").permitAll() - // 나머지는 인증 필요 + .requestMatchers(allowUris).permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .httpBasic(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable); + .formLogin(AbstractHttpConfigurer::disable) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll()) + .addFilterBefore( + jwtAuthFilter(), + UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(authenticationEntryPoint())); return http.build(); } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new AuthenticationEntryPointImpl(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(passwordEncoder()); + authProvider.setUserDetailsService(customUserDetailsService); + return new ProviderManager(authProvider); + } }