diff --git a/build.gradle b/build.gradle index 60e9a92..92a0e1a 100644 --- a/build.gradle +++ b/build.gradle @@ -26,9 +26,11 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-security' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testCompileOnly 'org.projectlombok:lombok' @@ -38,6 +40,11 @@ dependencies { 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' + } tasks.named('test') { diff --git a/src/main/java/com/umc/umc10th/domain/user/controller/UserAuthController.java b/src/main/java/com/umc/umc10th/domain/user/controller/UserAuthController.java index da81ee3..0a05ed3 100644 --- a/src/main/java/com/umc/umc10th/domain/user/controller/UserAuthController.java +++ b/src/main/java/com/umc/umc10th/domain/user/controller/UserAuthController.java @@ -2,6 +2,7 @@ import com.umc.umc10th.domain.user.apipayload.code.UserSuccessCode; import com.umc.umc10th.domain.user.dto.request.UserRequestDto; +import com.umc.umc10th.domain.user.dto.response.UserResponseDto; import com.umc.umc10th.domain.user.service.UserService; import com.umc.umc10th.global.apipayload.ApiResponse; import com.umc.umc10th.global.apipayload.code.BaseSuccessCode; @@ -22,4 +23,9 @@ public ApiResponse signup(@Valid @RequestBody UserRequestDto.CreateUser dt userService.createUser(dto); return ApiResponse.onSuccess(code); } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody UserRequestDto.Login dto) { + return ApiResponse.onSuccess(UserSuccessCode.OK, userService.login(dto)); + } } diff --git a/src/main/java/com/umc/umc10th/domain/user/controller/UserController.java b/src/main/java/com/umc/umc10th/domain/user/controller/UserController.java index 0856896..3bf6abf 100644 --- a/src/main/java/com/umc/umc10th/domain/user/controller/UserController.java +++ b/src/main/java/com/umc/umc10th/domain/user/controller/UserController.java @@ -8,8 +8,10 @@ import com.umc.umc10th.domain.user.service.UserService; import com.umc.umc10th.global.apipayload.ApiResponse; import com.umc.umc10th.global.apipayload.code.BaseSuccessCode; +import com.umc.umc10th.global.security.AuthUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -20,9 +22,8 @@ public class UserController { private final UserService userService; @GetMapping("/me") - public ApiResponse getInfo() { - BaseSuccessCode code = UserSuccessCode.OK; - return ApiResponse.onSuccess(code, userService.getInfo()); + public ApiResponse getInfo(@AuthenticationPrincipal AuthUser authUser) { + return ApiResponse.onSuccess(UserSuccessCode.OK, userService.getInfo(authUser)); } @PostMapping("/me/missions") diff --git a/src/main/java/com/umc/umc10th/domain/user/dto/request/UserRequestDto.java b/src/main/java/com/umc/umc10th/domain/user/dto/request/UserRequestDto.java index b9fc6bf..fbbe28d 100644 --- a/src/main/java/com/umc/umc10th/domain/user/dto/request/UserRequestDto.java +++ b/src/main/java/com/umc/umc10th/domain/user/dto/request/UserRequestDto.java @@ -25,6 +25,15 @@ public record CreateUser( String phone ) {} + @Builder + public record Login( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + @NotBlank(message = "비밀번호는 필수입니다.") + String password + ) {} + @Builder public record GetMissionsRequest( @NotNull(message = "사용자 ID는 필수입니다") diff --git a/src/main/java/com/umc/umc10th/domain/user/dto/response/UserResponseDto.java b/src/main/java/com/umc/umc10th/domain/user/dto/response/UserResponseDto.java index ea6b050..601c81c 100644 --- a/src/main/java/com/umc/umc10th/domain/user/dto/response/UserResponseDto.java +++ b/src/main/java/com/umc/umc10th/domain/user/dto/response/UserResponseDto.java @@ -3,6 +3,8 @@ import lombok.Builder; public class UserResponseDto { + public record Login(String token) {} + @Builder public record GetInfo( String nickname, diff --git a/src/main/java/com/umc/umc10th/domain/user/exception/code/UserErrorCode.java b/src/main/java/com/umc/umc10th/domain/user/exception/code/UserErrorCode.java index 6678189..406c10c 100644 --- a/src/main/java/com/umc/umc10th/domain/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/umc/umc10th/domain/user/exception/code/UserErrorCode.java @@ -10,6 +10,7 @@ public enum UserErrorCode implements BaseErrorCode { DUPLICATE_EMAIL(HttpStatus.CONFLICT, "USER409_1", "이미 사용 중인 이메일입니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER404_1", "존재하지 않는 유저입니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "USER401_1", "이메일 또는 비밀번호가 올바르지 않습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/umc/umc10th/domain/user/service/UserService.java b/src/main/java/com/umc/umc10th/domain/user/service/UserService.java index 2fe50fa..1450d83 100644 --- a/src/main/java/com/umc/umc10th/domain/user/service/UserService.java +++ b/src/main/java/com/umc/umc10th/domain/user/service/UserService.java @@ -14,6 +14,8 @@ import com.umc.umc10th.domain.user.exception.code.UserErrorCode; import com.umc.umc10th.domain.user.repository.UserDoingMissionRepository; import com.umc.umc10th.domain.user.repository.UserRepository; +import com.umc.umc10th.global.security.AuthUser; +import com.umc.umc10th.global.security.util.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -34,9 +36,8 @@ public class UserService { private final UserDoingMissionRepository userDoingMissionRepository; private final ReviewRepository reviewRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; - // 임시 인증 유저 ID - 실제로는 SecurityContextHolder 등에서 추출 - private static final Long TEMP_USER_ID = 1L; private static final int PAGE_SIZE = 10; @Transactional @@ -48,10 +49,18 @@ public void createUser(UserRequestDto.CreateUser dto) { userRepository.save(UserConverter.toUser(dto, encodedPassword)); } - public UserResponseDto.GetInfo getInfo() { - User user = userRepository.findUserInfo(TEMP_USER_ID) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + public UserResponseDto.Login login(UserRequestDto.Login dto) { + User user = userRepository.findByLoginId(dto.email()) + .orElseThrow(() -> new UserException(UserErrorCode.INVALID_PASSWORD)); + if (!passwordEncoder.matches(dto.password(), user.getPassword())) { + throw new UserException(UserErrorCode.INVALID_PASSWORD); + } + String token = jwtUtil.createAccessToken(new AuthUser(user)); + return new UserResponseDto.Login(token); + } + public UserResponseDto.GetInfo getInfo(AuthUser authUser) { + User user = authUser.getUser(); return UserResponseDto.GetInfo.builder() .nickname(user.getName()) .email(user.getLoginId()) @@ -60,9 +69,9 @@ public UserResponseDto.GetInfo getInfo() { .build(); } - public MissionResponseDto.GetMissions getMissions(Long locationId, String status) { + public MissionResponseDto.GetMissions getMissions(Long locationId, String status, AuthUser authUser) { Pageable pageable = PageRequest.of(0, PAGE_SIZE); - Page page = userDoingMissionRepository.findMyMissions(TEMP_USER_ID, status, pageable); + Page page = userDoingMissionRepository.findMyMissions(authUser.getUser().getId(), status, pageable); List items = page.getContent().stream() .map(udm -> MissionResponseDto.GetMissions.GetMission.builder() @@ -77,9 +86,9 @@ public MissionResponseDto.GetMissions getMissions(Long locationId, String status .build(); } - public ReviewResponseDto.GetMyReviews getMyReviews() { + public ReviewResponseDto.GetMyReviews getMyReviews(AuthUser authUser) { Pageable pageable = PageRequest.of(0, PAGE_SIZE); - Page page = reviewRepository.findMyReviews(TEMP_USER_ID, pageable); + Page page = reviewRepository.findMyReviews(authUser.getUser().getId(), pageable); List items = page.getContent().stream() .map(r -> ReviewResponseDto.GetMyReviews.ReviewItem.builder() diff --git a/src/main/java/com/umc/umc10th/global/config/SecurityConfig.java b/src/main/java/com/umc/umc10th/global/config/SecurityConfig.java index 58b4bff..48f6925 100644 --- a/src/main/java/com/umc/umc10th/global/config/SecurityConfig.java +++ b/src/main/java/com/umc/umc10th/global/config/SecurityConfig.java @@ -1,16 +1,21 @@ package com.umc.umc10th.global.config; +import com.umc.umc10th.global.security.CustomUserDetailsService; +import com.umc.umc10th.global.security.filter.JwtAuthFilter; import com.umc.umc10th.global.security.handler.CustomAccessDenied; import com.umc.umc10th.global.security.handler.CustomEntryPoint; +import com.umc.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 @@ -19,6 +24,8 @@ public class SecurityConfig { private final CustomAccessDenied customAccessDenied; private final CustomEntryPoint customEntryPoint; + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; private final String[] allowUris = { "/swagger-ui/**", @@ -27,23 +34,22 @@ public class SecurityConfig { "/auth/**" }; + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + @Bean 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() ) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() - ) - .logout(logout -> logout - .logoutUrl("/logout") - .logoutSuccessUrl("/login?logout") - .permitAll() - ) + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) .exceptionHandling(e -> e .accessDeniedHandler(customAccessDenied) .authenticationEntryPoint(customEntryPoint) diff --git a/src/main/java/com/umc/umc10th/global/security/filter/JwtAuthFilter.java b/src/main/java/com/umc/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 0000000..4208e52 --- /dev/null +++ b/src/main/java/com/umc/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,69 @@ +package com.umc.umc10th.global.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.umc10th.global.apipayload.ApiResponse; +import com.umc.umc10th.global.apipayload.code.BaseErrorCode; +import com.umc.umc10th.global.apipayload.code.GeneralErrorCode; +import com.umc.umc10th.global.security.CustomUserDetailsService; +import com.umc.umc10th.global.security.util.JwtUtil; +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"); + + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + token = token.replace("Bearer ", ""); + + 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); + } + + 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); + } + } +} diff --git a/src/main/java/com/umc/umc10th/global/security/util/JwtUtil.java b/src/main/java/com/umc/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 0000000..e19d055 --- /dev/null +++ b/src/main/java/com/umc/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,79 @@ +package com.umc.umc10th.global.security.util; + +import com.umc.umc10th.global.security.AuthUser; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jws; +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); + } + + public String createAccessToken(AuthUser member) { + return createToken(member, accessExpiration); + } + + 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(AuthUser member, Duration expiration) { + Instant now = Instant.now(); + + String authorities = member.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(member.getUsername()) + .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) + .build() + .parseSignedClaims(token); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fbeafd7..bcd014a 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 # ???? SQL ??? ?? ?? ??? \ No newline at end of file + format_sql: true # ???? SQL ??? ?? ?? ??? + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 \ No newline at end of file