티스토리 뷰

카카오는 구글과 동일한 과정을 거칩니다.

1. 클라이언트 쪽에서 로그인을 합니다.
2. 카카오 서버는 redirect url로 code를 전달해줍니다.
3,4. code를 이용하여 access_token을 발급받습니다.
5. access_token을 서버로 전송합니다.

——————————————————————————————————————

6,7. 서버에서는 받은 access_token을 이용하여 카카오 서버에서 사용자 정보를 받습니다.
8. 받은 사용자 정보를 이용하여 회원가입 또는 로그인을 진행합니다.
9. JWT등과 같이 사용자 식별 정보를 클라이언트로 보냅니다.


1~5는 프론트엔드에서 구현할 것이고, 백엔드는 6~9만 구현하면 됩니당!

 

저는 여기에 더 추가 조건이 있었습니다. 카카오 로그인을 하고 닉네임과 주소를 얻기 위해 추가정보를 얻는 과정이 필요하기 때문입니다! 

회원가입이 되어있으면 ⤵️ 이렇게 객체를 반환해주고, 

{
"statusCode": 200,
"message": "카카오 로그인을 했습니다",
"data": {
"accessToken": “string",
"refreshToken": "string",
"process": "로그인이 완료되었습니다"}
}

되어있지 않으면   ⤵️ 이렇게 객체를 반환해주려고 합니다.

{
"statusCode": 200,
"message": "카카오 로그인을 했습니다",
"data": {
"accessToken": “string",
"refreshToken": "string",
"process": "회원가입 중-추가 정보를 입력해주세요."}
}

 

UserController

@RestController
@AllArgsConstructor
@RequestMapping("/api/v1/users")
@Api(tags = "User API")
public class UserController {

    private UserService userService;

    @ApiOperation(value = "카카오 로그인", notes = "카카오 로그인을 합니다.")
    @PostMapping("/login")
    public ResponseEntity<ResponseDto<LoginResponse>> login(@Valid @RequestBody LoginRequest loginRequest){
        return ResponseEntity.ok(ResponseDto.create(HttpStatus.OK.value(),LOGIN_SUCCES.getMessage(),this.userService.login(loginRequest)));
    }

    @ApiOperation(value="추가 정보 입력", notes="추가 정보를 입력합니다.")
    @PostMapping("/additionalInfo")
    public ResponseEntity<ResponseDto<LoginResponse>> additionalInfo(@Valid @RequestBody AdditionInfoRequest additionInfoRequest){
        return ResponseEntity.ok(ResponseDto.create(HttpStatus.OK.value(), SIGN_UP_SUCCESS.getMessage(),this.userService.signup(additionInfoRequest)));
    }


}

 

 

User(entity)

@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long userId;

    private String email;

    @Lob
    private String imageUrl;

    private String gender;

    private String ageRange;

    private boolean isDeleted;

    // 추가정보
    private String nickName;

    private String address;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Builder
    public User(String email, String imageUrl, String gender, String ageRange, Role role){
        this.email=email;
        this.imageUrl=imageUrl;
        this.gender=gender;
        this.ageRange=ageRange;
        this.role=role;
    }

    public void setUser(String nickName, String address){
        this.nickName=nickName;
        this.address=address;
    }

    public void setDeleted(){
        this.email="withdrawal";
        this.isDeleted=true;
    }
}

UserDto

public abstract class UserDto {

    @Getter
    @AllArgsConstructor
    @Builder
    @ApiModel(description = "카카오 로그인을 위한 요청 객체")
    @NoArgsConstructor
    public static class LoginRequest {
        @NotBlank(message = "카카오 액세스 토큰을 입력해주세요.")
        @ApiModelProperty(notes = "카카오 accessToken을 주세요.")
        private String token;

        public void setToken(String token) {
            this.token = token;
        }
    }

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @ApiModel(description = "로그인을 위한 응답 객체")
    public static class LoginResponse {
        private String accessToken;
        private String refreshToken;
        private String process;

        public static LoginResponse from(TokenInfoResponse tokenInfoResponse, String process) {
            return LoginResponse.builder()
                    .accessToken(tokenInfoResponse.getAccessToken())
                    .refreshToken(tokenInfoResponse.getRefreshToken())
                    .process(process)
                    .build();
        }
    }
    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @ApiModel(description = "추가 정보 입력을 위한 요청 객체")
    public static class AdditionInfoRequest {
        @NotBlank(message = "자체 jwt 액세스 토큰을 입력해주세요.")
        @ApiModelProperty(notes = "자체 액세스 토큰을 입력해 주세요.")
        private String accessToken;

        @NotBlank(message = "닉네임을 입력해주세요.")
        @ApiModelProperty(notes = "닉네임을 입력해 주세요.")
        private String nickName;

        @NotBlank(message = "주소를 입력해주세요.")
        @ApiModelProperty(notes = "주소를 입력해주세요.")
        private String address;
    }

}

UserRepository

public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {

    Optional<User> findByNickName(String nickName);
}

UserRepsitoryCustom

public interface UserRepositoryCustom {
    Optional<User> findByEmail(String email);
}

UserRepositoryCustomImpl

public class UserRepositoryCustomImpl implements UserRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    public UserRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }
    @Override
    public Optional<User> findByEmail(String email) {
        return Optional.ofNullable(queryFactory.selectFrom(user)
                .where(user.email.eq(email),
                        user.isDeleted.eq(false))
                .fetchFirst());
    }
}

UserServiceImpl

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class UserServiceImpl implements UserService {
    private final TokenProvider tokenProvider;
    private final UserRepository userRepository;
    private final String LOGIN_URL = "https://kapi.kakao.com/v2/user/me";
    private final String DELETE_URL = "https://kapi.kakao.com/v1/user/unlink";
    private final String KAKAO_ACOUNT = "kakao_account";
    private final String VALID_NICKNAME="가능한 닉네임입니다";
    private final String EXISTED_NCIKNAME="이미 존재하는 닉네임입니다";

    @Override
    public LoginResponse login(UserDto.LoginRequest loginRequest) {
        //1. 프론트에게 받은 액세스 토큰 이용해서 사용자 정보 가져오기
        String token = loginRequest.getToken();
        JsonObject userInfo = connectKakao(LOGIN_URL, token);
        String email = getEmail(userInfo);
        String pictureUrl = getPictureUrl(userInfo);
        String gender = getGender(userInfo);
        String age_range = getAgeRange(userInfo);
        User user = saveUser(email, pictureUrl, gender, age_range);
        boolean isSignedUp =  user.getNickName() != null;

        //2. 스프링 시큐리티 처리
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(String.valueOf(ROLE_USER)));
        OAuth2User userDetails = createOAuth2UserByJson(authorities, userInfo, email);
        OAuth2AuthenticationToken auth = new OAuth2AuthenticationToken(userDetails, authorities, "email");
        auth.setDetails(userDetails);

        //3. JWT 토큰 생성
        TokenInfoResponse tokenInfoResponse = tokenProvider.createToken(auth, isSignedUp);
        String message = isSignedUp ? LOGIN_SUCCESS.getMessage() : SIGN_UP_ING.getMessage();
        SecurityContextHolder.getContext().setAuthentication(auth);
        return LoginResponse.from(tokenInfoResponse, message);
    }

    @Override
    public LoginResponse signup(UserDto.AdditionInfoRequest additionInfoRequest) {
        //추가 정보 입력 시
        //1. 프론트엔드에게 받은 (자체) 액세스 토큰 이용해서 사용자 이메일 가져오기
        Authentication authentication = tokenProvider.getAuthentication(additionInfoRequest.getAccessToken());
        User user = userRepository.findByEmail(authentication.getName()).get();
        //2. 추가 정보 저장
        user.setUser(additionInfoRequest.getNickName(), additionInfoRequest.getAddress());
        userRepository.save(user);
        //3. 스프링 시큐리티 처리
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(String.valueOf(ROLE_USER)));
        OAuth2User userDetails = createOAuth2UserByUser(authorities, user, additionInfoRequest);
        OAuth2AuthenticationToken auth = new OAuth2AuthenticationToken(userDetails, authorities, "email");
        auth.setDetails(userDetails);
        SecurityContextHolder.getContext().setAuthentication(auth);
        //4. JWT 토큰 생성
        TokenInfoResponse tokenInfoResponse = tokenProvider.createToken(auth, true);
        return LoginResponse.from(tokenInfoResponse, LOGIN_SUCCESS.getMessage());
    }


    @Override
    public User validateEmail(String email) {
        return this.userRepository.findByEmail(email).orElseThrow(NotHaveEmailException::new);
    }

    private OAuth2User createOAuth2UserByUser(List<GrantedAuthority> authorities, User user, UserDto.AdditionInfoRequest additionInfoRequest) {
        Map userMap = new HashMap<String, String>();
        userMap.put("email", user.getEmail());
        userMap.put("pictureUrl", user.getImageUrl());
        userMap.put("nickName", user.getNickName());
        userMap.put("address", user.getAddress());
        OAuth2User userDetails = new DefaultOAuth2User(authorities, userMap, "email");
        return userDetails;
    }

    private OAuth2User createOAuth2UserByJson(List<GrantedAuthority> authorities, JsonObject userInfo, String email) {
        Map userMap = new HashMap<String, String>();
        userMap.put("email", email);
        userMap.put("pictureUrl", getPictureUrl(userInfo));
        userMap.put("gender", getGender(userInfo));
        userMap.put("age_range", getAgeRange(userInfo));
        authorities.add(new SimpleGrantedAuthority(String.valueOf(ROLE_USER)));
        OAuth2User userDetails = new DefaultOAuth2User(authorities, userMap, "email");
        return userDetails;
    }
    private JsonObject connectKakao(String reqURL, String token) {
        try {
            URL url = new URL(reqURL);
            System.out.println(reqURL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();

            conn.setRequestMethod("POST");
            conn.setDoOutput(true);
            conn.setRequestProperty("Authorization", "Bearer " + token); //전송할 header 작성, access_token전송


            BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            String inputLine;

            StringBuffer response = new StringBuffer();
            while ((inputLine = in.readLine()) != null) {
                response.append(inputLine);
            }

            in.close();
            JsonObject json = JsonParser.parseString(response.toString()).getAsJsonObject();
            return json;
        } catch (IOException e) {
            throw new ConnException();
        }
    }

    private String getEmail(JsonObject userInfo) {
        if (userInfo.getAsJsonObject(KAKAO_ACOUNT).get("has_email").getAsBoolean()) {
            return userInfo.getAsJsonObject(KAKAO_ACOUNT).get("email").getAsString();
        }
        throw new NotHaveEmailException();
    }

    private String getPictureUrl(JsonObject userInfo) {
        return userInfo.getAsJsonObject("properties").get("profile_image").getAsString();
    }

    private String getGender(JsonObject userInfo) {
        if (userInfo.getAsJsonObject(KAKAO_ACOUNT).get("has_gender").getAsBoolean() &&
                !userInfo.getAsJsonObject(KAKAO_ACOUNT).get("gender_needs_agreement").getAsBoolean()) {
            return userInfo.getAsJsonObject(KAKAO_ACOUNT).get("gender").getAsString();
        }
        return "동의안함";
    }

    private String getAgeRange(JsonObject userInfo) {
        String KAKAO_ACOUNT = "kakao_account";
        if (userInfo.getAsJsonObject(KAKAO_ACOUNT).get("has_age_range").getAsBoolean() &&
                !userInfo.getAsJsonObject(KAKAO_ACOUNT).get("age_range_needs_agreement").getAsBoolean()) {
            return userInfo.getAsJsonObject(KAKAO_ACOUNT).get("age_range").getAsString();
        }
        return "동의안함";
    }

    private User saveUser(String email, String pictureUrl, String gender, String ageRange) {
        User user = new User(email, pictureUrl, gender, ageRange, ROLE_USER);
        if (!userRepository.findByEmail(email).isPresent()) {
            return userRepository.save(user);
        }
        return userRepository.findByEmail(email).get();
    }

}

⚡️ login

1. connectKakao()함수로 카카오 api와 연결해서 userInfo Json객체를 받아옵니다.

   getEmail(), getGender(), getAgeRange()함수로 각각의 컬럼값을 가져옵니다. 

   회원가입이 되어있는지 여부(isSignedUp)는 닉네임값이 null인지 여부입니다. 

 

2. 스프링 시큐리티 처리를 통해 OAuth2User 객체를 생성하고, 인증 토큰을 만들어 JWT 토큰을 발행합니다. 


3. JWT 토큰을 생성합니다. 

여기서 중요한 것은 JWT 토큰을 생성할 때 추가정보를 입력했는지 여부에 대한 정보도 포함한다는 것입니다. 
➡️ TokenInfoResponse tokenInfoResponse = tokenProvider.createToken(auth, isSignedUp);
이렇게 하면 추가 정보를 입력하지 않고 이탈한 사람이 다른 기능을 사용했을 때 단순히 JWT 오류라고 에러 반환 객체가 뜨지 않고, 추가 정보를 입력하지 않았다는 것을 명확하게 알려줄 수 있습니다.

 

✨ signUp 

1. 프론트엔드에게 받은 (자체) 액세스 토큰 이용해서 사용자 이메일을 가져옵니다.

 

2. 추가 정보를 db에 저장합니다.

 

3.  스프링 시큐리티 처리를 통해 OAuth2User 객체를 생성하고, 인증 토큰을 만들어 JWT 토큰을 발행합니다. 

 

4. 추가정보를 입력했기에 JWT 토큰을 새로 생성해줍니다. 
➡️ TokenInfoResponse tokenInfoResponse = tokenProvider.createToken(auth, true);

 

TokenProvider에서 createToken

    /**
     * 토큰 만드는 함수
     * @param authentication
     * @return TokenInfoResponse
     */
    public TokenInfoResponse createToken(Authentication authentication, boolean isAdditionalInfoProvided) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date accessTokenValidity = new Date(now + this.accessTokenValidityTime);
        Date refreshTokenValidity = new Date(now + this.refreshTokenValidityTime);

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .claim(ADDITIONAL_INFO, isAdditionalInfoProvided) // 추가 정보 입력 여부를 클레임에 추가
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(accessTokenValidity)
                .compact();

        String refreshToken = Jwts.builder()
                .setExpiration(refreshTokenValidity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenInfoResponse.from("Bearer", accessToken, refreshToken, refreshTokenValidityTime);

    }

JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, JwtException {
        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();
        try {
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                boolean isAdditionalInfoProvided = tokenProvider.getAdditionalInfoProvided(jwt);
                if (isAdditionalInfoProvided) {
                    Authentication authentication = tokenProvider.getAuthentication(jwt);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                    log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
                } else {
                    throw new AdditionalInfoException();
                }
            }
        }
        catch(AdditionalInfoException e){
            request.setAttribute("exception", JwtConstants.JWTExceptionList.ADDITIONAL_REQUIRED_TOKEN.getErrorCode());
        } catch (SecurityException | MalformedException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.MAL_FORMED_TOKEN.getErrorCode());
        } catch (ExpiredException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.EXPIRED_TOKEN.getErrorCode());
        } catch (UnsupportedException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.UNSUPPORTED_TOKEN.getErrorCode());
        } catch (IllegalException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.ILLEGAL_TOKEN.getErrorCode());
        } catch (Exception e) {
            log.error("================================================");
            log.error("JwtFilter - doFilterInternal() 오류발생");
            log.error("token : {}", jwt);
            log.error("Exception Message : {}", e.getMessage());
            log.error("Exception StackTrace : {");
            e.printStackTrace();
            log.error("}");
            log.error("================================================");
            request.setAttribute("exception", JwtConstants.JWTExceptionList.UNKNOWN_ERROR.getErrorCode());
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}