diff --git a/build.gradle b/build.gradle index fb005db..886776f 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,16 @@ dependencies { implementation 'me.paulschwarz:spring-dotenv:4.0.0' // .env 읽기 + // JWT + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + // Spring Security + implementation("org.springframework.boot:spring-boot-starter-security") + + // WebClient + implementation("org.springframework.boot:spring-boot-starter-webflux") } tasks.named('test') { diff --git a/src/main/java/com/example/demo/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/demo/auth/client/KakaoOAuthClient.java new file mode 100644 index 0000000..80c6f93 --- /dev/null +++ b/src/main/java/com/example/demo/auth/client/KakaoOAuthClient.java @@ -0,0 +1,28 @@ +package com.example.demo.auth.client; + +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +@Component +public class KakaoOAuthClient { + + private final WebClient webClient; + + public KakaoOAuthClient(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.baseUrl("https://kapi.kakao.com").build(); + } + + public KakaoUserInfo retrieveUserInfo(String accessToken) { + try { + return webClient.get() + .uri("/v2/user/me") + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .bodyToMono(KakaoUserInfo.class) + .block(); + } catch (WebClientResponseException e) { + throw new RuntimeException("카카오 API 호출 실패: " + e.getResponseBodyAsString(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/auth/client/KakaoUserInfo.java b/src/main/java/com/example/demo/auth/client/KakaoUserInfo.java new file mode 100644 index 0000000..70292cf --- /dev/null +++ b/src/main/java/com/example/demo/auth/client/KakaoUserInfo.java @@ -0,0 +1,37 @@ +package com.example.demo.auth.client; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +public class KakaoUserInfo { + + private String id; + private Properties properties; + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + @Getter + @Setter + @NoArgsConstructor + public static class Properties { + private String nickname; + } + + @Getter + @Setter + @NoArgsConstructor + public static class KakaoAccount { + private String email; + } + + public String getNickName() { + return this.properties.nickname; + } + + public String getEmail() { + return this.kakaoAccount.email; + } +} diff --git a/src/main/java/com/example/demo/auth/dto/AccessTokenRequest.java b/src/main/java/com/example/demo/auth/dto/AccessTokenRequest.java new file mode 100644 index 0000000..70965a2 --- /dev/null +++ b/src/main/java/com/example/demo/auth/dto/AccessTokenRequest.java @@ -0,0 +1,6 @@ +package com.example.demo.auth.dto; + +public record AccessTokenRequest( + String accessToken +) { +} diff --git a/src/main/java/com/example/demo/auth/dto/RefreshTokenRequest.java b/src/main/java/com/example/demo/auth/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..865a897 --- /dev/null +++ b/src/main/java/com/example/demo/auth/dto/RefreshTokenRequest.java @@ -0,0 +1,6 @@ +package com.example.demo.auth.dto; + +public record RefreshTokenRequest( + String refreshToken +) { +} diff --git a/src/main/java/com/example/demo/auth/dto/TokenResponse.java b/src/main/java/com/example/demo/auth/dto/TokenResponse.java new file mode 100644 index 0000000..39e09a0 --- /dev/null +++ b/src/main/java/com/example/demo/auth/dto/TokenResponse.java @@ -0,0 +1,10 @@ +package com.example.demo.auth.dto; + +import lombok.Builder; + +@Builder +public record TokenResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/example/demo/auth/entity/Member.java b/src/main/java/com/example/demo/auth/entity/Member.java new file mode 100644 index 0000000..7ad6e1f --- /dev/null +++ b/src/main/java/com/example/demo/auth/entity/Member.java @@ -0,0 +1,24 @@ +package com.example.demo.auth.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Getter +public class Member { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String socialId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String email; +} diff --git a/src/main/java/com/example/demo/auth/entity/RefreshToken.java b/src/main/java/com/example/demo/auth/entity/RefreshToken.java new file mode 100644 index 0000000..0f61f5e --- /dev/null +++ b/src/main/java/com/example/demo/auth/entity/RefreshToken.java @@ -0,0 +1,27 @@ +package com.example.demo.auth.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long memberId; + + @Column(length = 500, nullable = false) + private String token; + + public void updateToken(String token) { + this.token = token; + } +} diff --git a/src/main/java/com/example/demo/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/demo/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..c871f1f --- /dev/null +++ b/src/main/java/com/example/demo/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package com.example.demo.auth.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String token = extractToken(request); + if (token != null) { + var authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + String header = "Authorization"; + String prefix = "Bearer "; + String bearerToken = request.getHeader(header); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(prefix)) { + return bearerToken.substring(prefix.length()); + } + + return null; + } +} diff --git a/src/main/java/com/example/demo/auth/jwt/JwtProperties.java b/src/main/java/com/example/demo/auth/jwt/JwtProperties.java new file mode 100644 index 0000000..c9c6603 --- /dev/null +++ b/src/main/java/com/example/demo/auth/jwt/JwtProperties.java @@ -0,0 +1,20 @@ +package com.example.demo.auth.jwt; + + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Getter +public class JwtProperties { + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration-time}") + private long accessTokenExpirationTime; + + @Value("${jwt.refresh-token-expiration-time}") + private long refreshTokenExpirationTime; + +} diff --git a/src/main/java/com/example/demo/auth/jwt/JwtTokenProvider.java b/src/main/java/com/example/demo/auth/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..fba0707 --- /dev/null +++ b/src/main/java/com/example/demo/auth/jwt/JwtTokenProvider.java @@ -0,0 +1,89 @@ +package com.example.demo.auth.jwt; + +import com.example.demo.auth.dto.TokenResponse; +import com.example.demo.auth.service.RefreshTokenService; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Collections; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + private final RefreshTokenService refreshTokenService; + private final SecretKey key; + + public JwtTokenProvider(JwtProperties jwtProperties, RefreshTokenService refreshTokenService) { + this.jwtProperties = jwtProperties; + this.refreshTokenService = refreshTokenService; + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.getSecret())); + } + + public String createAccessToken(Long userId) { + return createToken(userId, jwtProperties.getAccessTokenExpirationTime()); + } + + public String createRefreshToken(Long userId) { + return createToken(userId, jwtProperties.getRefreshTokenExpirationTime()); + } + + private String createToken(Long userId, long expireTime) { + Date now = new Date(); + return Jwts.builder() + .issuer("wisecard") + .claim("id", userId.toString()) + .issuedAt(now) + .expiration(new Date(now.getTime() + expireTime)) + .signWith(key) + .compact(); + } + + public TokenResponse reissueToken(String token) { + if (!refreshTokenService.existsToken(token)) { + throw new RuntimeException("refresh token을 찾을 수 없습니다."); + } + + Claims claims = validateToken(token); + Long userId = Long.parseLong(claims.get("id").toString()); + + String accessToken = createAccessToken(userId); + String refreshToken = createRefreshToken(userId); + + refreshTokenService.updateToken(userId, refreshToken); + + return new TokenResponse(accessToken, refreshToken); + } + + public Claims validateToken(String token) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (IllegalArgumentException | UnsupportedJwtException | MalformedJwtException | SecurityException e) { + throw new RuntimeException("잘못된 토큰입니다."); + } catch (ExpiredJwtException e) { + throw new RuntimeException("만료된 토큰입니다."); + } catch (Exception e) { + throw new RuntimeException("토큰 검증 중 알 수 없는 오류가 발생했습니다."); + } + } + + public Authentication getAuthentication(String token) { + Claims claims = validateToken(token); + String userId = claims.get("id").toString(); + + User user = new User(userId, "", Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))); + return new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()); + } +} diff --git a/src/main/java/com/example/demo/auth/repository/MemberRepository.java b/src/main/java/com/example/demo/auth/repository/MemberRepository.java new file mode 100644 index 0000000..1d880e8 --- /dev/null +++ b/src/main/java/com/example/demo/auth/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package com.example.demo.auth.repository; + +import com.example.demo.auth.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findBySocialId(String socialId); +} diff --git a/src/main/java/com/example/demo/auth/repository/RefreshTokenRepository.java b/src/main/java/com/example/demo/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..515d2d9 --- /dev/null +++ b/src/main/java/com/example/demo/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package com.example.demo.auth.repository; + +import com.example.demo.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByMemberId(Long memberId); + + Boolean existsByToken(String token); +} diff --git a/src/main/java/com/example/demo/auth/service/KakaoLoginService.java b/src/main/java/com/example/demo/auth/service/KakaoLoginService.java new file mode 100644 index 0000000..bbf1267 --- /dev/null +++ b/src/main/java/com/example/demo/auth/service/KakaoLoginService.java @@ -0,0 +1,75 @@ +package com.example.demo.auth.service; + +import com.example.demo.auth.client.KakaoOAuthClient; +import com.example.demo.auth.client.KakaoUserInfo; +import com.example.demo.auth.dto.AccessTokenRequest; +import com.example.demo.auth.dto.RefreshTokenRequest; +import com.example.demo.auth.dto.TokenResponse; +import com.example.demo.auth.entity.Member; +import com.example.demo.auth.jwt.JwtTokenProvider; +import com.example.demo.auth.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.demo.auth.util.AuthUtils.getMemberId; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KakaoLoginService { + private final MemberRepository memberRepository; + private final KakaoOAuthClient kakaoOAuthClient; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + @Transactional + public TokenResponse signup(AccessTokenRequest request) { + KakaoUserInfo kakaoUserInfo = kakaoOAuthClient.retrieveUserInfo(request.accessToken()); + + if (memberRepository.findBySocialId(kakaoUserInfo.getId()).isPresent()) { + throw new RuntimeException("이미 가입된 사용자입니다."); + } + + Member member = Member.builder() + .socialId(kakaoUserInfo.getId()) + .name(kakaoUserInfo.getNickName()) + .email(kakaoUserInfo.getEmail()) + .build(); + + Member newMember = memberRepository.save(member); + + String accessToken = jwtTokenProvider.createAccessToken(newMember.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(newMember.getId()); + + refreshTokenService.save(newMember.getId(), refreshToken); + + return new TokenResponse(accessToken, refreshToken); + } + + @Transactional + public TokenResponse login(AccessTokenRequest request) { + KakaoUserInfo kakaoUserInfo = kakaoOAuthClient.retrieveUserInfo(request.accessToken()); + Member member = memberRepository.findBySocialId(kakaoUserInfo.getId()).orElseThrow(); + + String accessToken = jwtTokenProvider.createAccessToken(member.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId()); + + refreshTokenService.saveOrUpdateToken(member.getId(), refreshToken); + + return new TokenResponse(accessToken, refreshToken); + } + + @Transactional + public TokenResponse reissue(RefreshTokenRequest request) { + return jwtTokenProvider.reissueToken(request.refreshToken()); + } + + + @Transactional + public void withdraw() { + Long memberId = getMemberId(); + memberRepository.deleteById(memberId); + } +} diff --git a/src/main/java/com/example/demo/auth/service/RefreshTokenService.java b/src/main/java/com/example/demo/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..53ba0e2 --- /dev/null +++ b/src/main/java/com/example/demo/auth/service/RefreshTokenService.java @@ -0,0 +1,52 @@ +package com.example.demo.auth.service; + +import com.example.demo.auth.entity.RefreshToken; +import com.example.demo.auth.repository.RefreshTokenRepository; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +@Service +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + public RefreshTokenService(RefreshTokenRepository refreshTokenRepository) { + this.refreshTokenRepository = refreshTokenRepository; + } + + public boolean existsToken(String token) { + return refreshTokenRepository.existsByToken(token); + } + + @Transactional + public void updateToken(Long userId, String token) { + RefreshToken refreshToken = refreshTokenRepository.findByMemberId(userId).orElseThrow(); + refreshToken.updateToken(token); + refreshTokenRepository.save(refreshToken); + } + + @Transactional + public void save(Long memberId, String token) { + RefreshToken refreshToken = RefreshToken.builder() + .memberId(memberId) + .token(token) + .build(); + refreshTokenRepository.save(refreshToken); + } + + @Transactional + public void saveOrUpdateToken(Long memberId, String token) { + refreshTokenRepository.findByMemberId(memberId).ifPresentOrElse( + refreshToken -> { + refreshToken.updateToken(token); + refreshTokenRepository.save(refreshToken); + }, + () -> { + RefreshToken newToken = RefreshToken.builder() + .memberId(memberId) + .token(token) + .build(); + refreshTokenRepository.save(newToken); + } + ); + } +} diff --git a/src/main/java/com/example/demo/auth/util/AuthUtils.java b/src/main/java/com/example/demo/auth/util/AuthUtils.java new file mode 100644 index 0000000..357a1d1 --- /dev/null +++ b/src/main/java/com/example/demo/auth/util/AuthUtils.java @@ -0,0 +1,20 @@ +package com.example.demo.auth.util; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; + +public class AuthUtils { + + private AuthUtils() { + } + + public static Long getMemberId() { + try { + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + User user = (User) principal; + return Long.parseLong(user.getUsername()); + } catch (Exception e) { + throw new RuntimeException("인증에 실패했습니다."); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/config/SecurityConfig.java b/src/main/java/com/example/demo/config/SecurityConfig.java new file mode 100644 index 0000000..4478197 --- /dev/null +++ b/src/main/java/com/example/demo/config/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.example.demo.config; + +import com.example.demo.auth.jwt.JwtAuthenticationFilter; +import com.example.demo.auth.jwt.JwtTokenProvider; +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.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + public SecurityConfig(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/h2-console/**").permitAll() + .anyRequest().permitAll() + ) + .addFilterBefore( + new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class + ); + + return http.build(); + } +} diff --git a/src/main/java/com/example/demo/controller/AuthController.java b/src/main/java/com/example/demo/controller/AuthController.java new file mode 100644 index 0000000..47ced9d --- /dev/null +++ b/src/main/java/com/example/demo/controller/AuthController.java @@ -0,0 +1,49 @@ +package com.example.demo.controller; + +import com.example.demo.auth.dto.AccessTokenRequest; +import com.example.demo.auth.dto.RefreshTokenRequest; +import com.example.demo.auth.dto.TokenResponse; +import com.example.demo.auth.service.KakaoLoginService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + private final KakaoLoginService kakaoLoginService; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody AccessTokenRequest request) { + try { + TokenResponse token = kakaoLoginService.signup(request); + return ResponseEntity.ok(token); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody AccessTokenRequest request) { + try { + TokenResponse token = kakaoLoginService.login(request); + return ResponseEntity.ok(token); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + } + + @PutMapping("/reissue") + public ResponseEntity reissue(@RequestBody RefreshTokenRequest request) { + TokenResponse token = kakaoLoginService.reissue(request); + return ResponseEntity.ok(token); + } + + @DeleteMapping("/withdraw") + public ResponseEntity withdraw() { + kakaoLoginService.withdraw(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b7864b0..10af7c2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,3 +44,8 @@ app: api: kakao: key: ${APP_API_KAKAO_KEY:} + +jwt: + secret: ${JWT_SECRET:} + access-token-expiration-time: 1209600000 # 14 day = 14 * 1000 * 60 * 60 * 24 + refresh-token-expiration-time: 15552000000 # 180day \ No newline at end of file