Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨Feat: 카카오 소셜로그인 구현 (#31) #34

Merged
merged 15 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4f15ff9
:heavy_plus_sign: Chore: 로그인 관련 의존성 추가 (#31)
win-luck Oct 15, 2023
8c81c46
:sparkles: Feat: 카카오로그인 관련 객체 추가 (#31)
win-luck Oct 15, 2023
af26095
:sparkles: Feat: AuthService 구현 및 UserController에 추가 (#31)
win-luck Oct 15, 2023
ca7e9f5
:heavy_plus_sign: Chore: WebClientConfig dependency 누락 반영 (#31)
win-luck Oct 15, 2023
b1740ba
:heavy_plus_sign: Chore: build.gradle 누락된 의존성 추가 (#31)
win-luck Oct 15, 2023
f1b5e9c
:white_check_mark: Fix: UserControllerTest에 KakaoAuthService 반영 (#31)
win-luck Oct 15, 2023
6671250
:heavy_plus_sign: Chore: Jwt 관련 의존성 추가 및 Auth 기능 패키지 분리 (#31)
win-luck Oct 16, 2023
249f05a
:safety_vest: Fix: UserService keyCode 관련 예외처리 로직 보강 (#31)
win-luck Oct 16, 2023
40a9089
:fire: Fix: UserController 로그인 기능 삭제 (#31)
win-luck Oct 16, 2023
229ac32
:sparkles: Feat: Spring Security를 위한 User 객체에 UserDetails 인터페이스 상속 (…
win-luck Oct 16, 2023
39c628b
:sparkles: Feat: WebSecurity 설정 및 Jwt 토큰 생성 기능 구현 (#31)
win-luck Oct 16, 2023
f8a8505
:sparkles: Feat: 회원가입 및 로그인 AuthController 구현 (테스트 X) (#31)
win-luck Oct 16, 2023
4fb3909
:heavy_plus_sign: Chore: 프로퍼티 개행 누락 수정 (#31)
win-luck Oct 16, 2023
90c29f4
:fire: Chore: ApplicationTests @WebAppConfiguration 어노테이션 추가 (#31)
win-luck Oct 16, 2023
7e6066d
:sparkles: Feat: AuthController 토큰 검증 로직 추가 (#31)
win-luck Oct 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ dependencies {
// Swagger dependency
implementation 'io.springfox:springfox-boot-starter:3.0.0'
implementation 'io.springfox:springfox-swagger-ui:3.0.0'

// Webclient dependency
implementation platform('org.springframework.boot:spring-boot-dependencies:2.7.15')
implementation 'org.springframework.boot:spring-boot-starter-webflux'

implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'

// Jwt dependency
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2' // Spring Boot MyBatis
implementation "io.jsonwebtoken:jjwt:0.9.1"
}

tasks.named('test') {
Expand Down
22 changes: 0 additions & 22 deletions src/main/java/com/diareat/diareat/auth/AuthService.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.diareat.diareat.auth.component;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {

private final JwtTokenProvider jwtTokenProvider;

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 토큰 받아오기
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

// 토큰이 유효하다면
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰으로부터 유저 정보를 받아
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 객체 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}

// 다음 Filter 실행
chain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.diareat.diareat.auth.component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

@Value("${jwt.secret}")
private String secretKey;

private final UserDetailsService userDetailsService;

// 객체 초기화, secretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}

// 토큰 생성
public String createToken(String userPk) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + (30 * 60 * 1000L))) // 토큰 유효시각 설정 (30분)
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘과, secret 값
.compact();
}

// 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}

// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}

// 토큰 유효성, 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}

// Request의 Header에서 token 값 가져오기
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.diareat.diareat.auth.component;

import com.diareat.diareat.auth.dto.KakaoUserInfoResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;

@RequiredArgsConstructor
@Component
public class KakaoUserInfo {

private final WebClient webClient;
private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me";

public KakaoUserInfoResponse getUserInfo(String token) {
Flux<KakaoUserInfoResponse> response = webClient.get()
.uri(USER_INFO_URI)
.header("Authorization", "Bearer " + token)
.retrieve()
.bodyToFlux(KakaoUserInfoResponse.class);
return response.blockFirst();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.diareat.diareat.auth.controller;

import com.diareat.diareat.auth.component.JwtTokenProvider;
import com.diareat.diareat.auth.service.KakaoAuthService;
import com.diareat.diareat.user.dto.CreateUserDto;
import com.diareat.diareat.user.service.UserService;
import com.diareat.diareat.util.api.ApiResponse;
import com.diareat.diareat.util.api.ResponseCode;
import io.swagger.annotations.Api;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;

@Api(tags = "2. Auth")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/auth")
public class AuthController {

private final UserService userService;
private final KakaoAuthService kakaoAuthService;
private final JwtTokenProvider jwtTokenProvider;

// 카카오 로그인을 위해 회원가입 여부 확인, 이미 회원이면 Jwt 토큰 발급
@Operation(summary = "[로그인] 카카오로그인 및 토큰 발급", description = "카카오 로그인을 위해 회원가입 여부를 확인하고, 이미 회원이면 Jwt 토큰을 발급합니다.")
@PostMapping("/login")
public ApiResponse<HashMap<Long, String>> authCheck(@RequestHeader String accessToken) {
Long userId = kakaoAuthService.isSignedUp(accessToken); // 유저 고유번호 추출
HashMap<Long, String> map = new HashMap<>();
map.put(userId, jwtTokenProvider.createToken(userId.toString()));
return ApiResponse.success(map, ResponseCode.USER_LOGIN_SUCCESS.getMessage());
}

// 회원가입 (성공 시 Jwt 토큰 발급)
@Operation(summary = "[회원가입] 회원가입 및 토큰 발급", description = "신규 회원가입을 처리하고, 회원가입 성공 시 Jwt 토큰을 발급합니다.")
@PostMapping("/join")
public ApiResponse<HashMap<Long, String>> saveUser(CreateUserDto createUserDto) {
Long userId = userService.saveUser(createUserDto);
HashMap<Long, String> map = new HashMap<>();
map.put(userId, jwtTokenProvider.createToken(userId.toString()));
return ApiResponse.success(map, ResponseCode.USER_CREATE_SUCCESS.getMessage());
}

// 토큰 검증 (Jwt 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 True 혹은 예외 반환)
@Operation(summary = "[토큰 검증] 토큰 검증", description = "클라이언트가 가지고 있던 Jwt 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 OK 혹은 예외를 반환합니다.")
@GetMapping("/token")
public ApiResponse<Boolean> tokenCheck(@RequestHeader String jwtToken) {
return ApiResponse.success(jwtTokenProvider.validateToken(jwtToken), ResponseCode.TOKEN_CHECK_SUCCESS.getMessage());
}
}
9 changes: 9 additions & 0 deletions src/main/java/com/diareat/diareat/auth/dto/KakaoAccount.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.diareat.diareat.auth.dto;

import lombok.Getter;

@Getter
public class KakaoAccount {

private KakaoProfile profile;
}
12 changes: 12 additions & 0 deletions src/main/java/com/diareat/diareat/auth/dto/KakaoProfile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.diareat.diareat.auth.dto;

import lombok.Getter;

@Getter
public class KakaoProfile {

private String nickname;
private String profileImageUrl;
private String thumbnailImageUrl;
private boolean isDefaultImage;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.diareat.diareat.auth.dto;

import lombok.Getter;

@Getter
public class KakaoUserInfoResponse {

private Long id;
private boolean hasSignedUp;
private KakaoAccount kakaoAccount;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.diareat.diareat.auth.service;

import com.diareat.diareat.user.repository.UserRepository;
import com.diareat.diareat.util.api.ResponseCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {

private final UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String keyCode) throws UsernameNotFoundException {
return userRepository.findByKeyCode(keyCode)
.orElseThrow(() -> new UsernameNotFoundException(ResponseCode.USER_NOT_FOUND.getMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.diareat.diareat.auth.service;

import com.diareat.diareat.auth.component.KakaoUserInfo;
import com.diareat.diareat.auth.dto.KakaoUserInfoResponse;
import com.diareat.diareat.user.domain.User;
import com.diareat.diareat.user.repository.UserRepository;
import com.diareat.diareat.util.api.ResponseCode;
import com.diareat.diareat.util.exception.UserException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class KakaoAuthService { // 카카오 소셜로그인, 세션 관리는 추후 구현 예정

private final KakaoUserInfo kakaoUserInfo;
private final UserRepository userRepository;

@Transactional(readOnly = true)
public Long isSignedUp(String token) { // 클라이언트가 보낸 token을 이용해 카카오 API에 유저 정보를 요청, 유저가 존재하지 않으면 예외 발생, 존재하면 회원번호 반환
KakaoUserInfoResponse userInfo = kakaoUserInfo.getUserInfo(token);
User user = userRepository.findByKeyCode(userInfo.getId().toString()).orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND));
return user.getId();
}
}
39 changes: 39 additions & 0 deletions src/main/java/com/diareat/diareat/config/WebClientConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.diareat.diareat.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.http.client.reactive.ReactorResourceFactory;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

import java.time.Duration;
import java.util.function.Function;

@Configuration
public class WebClientConfig {

@Bean
public ReactorResourceFactory resourceFactory() {
ReactorResourceFactory factory = new ReactorResourceFactory();
factory.setUseGlobalResources(false);
return factory;
}

@Bean
public WebClient webClient() {
Function<HttpClient, HttpClient> mapper = client -> HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(10))
.addHandlerLast(new WriteTimeoutHandler(10)))
.responseTimeout(Duration.ofSeconds(1));

ClientHttpConnector connector =
new ReactorClientHttpConnector(resourceFactory(), mapper);
return WebClient.builder().clientConnector(connector).build();
}
}
36 changes: 36 additions & 0 deletions src/main/java/com/diareat/diareat/config/WebSecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.diareat.diareat.config;

import com.diareat.diareat.auth.component.JwtAuthFilter;
import com.diareat.diareat.auth.component.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig {

private final JwtTokenProvider jwtTokenProvider;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
//세션 사용 안함
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//URL 관리
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll() // 이 API 는 누구나 접근 가능
.anyRequest().authenticated()
.and()
// JwtAuthenticationFilter를 먼저 적용
.addFilterBefore(new JwtAuthFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}
Loading