From 355b9755abcaa7c8e52299334153e7a23420f52e Mon Sep 17 00:00:00 2001 From: Junmo Date: Mon, 6 Jan 2025 10:54:10 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Feat:=20JWT=20=EC=B4=88=EA=B8=B0=EC=84=A4?= =?UTF-8?q?=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whalewatch/controller/UserController.java | 44 +----------- application.yml | 5 ++ common/build.gradle | 11 +++ .../whalewatch/config/JwtAuthentication.java | 50 +++++++++++++ .../whalewatch/config/JwtTokenProvider.java | 72 +++++++++++++++++++ .../com/whalewatch/config/SecurityConfig.java | 47 ++++++++++++ .../com/whalewatch/dto/TokenResponseDto.java | 26 +++++++ 7 files changed, 212 insertions(+), 43 deletions(-) create mode 100644 common/src/main/java/com/whalewatch/config/JwtAuthentication.java create mode 100644 common/src/main/java/com/whalewatch/config/JwtTokenProvider.java create mode 100644 common/src/main/java/com/whalewatch/config/SecurityConfig.java create mode 100644 common/src/main/java/com/whalewatch/dto/TokenResponseDto.java diff --git a/api/src/main/java/com/whalewatch/controller/UserController.java b/api/src/main/java/com/whalewatch/controller/UserController.java index 57c922a..0519ecb 100644 --- a/api/src/main/java/com/whalewatch/controller/UserController.java +++ b/api/src/main/java/com/whalewatch/controller/UserController.java @@ -1,43 +1 @@ -package com.whalewatch.controller; - -import com.whalewatch.domain.User; -import com.whalewatch.dto.UserDto; -import com.whalewatch.mapper.UserMapper; -import com.whalewatch.service.UserService; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/users") -public class UserController { - private final UserService userService; - private final UserMapper userMapper; - - public UserController(UserService userService,UserMapper userMapper) { - this.userService = userService; - this.userMapper = userMapper; - } - - @PostMapping - public UserDto registerUser(@RequestBody UserDto userDto) { - User entity = userMapper.toEntity(userDto); - User saved = userService.registerUser(entity); - return userMapper.toDto(saved); - } - - @PostMapping("/login") - public UserDto loginUser(@RequestBody UserDto userDto) { - User user = userService.loginUser(userDto.getEmail(),userDto.getPassword()); - - if (!user.getPassword().equals(userDto.getPassword())){ - throw new RuntimeException("Invalid password"); - } - - return userMapper.toDto(user); - } - - @GetMapping("{id}") - public UserDto getUserInfo(@PathVariable int id) { - User user = userService.getUserInfo(id); - return userMapper.toDto(user); - } -} + \ No newline at end of file diff --git a/application.yml b/application.yml index de50b8c..35891dd 100644 --- a/application.yml +++ b/application.yml @@ -17,3 +17,8 @@ spring: console: enabled: true path: /h2-console + + jwt: + secret-key: "${JWT_SECRET_KEY}" + access-token-validity-in-seconds: 600 + refresh-token-validity-in-seconds: 1209600 diff --git a/common/build.gradle b/common/build.gradle index ac2939b..7a118a0 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,7 +1,18 @@ plugins { id 'java' + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' } dependencies { + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + // JWT 설정 + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } \ No newline at end of file diff --git a/common/src/main/java/com/whalewatch/config/JwtAuthentication.java b/common/src/main/java/com/whalewatch/config/JwtAuthentication.java new file mode 100644 index 0000000..4b97d0c --- /dev/null +++ b/common/src/main/java/com/whalewatch/config/JwtAuthentication.java @@ -0,0 +1,50 @@ +package com.whalewatch.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class JwtAuthentication extends OncePerRequestFilter { + + private final JwtTokenProvider tokenProvider; + + public JwtAuthentication(JwtTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws IOException, jakarta.servlet.ServletException { + + String jwt = resolveToken(request); + + if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + String email = tokenProvider.getEmailFromToken(jwt); + + // DB 조회 후 권한 설정 가능 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(email, null, null); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + // "Authorization: Bearer <토큰값>" 형태일 때 추출 + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/common/src/main/java/com/whalewatch/config/JwtTokenProvider.java b/common/src/main/java/com/whalewatch/config/JwtTokenProvider.java new file mode 100644 index 0000000..26bff83 --- /dev/null +++ b/common/src/main/java/com/whalewatch/config/JwtTokenProvider.java @@ -0,0 +1,72 @@ +package com.whalewatch.config; + +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +public class JwtTokenProvider { + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-validity-in-seconds}") + private long accessTokenValidity; // 초 단위 (예: 600) + + @Value("${jwt.refresh-token-validity-in-seconds}") + private long refreshTokenValidity; // 초 단위 (예: 1209600) + + public JwtTokenProvider() { + } + + // Access Token 생성 + public String generateAccessToken(String email) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessTokenValidity * 1000); + + return Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + // Refresh Token 생성 + public String generateRefreshToken(String email) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + refreshTokenValidity * 1000); + + return Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + // 토큰에서 Subject 추출 + public String getEmailFromToken(String token) { + return Jwts.parserBuilder().build() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + // 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().build() + .setSigningKey(secretKey) + .parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + // 유효하지 않은 토큰 + } + return false; + } +} diff --git a/common/src/main/java/com/whalewatch/config/SecurityConfig.java b/common/src/main/java/com/whalewatch/config/SecurityConfig.java new file mode 100644 index 0000000..5cf672a --- /dev/null +++ b/common/src/main/java/com/whalewatch/config/SecurityConfig.java @@ -0,0 +1,47 @@ +package com.whalewatch.config; + +import org.springframework.beans.factory.annotation.Autowired; +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.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; + +@Configuration +public class SecurityConfig { + + private final JwtTokenProvider tokenProvider; + + @Autowired + public SecurityConfig(JwtTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + // Spring Security 설정 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf().disable() + // 세션 사용 안 함 + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + // URL별 권한 설정 + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/users", "/api/users/login", "/api/users/refresh").permitAll() + .anyRequest().authenticated() + ) + // JWT 필터 추가 + .addFilterBefore(new JwtAuthentication(tokenProvider), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/common/src/main/java/com/whalewatch/dto/TokenResponseDto.java b/common/src/main/java/com/whalewatch/dto/TokenResponseDto.java new file mode 100644 index 0000000..df159b8 --- /dev/null +++ b/common/src/main/java/com/whalewatch/dto/TokenResponseDto.java @@ -0,0 +1,26 @@ +package com.whalewatch.dto; + +public class TokenResponseDto { + private String accessToken; + private String refreshToken; + + public TokenResponseDto() {} + + public TokenResponseDto(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public String getAccessToken() { + return accessToken; + } + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + public String getRefreshToken() { + return refreshToken; + } + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} From 493c0c23aba41936d0fd1d8e58b85e2fc5f95af8 Mon Sep 17 00:00:00 2001 From: Junmo Date: Mon, 6 Jan 2025 16:58:13 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Feat:=20JWT=20service=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whalewatch/controller/UserController.java | 50 ++++++++++- service/build.gradle | 3 + .../java/com/whalewatch/domain/JwtToken.java | 35 ++++++++ .../repository/JwtTokenRepository.java | 11 +++ .../com/whalewatch/service/JwtService.java | 90 +++++++++++++++++++ .../com/whalewatch/service/UserService.java | 9 +- 6 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 service/src/main/java/com/whalewatch/domain/JwtToken.java create mode 100644 service/src/main/java/com/whalewatch/repository/JwtTokenRepository.java create mode 100644 service/src/main/java/com/whalewatch/service/JwtService.java diff --git a/api/src/main/java/com/whalewatch/controller/UserController.java b/api/src/main/java/com/whalewatch/controller/UserController.java index 0519ecb..6b1a7a4 100644 --- a/api/src/main/java/com/whalewatch/controller/UserController.java +++ b/api/src/main/java/com/whalewatch/controller/UserController.java @@ -1 +1,49 @@ - \ No newline at end of file +package com.whalewatch.controller; + +import com.whalewatch.domain.User; +import com.whalewatch.dto.TokenResponseDto; +import com.whalewatch.dto.UserDto; +import com.whalewatch.mapper.UserMapper; +import com.whalewatch.service.JwtService; +import com.whalewatch.service.UserService; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/users") +public class UserController { + private final UserService userService; + private final JwtService jwtService; + private final UserMapper userMapper; + + public UserController(UserService userService, + UserMapper userMapper, + JwtService jwtService) { + this.userService = userService; + this.userMapper = userMapper; + this.jwtService = jwtService; + } + + @PostMapping + public UserDto registerUser(@RequestBody UserDto userDto) { + User entity = userMapper.toEntity(userDto); + User saved = userService.registerUser(entity); + return userMapper.toDto(saved); + } + + @PostMapping("/login") + public TokenResponseDto loginUser(@RequestBody UserDto userDto) { + // JwtService로 로그인 + 토큰 발급 + return jwtService.login(userDto.getEmail(),userDto.getPassword()); + } + + @PostMapping("/refresh") + public TokenResponseDto refreshToken(@RequestBody TokenResponseDto tokenDto){ + return jwtService.refreshAccessToken(tokenDto.getRefreshToken()); + } + + @GetMapping("{id}") + public UserDto getUserInfo(@PathVariable int id) { + User user = userService.getUserInfo(id); + return userMapper.toDto(user); + } +} \ No newline at end of file diff --git a/service/build.gradle b/service/build.gradle index e7739d0..6b4476f 100644 --- a/service/build.gradle +++ b/service/build.gradle @@ -17,5 +17,8 @@ dependencies { implementation 'org.mapstruct:mapstruct:1.5.5.Final' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + //Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } \ No newline at end of file diff --git a/service/src/main/java/com/whalewatch/domain/JwtToken.java b/service/src/main/java/com/whalewatch/domain/JwtToken.java new file mode 100644 index 0000000..f302c2d --- /dev/null +++ b/service/src/main/java/com/whalewatch/domain/JwtToken.java @@ -0,0 +1,35 @@ +package com.whalewatch.domain; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "jwt_tokens") +public class JwtToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + private String email; + private LocalDateTime expiry; + + protected JwtToken() {} + + public JwtToken(String token, String email, LocalDateTime expiry) { + this.token = token; + this.email = email; + this.expiry = expiry; + } + + public Long getId() { return id; } + public String getToken() { return token; } + public String getEmail() { return email; } + public LocalDateTime getExpiry() { return expiry; } + + public void setId(Long id) { this.id = id; } + public void setToken(String token) { this.token = token; } + public void setEmail(String email) { this.email = email; } + public void setExpiry(LocalDateTime expiry) { this.expiry = expiry; } +} diff --git a/service/src/main/java/com/whalewatch/repository/JwtTokenRepository.java b/service/src/main/java/com/whalewatch/repository/JwtTokenRepository.java new file mode 100644 index 0000000..80863d7 --- /dev/null +++ b/service/src/main/java/com/whalewatch/repository/JwtTokenRepository.java @@ -0,0 +1,11 @@ +package com.whalewatch.repository; + +import com.whalewatch.domain.JwtToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface JwtTokenRepository extends JpaRepository { + Optional findByToken(String token); + void deleteByEmail(String email); +} diff --git a/service/src/main/java/com/whalewatch/service/JwtService.java b/service/src/main/java/com/whalewatch/service/JwtService.java new file mode 100644 index 0000000..4a5d7a5 --- /dev/null +++ b/service/src/main/java/com/whalewatch/service/JwtService.java @@ -0,0 +1,90 @@ +package com.whalewatch.service; + +import com.whalewatch.config.JwtTokenProvider; +import com.whalewatch.domain.JwtToken; +import com.whalewatch.domain.User; +import com.whalewatch.dto.TokenResponseDto; +import com.whalewatch.repository.JwtTokenRepository; +import com.whalewatch.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +public class JwtService { + private final UserRepository userRepository; + private final JwtTokenRepository jwtTokenRepository; + private final JwtTokenProvider tokenProvider; + private final PasswordEncoder passwordEncoder; + + public JwtService(UserRepository userRepository, + JwtTokenRepository jwtTokenRepository, + JwtTokenProvider tokenProvider, + PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.jwtTokenRepository = jwtTokenRepository; + this.tokenProvider = tokenProvider; + this.passwordEncoder = passwordEncoder; + } + + /** + * 로그인 -> 비밀번호 검증 -> Access/Refresh 토큰 발급 + */ + public TokenResponseDto login(String email, String rawPassword) { + // 1) 사용자 조회 + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("Invalid email or password")); + + // 2) 비밀번호 일치 여부 (BCrypt 매치) + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new RuntimeException("Invalid email or password"); + } + + // 3) 토큰 생성 + String accessToken = tokenProvider.generateAccessToken(user.getEmail()); + String refreshToken = tokenProvider.generateRefreshToken(user.getEmail()); + + // 4) 기존 refreshToken 있으면 제거 (단일 로그인 정책 등) + jwtTokenRepository.deleteByEmail(user.getEmail()); + + // 5) 새 refreshToken 저장 + JwtToken refreshTokenEntity = new JwtToken( + refreshToken, + user.getEmail(), + LocalDateTime.now().plusSeconds(1209600) // 2주 예시 + ); + jwtTokenRepository.save(refreshTokenEntity); + + return new TokenResponseDto(accessToken, refreshToken); + } + + /** + * Refresh Token -> 새 Access Token 발급 + */ + public TokenResponseDto refreshAccessToken(String refreshToken) { + // 1) DB에서 해당 refreshToken 조회 + JwtToken stored = jwtTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new RuntimeException("Invalid refresh token")); + + // 2) 만료 시간 확인 + if (stored.getExpiry().isBefore(LocalDateTime.now())) { + // 만료된 토큰 -> DB에서 삭제 후 에러 + jwtTokenRepository.delete(stored); + throw new RuntimeException("Refresh token expired. Please login again."); + } + + // 3) RefreshToken 자체가 위조/유효한지(서명) 검증 + if (!tokenProvider.validateToken(refreshToken)) { + jwtTokenRepository.delete(stored); + throw new RuntimeException("Invalid refresh token signature. Please login again."); + } + + // 4) 토큰에서 email 추출 -> 새 Access Token 발급 + String email = tokenProvider.getEmailFromToken(refreshToken); + String newAccessToken = tokenProvider.generateAccessToken(email); + + // (정책에 따라 RefreshToken도 재발급할 수 있음. 여기서는 재사용) + return new TokenResponseDto(newAccessToken, refreshToken); + } +} diff --git a/service/src/main/java/com/whalewatch/service/UserService.java b/service/src/main/java/com/whalewatch/service/UserService.java index d304e48..686f34b 100644 --- a/service/src/main/java/com/whalewatch/service/UserService.java +++ b/service/src/main/java/com/whalewatch/service/UserService.java @@ -2,17 +2,24 @@ import com.whalewatch.domain.User; import com.whalewatch.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class UserService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; - public UserService(UserRepository userRepository) { + public UserService(UserRepository userRepository, + PasswordEncoder passwordEncoder) { this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; } public User registerUser(User user) { + String hashed = passwordEncoder.encode(user.getPassword()); + user.setPassword(hashed); + return userRepository.save(user); } From ee5a2bb3af33402d3d8465d6e954f8b8172ef823 Mon Sep 17 00:00:00 2001 From: Junmo Date: Mon, 6 Jan 2025 17:43:29 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20JWT=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/whalewatch/service/JwtService.java | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/service/src/main/java/com/whalewatch/service/JwtService.java b/service/src/main/java/com/whalewatch/service/JwtService.java index 4a5d7a5..d76a14d 100644 --- a/service/src/main/java/com/whalewatch/service/JwtService.java +++ b/service/src/main/java/com/whalewatch/service/JwtService.java @@ -28,63 +28,50 @@ public JwtService(UserRepository userRepository, this.passwordEncoder = passwordEncoder; } - /** - * 로그인 -> 비밀번호 검증 -> Access/Refresh 토큰 발급 - */ - public TokenResponseDto login(String email, String rawPassword) { - // 1) 사용자 조회 + public TokenResponseDto login(String email, String Password) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new RuntimeException("Invalid email or password")); - // 2) 비밀번호 일치 여부 (BCrypt 매치) - if (!passwordEncoder.matches(rawPassword, user.getPassword())) { - throw new RuntimeException("Invalid email or password"); + if (!passwordEncoder.matches(Password, user.getPassword())) { + throw new RuntimeException("Invalid password"); } - // 3) 토큰 생성 String accessToken = tokenProvider.generateAccessToken(user.getEmail()); String refreshToken = tokenProvider.generateRefreshToken(user.getEmail()); - // 4) 기존 refreshToken 있으면 제거 (단일 로그인 정책 등) + //기존 refreshToken 있으면 제거 jwtTokenRepository.deleteByEmail(user.getEmail()); - // 5) 새 refreshToken 저장 + // refreshToken 저장 JwtToken refreshTokenEntity = new JwtToken( refreshToken, user.getEmail(), - LocalDateTime.now().plusSeconds(1209600) // 2주 예시 + LocalDateTime.now().plusSeconds(1209600) ); jwtTokenRepository.save(refreshTokenEntity); return new TokenResponseDto(accessToken, refreshToken); } - /** - * Refresh Token -> 새 Access Token 발급 - */ public TokenResponseDto refreshAccessToken(String refreshToken) { - // 1) DB에서 해당 refreshToken 조회 JwtToken stored = jwtTokenRepository.findByToken(refreshToken) - .orElseThrow(() -> new RuntimeException("Invalid refresh token")); + .orElseThrow(() -> new RuntimeException("Invalid token")); - // 2) 만료 시간 확인 if (stored.getExpiry().isBefore(LocalDateTime.now())) { - // 만료된 토큰 -> DB에서 삭제 후 에러 jwtTokenRepository.delete(stored); - throw new RuntimeException("Refresh token expired. Please login again."); + throw new RuntimeException("Token expired."); } - // 3) RefreshToken 자체가 위조/유효한지(서명) 검증 + // RefreshToken 자체가 유효한지 if (!tokenProvider.validateToken(refreshToken)) { jwtTokenRepository.delete(stored); - throw new RuntimeException("Invalid refresh token signature. Please login again."); + throw new RuntimeException("Invalid token signature."); } - // 4) 토큰에서 email 추출 -> 새 Access Token 발급 + // 새 Access Token 발급 String email = tokenProvider.getEmailFromToken(refreshToken); String newAccessToken = tokenProvider.generateAccessToken(email); - // (정책에 따라 RefreshToken도 재발급할 수 있음. 여기서는 재사용) return new TokenResponseDto(newAccessToken, refreshToken); } } From cffec379b8e682aba17bdbcccfff5e1a92e0346f Mon Sep 17 00:00:00 2001 From: Junmo Date: Mon, 6 Jan 2025 17:45:26 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20userservice=20login=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/whalewatch/service/UserService.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/service/src/main/java/com/whalewatch/service/UserService.java b/service/src/main/java/com/whalewatch/service/UserService.java index 686f34b..d7c05fd 100644 --- a/service/src/main/java/com/whalewatch/service/UserService.java +++ b/service/src/main/java/com/whalewatch/service/UserService.java @@ -23,11 +23,6 @@ public User registerUser(User user) { return userRepository.save(user); } - public User loginUser(String email,String password) { - return userRepository.findByEmailAndPassword(email, password) - .orElseThrow(() -> new RuntimeException("Invalid email or password")); - } - public User getUserInfo(int id) { return userRepository.findById(id).orElseThrow(() -> new RuntimeException("Not found")); } From 7a0bcb0310e852fd8a3fbc60e8dd71be0746e67e Mon Sep 17 00:00:00 2001 From: Junmo Date: Mon, 6 Jan 2025 17:56:57 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20jwtServiceTest=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whalewatch/service/JwtServiceTest.java | 91 +++++++++++++++++++ .../whalewatch/service/UserServiceTest.java | 21 ----- 2 files changed, 91 insertions(+), 21 deletions(-) create mode 100644 service/src/test/java/com/whalewatch/service/JwtServiceTest.java diff --git a/service/src/test/java/com/whalewatch/service/JwtServiceTest.java b/service/src/test/java/com/whalewatch/service/JwtServiceTest.java new file mode 100644 index 0000000..ad31f6e --- /dev/null +++ b/service/src/test/java/com/whalewatch/service/JwtServiceTest.java @@ -0,0 +1,91 @@ +package com.whalewatch.service; + +import com.whalewatch.config.JwtTokenProvider; +import com.whalewatch.domain.JwtToken; +import com.whalewatch.domain.User; +import com.whalewatch.dto.TokenResponseDto; +import com.whalewatch.repository.JwtTokenRepository; +import com.whalewatch.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(SpringExtension.class) +public class JwtServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private JwtTokenRepository refreshTokenRepository; + @Mock + private JwtTokenProvider jwtTokenProvider; + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private JwtService jwtService; + + @Test + void loginSuccess() { + // given + String email = "test@test.com"; + String Password = "test"; + String encodedPassword = "$2a$10$ABCD123..."; // bcrypt 해싱된 값 가정 + User user = new User(email, "logtester", encodedPassword); + + given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + // 비밀번호 매칭 + given(passwordEncoder.matches(Password, encodedPassword)).willReturn(true); + + // JWT 생성 + given(jwtTokenProvider.generateAccessToken(email)).willReturn("access-token"); + given(jwtTokenProvider.generateRefreshToken(email)).willReturn("refresh-token"); + + // when + TokenResponseDto result = jwtService.login(email, Password); + + // then + assertNotNull(result); + assertEquals("access-token", result.getAccessToken()); + assertEquals("refresh-token", result.getRefreshToken()); + verify(refreshTokenRepository).deleteByEmail(email); + verify(refreshTokenRepository).save(any(JwtToken.class)); + } + + @Test + void refreshAccessTokenSuccess() { + // given + String refreshToken = "valid-refresh-token"; + JwtToken stored = new JwtToken(refreshToken, "refresh@test.com", LocalDateTime.now().plusDays(1)); + given(refreshTokenRepository.findByToken(refreshToken)).willReturn(Optional.of(stored)); + + // 토큰 서명/만료 검증 + given(jwtTokenProvider.validateToken(refreshToken)).willReturn(true); + + // 토큰에서 이메일 추출 + given(jwtTokenProvider.getEmailFromToken(refreshToken)).willReturn("refresh@test.com"); + + // 새 Access Token 발급 + given(jwtTokenProvider.generateAccessToken("refresh@test.com")).willReturn("new-access-token"); + + // when + TokenResponseDto result = jwtService.refreshAccessToken(refreshToken); + + // then + assertNotNull(result); + assertEquals("new-access-token", result.getAccessToken()); + assertEquals("valid-refresh-token", result.getRefreshToken()); + } +} diff --git a/service/src/test/java/com/whalewatch/service/UserServiceTest.java b/service/src/test/java/com/whalewatch/service/UserServiceTest.java index e8a8720..192b103 100644 --- a/service/src/test/java/com/whalewatch/service/UserServiceTest.java +++ b/service/src/test/java/com/whalewatch/service/UserServiceTest.java @@ -39,27 +39,6 @@ void registerUser() { assertEquals("1234", result.getPassword()); // 비밀번호 검증 } - @Test - void loginUser() { - // given - String email = "login@test.com"; - String password = "1234"; - User user = new User(email, "logtester", password); - - given(userRepository.findByEmailAndPassword(email, password)) - .willReturn(Optional.of(user)); - - // when - User result = userService.loginUser(email, password); - - // then - assertNotNull(result); // 반환값이 null이 아님을 확인 - assertEquals(email, result.getEmail()); // 이메일 검증 - assertEquals("logtester", result.getUsername()); // 이름 검증 - assertEquals(password, result.getPassword()); // 비밀번호 검증 - } - - @Test void getUserInfo() { // given From 8f90d5852fc53ea5fa5a8a9feca8a749ccefd340 Mon Sep 17 00:00:00 2001 From: Junmo Date: Tue, 7 Jan 2025 09:17:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20jwt=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/build.gradle | 2 + .../controller/UserControllerTest.java | 81 ++++++++++++++++--- application.yml | 8 +- .../whalewatch/config/JwtTokenProvider.java | 10 ++- .../com/whalewatch/config/SecurityConfig.java | 3 +- 5 files changed, 85 insertions(+), 19 deletions(-) diff --git a/api/build.gradle b/api/build.gradle index 1cdca9e..0744cab 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -11,5 +11,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.boot:spring-boot-starter-test' } \ No newline at end of file diff --git a/api/src/test/java/com/whalewatch/controller/UserControllerTest.java b/api/src/test/java/com/whalewatch/controller/UserControllerTest.java index 1618a72..a664bfb 100644 --- a/api/src/test/java/com/whalewatch/controller/UserControllerTest.java +++ b/api/src/test/java/com/whalewatch/controller/UserControllerTest.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.whalewatch.domain.User; +import com.whalewatch.dto.TokenResponseDto; import com.whalewatch.dto.UserDto; +import com.whalewatch.repository.JwtTokenRepository; import com.whalewatch.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -10,6 +12,8 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import java.util.List; @@ -22,6 +26,11 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc +@TestPropertySource(properties = { + "jwt.secret-key=zTjEp7AUmDS+bUZKV5OFIVUtFL7EQCMflxiZ3gxpxo0=", + "jwt.access-token-validity-in-seconds=600", + "jwt.refresh-token-validity-in-seconds=1209600" +}) public class UserControllerTest { @Autowired @@ -33,18 +42,27 @@ public class UserControllerTest { @Autowired private ObjectMapper objectMapper; + @Autowired + private JwtTokenRepository jwtTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + @BeforeEach void setup() { + jwtTokenRepository.deleteAll(); userRepository.deleteAll(); - userRepository.save(new User("test@test.com", "tester", "1234")); + + String hashed = passwordEncoder.encode("1234"); + userRepository.save(new User("test@test.com", "tester", hashed)); } @Test void testRegisterUser() throws Exception { // given UserDto request = new UserDto(); - request.setEmail("test@test.com"); - request.setUsername("tester"); + request.setEmail("test2@test.com"); + request.setUsername("tester2"); request.setPassword("1234"); // when @@ -53,8 +71,8 @@ void testRegisterUser() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.email").value("test@test.com")) - .andExpect(jsonPath("$.username").value("tester")); + .andExpect(jsonPath("$.email").value("test2@test.com")) + .andExpect(jsonPath("$.username").value("tester2")); List all = userRepository.findAll(); assertEquals(2, all.size()); @@ -72,20 +90,63 @@ void testLoginUser() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.email").value("test@test.com")) - .andExpect(jsonPath("$.username").value("tester")); + .andExpect(jsonPath("$.accessToken").exists()) + .andExpect(jsonPath("$.refreshToken").exists()); } @Test void testGetUserInfo() throws Exception { // given - User first = userRepository.findAll().get(0); + User user = userRepository.findAll().get(0); + + // 로그인 + UserDto loginRequest = new UserDto(); + loginRequest.setEmail("test@test.com"); + loginRequest.setPassword("1234"); + + String loginResponse = mockMvc.perform(post("/api/users/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + TokenResponseDto tokens = objectMapper.readValue(loginResponse, TokenResponseDto.class); + String accessToken = tokens.getAccessToken(); // when & then - mockMvc.perform(get("/api/users/" + first.getId())) + mockMvc.perform(get("/api/users/" + user.getId()) + .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("test@test.com")) .andExpect(jsonPath("$.username").value("tester")); } + + @Test + void testRefreshToken() throws Exception { + //given + UserDto loginRequest = new UserDto(); + loginRequest.setEmail("test@test.com"); + loginRequest.setPassword("1234"); + + //when + String loginResponse = mockMvc.perform(post("/api/users/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + TokenResponseDto tokens = objectMapper.readValue(loginResponse, TokenResponseDto.class); + String refreshToken = tokens.getRefreshToken(); + + //given - accesstoken 재발급 + TokenResponseDto refreshRequest = new TokenResponseDto("", refreshToken); + + // then + mockMvc.perform(post("/api/users/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(refreshRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").exists()) + .andExpect(jsonPath("$.refreshToken").value(refreshToken)); + } } diff --git a/application.yml b/application.yml index 35891dd..37d8925 100644 --- a/application.yml +++ b/application.yml @@ -18,7 +18,7 @@ spring: enabled: true path: /h2-console - jwt: - secret-key: "${JWT_SECRET_KEY}" - access-token-validity-in-seconds: 600 - refresh-token-validity-in-seconds: 1209600 +jwt: + secret-key: "${JWT_SECRET_KEY}" + access-token-validity-in-seconds: 600 + refresh-token-validity-in-seconds: 1209600 diff --git a/common/src/main/java/com/whalewatch/config/JwtTokenProvider.java b/common/src/main/java/com/whalewatch/config/JwtTokenProvider.java index 26bff83..23e247a 100644 --- a/common/src/main/java/com/whalewatch/config/JwtTokenProvider.java +++ b/common/src/main/java/com/whalewatch/config/JwtTokenProvider.java @@ -14,10 +14,10 @@ public class JwtTokenProvider { private String secretKey; @Value("${jwt.access-token-validity-in-seconds}") - private long accessTokenValidity; // 초 단위 (예: 600) + private long accessTokenValidity; @Value("${jwt.refresh-token-validity-in-seconds}") - private long refreshTokenValidity; // 초 단위 (예: 1209600) + private long refreshTokenValidity; public JwtTokenProvider() { } @@ -50,8 +50,9 @@ public String generateRefreshToken(String email) { // 토큰에서 Subject 추출 public String getEmailFromToken(String token) { - return Jwts.parserBuilder().build() + return Jwts.parserBuilder() .setSigningKey(secretKey) + .build() .parseClaimsJws(token) .getBody() .getSubject(); @@ -60,8 +61,9 @@ public String getEmailFromToken(String token) { // 토큰 유효성 검증 public boolean validateToken(String token) { try { - Jwts.parserBuilder().build() + Jwts.parserBuilder() .setSigningKey(secretKey) + .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { diff --git a/common/src/main/java/com/whalewatch/config/SecurityConfig.java b/common/src/main/java/com/whalewatch/config/SecurityConfig.java index 5cf672a..726d217 100644 --- a/common/src/main/java/com/whalewatch/config/SecurityConfig.java +++ b/common/src/main/java/com/whalewatch/config/SecurityConfig.java @@ -35,7 +35,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .and() // URL별 권한 설정 .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/users", "/api/users/login", "/api/users/refresh").permitAll() + .requestMatchers("/h2-console/**").permitAll() + .requestMatchers("/api/users/**").permitAll() .anyRequest().authenticated() ) // JWT 필터 추가 From 14dea18ba7fa191b0c3c22eecc58a4dc0d4ff948 Mon Sep 17 00:00:00 2001 From: Junmo Date: Thu, 9 Jan 2025 23:00:06 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:Security=20Config=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/src/main/java/com/whalewatch/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/whalewatch/config/SecurityConfig.java b/common/src/main/java/com/whalewatch/config/SecurityConfig.java index 726d217..7b7e788 100644 --- a/common/src/main/java/com/whalewatch/config/SecurityConfig.java +++ b/common/src/main/java/com/whalewatch/config/SecurityConfig.java @@ -36,7 +36,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // URL별 권한 설정 .authorizeHttpRequests(auth -> auth .requestMatchers("/h2-console/**").permitAll() - .requestMatchers("/api/users/**").permitAll() + .requestMatchers("/api/users", "/api/users/login").permitAll() // 회원가입 및 로그인 허용 + .requestMatchers("/api/users/**").authenticated() .anyRequest().authenticated() ) // JWT 필터 추가