diff --git a/build.gradle b/build.gradle index 851c018..1aa18fc 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,12 @@ dependencies { // 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/docs/week9_login.png b/docs/week9_login.png new file mode 100644 index 0000000..909ea19 Binary files /dev/null and b/docs/week9_login.png differ diff --git a/docs/week9_myPage.png b/docs/week9_myPage.png new file mode 100644 index 0000000..9c48d90 Binary files /dev/null and b/docs/week9_myPage.png differ diff --git a/docs/week9_singUp.png b/docs/week9_singUp.png new file mode 100644 index 0000000..17ff6a1 Binary files /dev/null and b/docs/week9_singUp.png differ diff --git a/src/main/java/com/example/umc10th/Umc10thApplication.java b/src/main/java/com/example/umc10th/Umc10thApplication.java index 35d830f..330e630 100644 --- a/src/main/java/com/example/umc10th/Umc10thApplication.java +++ b/src/main/java/com/example/umc10th/Umc10thApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class Umc10thApplication { public static void main(String[] args) { 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 01f0da7..7354165 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 @@ -2,11 +2,14 @@ import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; +import com.example.umc10th.domain.member.entity.Member; import com.example.umc10th.domain.member.exception.MemberSuccessCode; import com.example.umc10th.domain.member.service.MemberService; import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -21,13 +24,20 @@ public ApiResponse signup(@RequestBody MemberReqDTO.SignUp return ApiResponse.onSuccess(code, memberService.signUp(dto)); } - @GetMapping("/members/me") - public ApiResponse getMyPage( - @RequestParam Long id // 추후 JWT 적용 시 @AuthenticationPrincipal로 교체 - ) { + @PostMapping("/auth/login") + public ApiResponse login(@RequestBody MemberReqDTO.Login dto){ return ApiResponse.onSuccess( - MemberSuccessCode.GET_MY_PAGE, - memberService.getMyPage(id) + MemberSuccessCode.LOGIN_SUCCESS, + memberService.login(dto) ); } + + + @GetMapping("/members/me") + public ApiResponse getMyPage( + @AuthenticationPrincipal AuthMember member + ) { + BaseSuccessCode code = MemberSuccessCode.GET_MY_PAGE; + return ApiResponse.onSuccess(code, memberService.getMyPage(member)); + } } 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 676ebde..723e295 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 @@ -7,12 +7,14 @@ public class MemberConverter { // ReqDTO → Entity - public static Member toMember(MemberReqDTO.SignUp dto) { + public static Member toMember(MemberReqDTO.SignUp dto, String encodedPassword) { return Member.builder() .name(dto.name()) .gender(dto.gender()) .birth(dto.birth()) .address(dto.address()) + .email(dto.email()) + .password(encodedPassword) .build(); } @@ -24,10 +26,18 @@ public static MemberResDTO.SignUp toSignUpRes(Member member) { .build(); } + public static MemberResDTO.Login toLoginRes(Member member, String accessToken){ + return MemberResDTO.Login.builder() + .memberId(member.getId()) + .accessToken(accessToken) + .build(); + } + // Entity → ResDTO (마이페이지) public static MemberResDTO.MyPage toMyPageRes(Member member) { return MemberResDTO.MyPage.builder() .name(member.getName()) + .email(member.getEmail()) .point(member.getPoint()) .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 752405f..fba5aae 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 @@ -15,6 +15,11 @@ public record SignUp( String password ){} + public record Login( + String email, + String password + ){} + public record GetMyPage( Long id ) {} 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 821c089..2358f6a 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 @@ -13,6 +13,12 @@ public record SignUp( LocalDateTime createdAt ){} + @Builder + public record Login( + String accessToken, + Long memberId + ){} + @Builder public record MyPage( String name, 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 d189781..831de05 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 @@ -29,20 +29,20 @@ public class Member extends BaseEntity { @JoinColumn(name = "region_id") private Region region; - @Column(name = "name") + @Column(name = "name", nullable = false) private String name; @Enumerated(EnumType.STRING) - @Column(name = "gender") + @Column(name = "gender", nullable = false) private Gender gender; - @Column(name = "birth") + @Column(name = "birth", nullable = false) private LocalDate birth; - @Column(name = "address") + @Column(name = "address", nullable = false) private String address; - @Column(name = "email") + @Column(name = "email", nullable = false) private String email; @Column(name = "password") diff --git a/src/main/java/com/example/umc10th/domain/member/exception/MemberErrorCode.java b/src/main/java/com/example/umc10th/domain/member/exception/MemberErrorCode.java index e7ad254..441d661 100644 --- a/src/main/java/com/example/umc10th/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/com/example/umc10th/domain/member/exception/MemberErrorCode.java @@ -9,12 +9,18 @@ @RequiredArgsConstructor public enum MemberErrorCode implements BaseErrorCode { + // 400 + INVALID_PASSWORD(HttpStatus.NOT_FOUND, + "MEMBER404_2", + "이메일 또는 비밀번호가 일치하지 않습니다."), + // 404 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "멤버를 찾을 수 없습니다."), + // 409 DUPLICATE_EMAIL(HttpStatus.CONFLICT, "MEMBER409_1", - "이미 존재하는 이메일입니다.") + "이미 존재하는 이메일입니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/example/umc10th/domain/member/exception/MemberSuccessCode.java b/src/main/java/com/example/umc10th/domain/member/exception/MemberSuccessCode.java index 270f90a..18245c5 100644 --- a/src/main/java/com/example/umc10th/domain/member/exception/MemberSuccessCode.java +++ b/src/main/java/com/example/umc10th/domain/member/exception/MemberSuccessCode.java @@ -12,6 +12,9 @@ public enum MemberSuccessCode implements BaseSuccessCode { GET_MY_PAGE(HttpStatus.OK, "MEMBER200_1", "마이페이지가 조회되었습니다."), + LOGIN_SUCCESS(HttpStatus.OK, + "MEMBER200_2", + "성공적으로 로그인 하였습니다."), SIGN_UP(HttpStatus.CREATED, "MEMBER201_1", "성공적으로 회원가입 하였습니다."), 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 fc135eb..ebf8615 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 @@ -7,7 +7,10 @@ import com.example.umc10th.domain.member.exception.MemberErrorCode; import com.example.umc10th.domain.member.repository.MemberRepository; import com.example.umc10th.global.apiPayload.exception.ProjectException; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.util.JwtUtil; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,18 +19,44 @@ public class MemberService { private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; - @Transactional + @Transactional public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp dto) { - Member member = MemberConverter.toMember(dto); + String encodedPassword = passwordEncoder.encode(dto.password()); + + Member member = MemberConverter.toMember(dto, encodedPassword); + Member saved = memberRepository.save(member); return MemberConverter.toSignUpRes(saved); } - @Transactional(readOnly = true) - public MemberResDTO.MyPage getMyPage(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ProjectException(MemberErrorCode.MEMBER_NOT_FOUND)); - return MemberConverter.toMyPageRes(member); + @Transactional + public MemberResDTO.Login login(MemberReqDTO.Login dto){ + Member member = memberRepository.findByEmail(dto.email()) + .orElseThrow(()-> new ProjectException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if(!passwordEncoder.matches(dto.password(), member.getPassword())){ + throw new ProjectException(MemberErrorCode.INVALID_PASSWORD); + } + + AuthMember authMember = new AuthMember(member); + + String accessToken = jwtUtil.createAccessToken(authMember); + + return MemberConverter.toLoginRes(member, accessToken); + } + +// @Transactional(readOnly = true) +// public MemberResDTO.MyPage getMyPage(Long memberId) { +// Member member = memberRepository.findById(memberId) +// .orElseThrow(() -> new ProjectException(MemberErrorCode.MEMBER_NOT_FOUND)); +// return MemberConverter.toMyPageRes(member); +// } + + @Transactional(readOnly = true) + public MemberResDTO.MyPage getMyPage(AuthMember member) { + return MemberConverter.toMyPageRes(member.getMember()); } } \ No newline at end of file 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 c20bdd7..d3f7fbb 100644 --- a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -2,6 +2,10 @@ import com.example.umc10th.global.security.exception.CustomAccessDenied; import com.example.umc10th.global.security.exception.CustomEntryPoint; +import com.example.umc10th.global.security.filter.JwtAuthFilter; +import com.example.umc10th.global.security.service.CustomUserDetailsService; +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; @@ -10,15 +14,22 @@ 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 String[] allowUris = { // Swagger 허용 "/swagger-ui/**", "/swagger-resources/**", + "/swagger-ui.html", + "/v3/api-docs/swagger-config", "/v3/api-docs/**", // public api @@ -29,19 +40,28 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + // URI 허용 여부 .authorizeHttpRequests(requests -> requests + // Public API 허용 .requestMatchers(allowUris).permitAll() + // 그 외의 API는 인증 필요 .anyRequest().authenticated() ) + // 폼 로그인 .formLogin(form -> form .defaultSuccessUrl("/swagger-ui/index.html", true) .permitAll() ) + // 세션 + .sessionManagement(AbstractHttpConfigurer::disable) + // JWT 필터 -> 순서는 상관없고 시큐리티 필터체인에 들어가면 된다. + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .permitAll() ) + // 예외사항 핸들러 .exceptionHandling(exception ->exception .accessDeniedHandler(customAccessDenied()) .authenticationEntryPoint(customEntryPoint()) @@ -65,4 +85,9 @@ public CustomAccessDenied customAccessDenied(){ public CustomEntryPoint customEntryPoint(){ return new CustomEntryPoint(); } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } } 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..f0195b5 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,73 @@ +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.service.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 { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 + 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); + } catch (Exception e) { + 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..7208367 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,94 @@ +package com.example.umc10th.global.security.util; + +import com.example.umc10th.global.security.entity.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; + +// create jwt, decode jwt util +@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); + } + + /** 토큰에서 이메일 가져오기 + * + * @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(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()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", member.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/resources/application.yml b/src/main/resources/application.yml index 10edda0..fbc158f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,4 +16,10 @@ spring: ddl-auto: update properties: hibernate: - format_sql: true # printed sql query formating \ No newline at end of file + format_sql: true # printed sql query formating + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 \ No newline at end of file