diff --git a/build.gradle b/build.gradle index d803a8d4..db8cc0e9 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,14 @@ 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/src/main/java/umc/domain/member/controller/MemberController.java b/src/main/java/umc/domain/member/controller/MemberController.java index 233d0e0a..822eba6f 100644 --- a/src/main/java/umc/domain/member/controller/MemberController.java +++ b/src/main/java/umc/domain/member/controller/MemberController.java @@ -2,12 +2,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import umc.domain.member.dto.MemberRequestDTO; import umc.domain.member.dto.MemberResponseDTO; import umc.domain.member.exception.code.MemberSuccessCode; import umc.domain.member.service.MemberService; import umc.global.apiPayload.ApiResponse; +import umc.global.apiPayload.code.BaseSuccessCode; +import umc.global.security.entity.AuthMember; @RestController @RequestMapping("/api/members") @@ -24,6 +27,14 @@ public ApiResponse signUp( return ApiResponse.onSuccess(MemberSuccessCode.CREATED, memberService.signUp(requestDto)); } + // 로그인 + @PostMapping("/login") + public ApiResponse login( + @RequestBody @Valid MemberRequestDTO.LoginDTO requestDto + ) { + return ApiResponse.onSuccess(MemberSuccessCode.LOGIN_SUCCESS, memberService.login(requestDto)); + } + // 홈 화면 조회 @GetMapping("/me/home") public ApiResponse getHome( @@ -38,21 +49,11 @@ public ApiResponse getHome( // 마이페이지 @GetMapping("/me") - public ApiResponse getMyPage() { - MemberResponseDTO.MyPageDTO responseDto = memberService.getMyPage(1L); - return ApiResponse.onSuccess(MemberSuccessCode.MY_PAGE_VIEW, responseDto); - } - - - - - - - - - - - - + public ApiResponse get( + @AuthenticationPrincipal AuthMember member + ) { + BaseSuccessCode code = MemberSuccessCode.OK; + return ApiResponse.onSuccess(code, memberService.getMyPage(member)); + } } diff --git a/src/main/java/umc/domain/member/dto/MemberRequestDTO.java b/src/main/java/umc/domain/member/dto/MemberRequestDTO.java index d838182b..450fdc4a 100644 --- a/src/main/java/umc/domain/member/dto/MemberRequestDTO.java +++ b/src/main/java/umc/domain/member/dto/MemberRequestDTO.java @@ -41,4 +41,12 @@ public record TermDTO( Boolean isAgreed ) {} + // 로그인 + public record LoginDTO( + @NotBlank + @Email + String email, + @NotBlank + String password + ) {} } diff --git a/src/main/java/umc/domain/member/dto/MemberResponseDTO.java b/src/main/java/umc/domain/member/dto/MemberResponseDTO.java index 643fc99b..4ba5fd14 100644 --- a/src/main/java/umc/domain/member/dto/MemberResponseDTO.java +++ b/src/main/java/umc/domain/member/dto/MemberResponseDTO.java @@ -42,4 +42,9 @@ public record MyPageDTO( Integer points, String profileUrl ) {} + + @Builder + public record LoginDTO( + String accessToken + ) {} } diff --git a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java index 67968050..d9fd8203 100644 --- a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java @@ -11,6 +11,7 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 멤버를 찾을 수 없습니다."), DUPLICATE_EMAIL(HttpStatus.CONFLICT, "MEMBER409_1", "이미 사용 중인 이메일입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "아이디 또는 비밀번호가 올바르지 않습니다."), ; private final HttpStatus status; diff --git a/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java b/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java index a91a2b2b..5e686994 100644 --- a/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java +++ b/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java @@ -10,9 +10,10 @@ public enum MemberSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "MEMBER201_1", "회원가입을 완료했습니다!"), + LOGIN_SUCCESS(HttpStatus.OK, "MEMBER200_4", "로그인에 성공했습니다."), HOME_VIEW(HttpStatus.OK, "HOME200_1", "홈 화면"), OK(HttpStatus.OK, "MEMBER200_2", "성공적으로 유저를 조회했습니다."), - MY_PAGE_VIEW(HttpStatus.OK, "MEMBER200_2", "마이페이지를 성공적으로 조회했습니다."), + MY_PAGE_VIEW(HttpStatus.OK, "MEMBER200_3", "마이페이지를 성공적으로 조회했습니다."), ; private final HttpStatus status; diff --git a/src/main/java/umc/domain/member/service/MemberService.java b/src/main/java/umc/domain/member/service/MemberService.java index a8a9d3bb..21ba80bb 100644 --- a/src/main/java/umc/domain/member/service/MemberService.java +++ b/src/main/java/umc/domain/member/service/MemberService.java @@ -33,6 +33,8 @@ import umc.domain.term.exception.TermException; import umc.domain.term.exception.code.TermErrorCode; import umc.domain.term.repository.TermRepository; +import umc.global.security.entity.AuthMember; +import umc.global.security.util.JwtUtil; import java.util.List; @@ -49,6 +51,7 @@ public class MemberService { private final MemberPreferFoodRepository memberPreferFoodRepository; private final MemberTermRepository memberTermRepository; private final TermRepository termRepository; + private final JwtUtil jwtUtil; // 회원가입 @@ -100,13 +103,27 @@ private void saveTermAgreements(Member member, List te memberTermRepository.saveAll(memberTerms); } - // 마이 페이지 + // 로그인 @Transactional - public MemberResponseDTO.MyPageDTO getMyPage(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + public MemberResponseDTO.LoginDTO login(MemberRequestDTO.LoginDTO dto) { + Member member = memberRepository.findByEmail(dto.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_CREDENTIALS)); + if (!passwordEncoder.matches(dto.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.INVALID_CREDENTIALS); + } - return MemberConverter.toMyPageViewDTO(member); + String token = jwtUtil.createAccessToken(new AuthMember(member)); + return MemberResponseDTO.LoginDTO.builder() + .accessToken(token) + .build(); + } + + // 마이 페이지 + @Transactional + public MemberResponseDTO.MyPageDTO getMyPage( + AuthMember authMember + ) { + return MemberConverter.toMyPageViewDTO(authMember.getMember()); } // 홈 화면 조회 diff --git a/src/main/java/umc/global/config/JacksonConfig.java b/src/main/java/umc/global/config/JacksonConfig.java new file mode 100644 index 00000000..2f2c718f --- /dev/null +++ b/src/main/java/umc/global/config/JacksonConfig.java @@ -0,0 +1,13 @@ +package umc.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} \ No newline at end of file diff --git a/src/main/java/umc/global/config/SecurityConfig.java b/src/main/java/umc/global/config/SecurityConfig.java index 778f8f8a..e91fb60d 100644 --- a/src/main/java/umc/global/config/SecurityConfig.java +++ b/src/main/java/umc/global/config/SecurityConfig.java @@ -10,8 +10,12 @@ 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; +import umc.global.security.filter.JwtAuthFilter; +import umc.global.security.service.CustomUserDetailsService; import umc.global.security.util.CustomAccessDenied; import umc.global.security.util.CustomEntryPoint; +import umc.global.security.util.JwtUtil; @EnableWebSecurity @Configuration @@ -20,13 +24,16 @@ public class SecurityConfig { private final CustomAccessDenied customAccessDenied; private final CustomEntryPoint customEntryPoint; + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailService; private final String[] allowUris = { // Swagger 허용 "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", - "/api/members/signup" + "/api/members/signup", + "/api/members/login" }; @Bean @@ -37,10 +44,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(allowUris).permitAll() .anyRequest().authenticated() ) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() - ) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(AbstractHttpConfigurer::disable) + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") @@ -60,7 +66,7 @@ public PasswordEncoder passwordEncoder() { } @Bean - public ObjectMapper objectMapepr() { - return new ObjectMapper(); + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailService); } } \ No newline at end of file diff --git a/src/main/java/umc/global/security/filter/JwtAuthFilter.java b/src/main/java/umc/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..e1fb219e --- /dev/null +++ b/src/main/java/umc/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,73 @@ +package umc.global.security.filter; + +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 umc.global.apiPayload.ApiResponse; +import umc.global.apiPayload.code.BaseErrorCode; +import umc.global.apiPayload.code.GeneralErrorCode; +import umc.global.security.service.CustomUserDetailsService; +import umc.global.security.util.JwtUtil; + +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/umc/global/security/util/JwtUtil.java b/src/main/java/umc/global/security/util/JwtUtil.java new file mode 100644 index 00000000..ee69844b --- /dev/null +++ b/src/main/java/umc/global/security/util/JwtUtil.java @@ -0,0 +1,93 @@ +package umc.global.security.util; + +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 umc.global.security.entity.AuthMember; + +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); + } + + /** 토큰에서 이메일 가져오기 + * + * @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); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d0eb061d..899d77b7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,17 +3,22 @@ spring: name: "umc10th" datasource: - driver-class-name: com.mysql.cj.jdbc.Driver # MySQL JDBC ???? ??? ?? - url: ${DB_URL} # jdbc:mysql://localhost:3306/{???????} - username: ${DB_USER} # MySQL ?? ?? - password: ${DB_PW} # MySQL ???? + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PW} jpa: - database: mysql # ??? ?????? ?? ?? (MySQL) - database-platform: org.hibernate.dialect.MySQLDialect # Hibernate?? ??? MySQL ??(dialect) ?? - show-sql: true # ??? SQL ??? ??? ???? ?? ?? + database: mysql + database-platform: org.hibernate.dialect.MySQLDialect + show-sql: true hibernate: - ddl-auto: update # ?????? ?? ? ?????? ???? ??? ?? + ddl-auto: update properties: hibernate: - format_sql: true # ???? SQL ??? ?? ?? ??? \ No newline at end of file + format_sql: true +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 \ No newline at end of file