diff --git a/build.gradle b/build.gradle index 1b4d44b..98c52bc 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,12 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1' + + // 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/umc10th/domain/member/controller/MemberController.java b/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index d1ad441..f2568b6 100644 --- a/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -5,10 +5,12 @@ import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; import com.example.umc10th.domain.member.service.MemberService; import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.security.AuthMember; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -47,8 +49,19 @@ public ApiResponse getHome( // 3. 마이페이지 조회 @GetMapping("/members/me") - public ApiResponse getMyPage(@RequestParam Long memberId) { - MemberResDTO.MyPage result = memberService.getMyPage(memberId); + public ApiResponse getMyPage( + @AuthenticationPrincipal AuthMember authMember + ) { + MemberResDTO.MyPage result = memberService.getMyPage(authMember.getMember()); return ApiResponse.onSuccess(MemberSuccessCode.GET_MY_PAGE, result); } + + // 4. 로그인 + @PostMapping("/auth/login") + public ApiResponse login( + @Valid @RequestBody MemberReqDTO.Login request + ) { + MemberResDTO.Login result = memberService.login(request); + return ApiResponse.onSuccess(MemberSuccessCode.LOGIN, result); + } } diff --git a/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index e01cfa2..422a259 100644 --- a/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -100,4 +100,10 @@ public static MemberResDTO.Home toHome( .pageInfo(PageInfoDTO.from(missionPage)) // 페이지 정보 변환 .build(); } + + public static MemberResDTO.Login toLogin(String accessToken) { + return MemberResDTO.Login.builder() + .accessToken(accessToken) + .build(); + } } diff --git a/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index a29710b..3c3aaff 100644 --- a/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -60,5 +60,16 @@ public record SignUp( @NotEmpty(message = "필수 약관 동의가 필요합니다.") List agreedTerms // ["AGE", "SERVICE", "PRIVACY"] + ) { + } + + @Builder + public record Login( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password ) {} } diff --git a/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java index 4f98d21..8f5ab2f 100644 --- a/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java +++ b/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java @@ -64,4 +64,9 @@ public record MyPage( Integer point, String profileUrl ) {} + + @Builder + public record Login( + String accessToken + ) {} } diff --git a/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 32075dd..0c21c9f 100644 --- a/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -52,7 +52,7 @@ public class Member extends BaseEntity { private String socialUid; @Enumerated(EnumType.STRING) - @Column(name = "social_type", nullable = false) + @Column(name = "social_type", nullable = false, length = 20) @Builder.Default private SocialType socialType = SocialType.LOCAL; diff --git a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index 367b3e1..3192b59 100644 --- a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -13,7 +13,9 @@ public enum MemberErrorCode implements BaseErrorCode { // 회원가입 관련 에러 FOOD_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_1", "유효하지 않은 선호 음식입니다."), - TERM_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_2", "유효하지 않은 약관입니다."); + TERM_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_2", "유효하지 않은 약관입니다."), + + PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java index 4d8682a..a2fe57a 100644 --- a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java +++ b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java @@ -11,7 +11,8 @@ public enum MemberSuccessCode implements BaseSuccessCode { SIGN_UP(HttpStatus.OK, "MEMBER200_1", "회원가입에 성공했습니다."), GET_HOME(HttpStatus.OK, "MEMBER200_2", "홈 화면 조회에 성공했습니다."), - GET_MY_PAGE(HttpStatus.OK, "MEMBER200_3", "성공적으로 유저를 조회했습니다."); + GET_MY_PAGE(HttpStatus.OK, "MEMBER200_3", "성공적으로 유저를 조회했습니다."), + LOGIN(HttpStatus.OK, "MEMBER200_2", "로그인에 성공했습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 7a5ad3f..49a448a 100644 --- a/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -15,6 +15,8 @@ import com.example.umc10th.domain.member.repository.TermRepository; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.repository.MemberMissionRepository; +import com.example.umc10th.global.security.AuthMember; +import com.example.umc10th.global.security.util.JwtUtil; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; @@ -37,6 +39,7 @@ public class MemberService { private final FoodRepository foodRepository; private final TermRepository termRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; @PersistenceContext private EntityManager em; // MemberFood/MemberTerm을 직접 persist하기 위해 @@ -101,9 +104,7 @@ public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp request) { } // 마이페이지 - public MemberResDTO.MyPage getMyPage(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + public MemberResDTO.MyPage getMyPage(Member member) { return MemberConverter.toMyPage(member); } @@ -125,4 +126,22 @@ public MemberResDTO.Home getHome(Long memberId, Integer page, Integer size) { // 4. 컨버터로 변환 return MemberConverter.toHome(member, received, completed, inProgress, missionPage); } + + // 로그인 + @Transactional(readOnly = true) + public MemberResDTO.Login login(MemberReqDTO.Login request) { + // 1. 이메일로 회원 조회 + Member member = memberRepository.findByEmail(request.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 2. 비밀번호 검증 (입력 평문 vs DB 암호문) + if (!passwordEncoder.matches(request.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.PASSWORD_NOT_MATCH); + } + + // 3. JWT 발급 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member)); + + return MemberConverter.toLogin(accessToken); + } } diff --git a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 130840a..4586a01 100644 --- a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -2,21 +2,29 @@ import com.example.umc10th.global.security.CustomAccessDenied; import com.example.umc10th.global.security.CustomEntryPoint; +import com.example.umc10th.global.security.CustomUserDetailsService; +import com.example.umc10th.global.security.filter.JwtAuthFilter; +import com.example.umc10th.global.security.util.JwtUtil; 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; @EnableWebSecurity @Configuration @RequiredArgsConstructor public class SecurityConfig { + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final CustomEntryPoint customEntryPoint; private final CustomAccessDenied customAccessDenied; @@ -26,6 +34,7 @@ public class SecurityConfig { "/swagger-resources/**", "/v3/api-docs/**", "/auth/**", + "/api/v1/auth/**", // 회원가입 + 로그인 = 인증 없이 허용 // 폼 로그인 페이지 자체 "/login", @@ -36,6 +45,10 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + // 세션 안 씀 (토큰 기반) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) .authorizeHttpRequests(requests -> requests .requestMatchers(allowUris).permitAll() .anyRequest().authenticated() @@ -44,10 +57,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authenticationEntryPoint(customEntryPoint) .accessDeniedHandler(customAccessDenied) ) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() - ) + // 폼 로그인 / 기본 로그인 제거 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + // 우리 JWT 필터를 인증 필터 앞에 삽입 + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") @@ -57,6 +71,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 0000000..62974bf --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,74 @@ +package com.example.umc10th.global.security.filter; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.security.CustomUserDetailsService; +import com.example.umc10th.global.security.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +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; + +import java.io.IOException; + +@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 { + + try { + // 1. 헤더에서 토큰 가져오기 + String token = request.getHeader("Authorization"); + + // 2. 토큰이 없거나 Bearer 형식이 아니면 그냥 통과 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // 3. "Bearer " 떼기 + token = token.replace("Bearer ", ""); + + // 4. 유효한 토큰이면 인증 객체 생성 + if (jwtUtil.isValid(token)) { + String email = jwtUtil.getEmail(token); + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + // 5. 다음 필터로 + filterChain.doFilter(request, response); + + } catch (Exception e) { + // 토큰 파싱 실패 / 만료 / 서명 불일치 등 → 401 응답통일 + ObjectMapper mapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 0000000..9f5ea9e --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,84 @@ +package com.example.umc10th.global.security.util; + +import com.example.umc10th.global.security.AuthMember; +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.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(AuthMember member) { + return createToken(member, accessExpiration); + } + + // 토큰에서 이메일(Subject) 추출 + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); + } catch (JwtException e) { + return null; + } + } + + // 토큰 유효성 검증 (서명/만료 등) + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 실제 토큰 생성 로직 + private String createToken(AuthMember member, Duration expiration) { + Instant now = Instant.now(); + + String authorities = member.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(member.getUsername()) // 이메일을 Subject로 + .claim("role", authorities) + .claim("email", member.getUsername()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(expiration))) + .signWith(secretKey) + .compact(); + } + + // 토큰 파싱 + 서명 검증 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) // 서버 시계 오차 60초 허용 + .build() + .parseSignedClaims(token); + } +} \ No newline at end of file