Techit 13th 2nd

Huisu·2023년 7월 11일
0

Techit

목록 보기
31/42
post-thumbnail

Devide & Conquer

종이 접기

1802번: 종이 접기

문제

동호는 종이를 접는데 옆에서 보고 접으려고 한다. 옆에서 본다는 말은 아래 그림과 같이 본다는 뜻이다. 동호는 종이를 반으로 접을 때, 아래와 같이 두가지중 하나로만 접을 수 있다.

  1. 오른쪽 반을 반시계 방향으로 접어서 왼쪽 반의 위로 접는다.
  2. 오른쪽 반을 시계 방향으로 접어서 왼쪽 반의 아래로 접는다.

아래의 그림은 위의 설명을 그림으로 옮긴 것이다.

https://www.acmicpc.net/JudgeOnline/upload/201006/pfold.png

한 번의 종이 접기가 끝났을 때, 동호는 종이 접기를 원하는 만큼 더 할 수 있다. 종이 접기를 한번 접을 때 마다 두께는 2배가 되고 길이는 절반이 될 것이다.

https://www.acmicpc.net/JudgeOnline/upload/201006/pfoldd.png

종이 접기를 여러 번 했을 때 (안접을 수도 있다), 동호는 종이를 다시 피기로 했다. 그러고 나서 다시 접고 이렇게 놀고 있었다. 옆에서 보고 있던 원룡이는 동호를 위해 종이를 접어서 주기로 했다.(원룡이는 동호의 규칙대로 접지 않는다.) 동호는 그리고 나서 원룡이가 접었다 핀 종이를 다시 동호의 규칙대로 접을 수 있는지 궁금해졌다.

위의 저 종이를 접었다 피면 다음과 같은 그림처럼 펴진다.

https://www.acmicpc.net/JudgeOnline/upload/201006/pfofo.png

종이가 시계방향으로 꺽여있으면 OUT이고, 반시계방향으로 꺾여있으면 IN이다.

종이가 접혀있는 정보가 왼쪽부터 오른쪽까지 차례대로 주어졌을 때, 이 종이를 동호의 규칙대로 접을 수 있는지 없는지를 구하는 프로그램을 작성하시오.

입력

첫째 줄에 테스트 케이스의 개수 T가 주어진다. T는 1000보다 작거나 같은 자연수이다. 둘째 줄부터 T개의 줄에 각각의 종이가 어떻게 접혀있는지가 주어진다. 종이의 정보는 문자열로 주어지며, 1은 위의 그림에서 OUT을 의미하고 0은 위의 그림에서 IN을 의미한다. 예를 들어, 위의 그림과 같은 모양은 100으로 나타낼 수 있다. 문자열의 길이는 3000보다 작으며, 항상 2N-1꼴이다. (N ≥ 1)

출력

T개의 줄에 차례대로 각각의 종이를 동호의 방법대로 다시 접을 수 있으면 YES를, 접을 수 없으면 NO를 출력한다.

예제 입력 1

3
0
000
1000110

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

// https://www.acmicpc.net/problem/1802
public class one1802 {
    public void solution() throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
        int tests = Integer.parseInt(reader.readLine());
        for (int i = 0; i < tests; i++) {
            if (foldable(reader.readLine())) System.out.println("YES");
            else System.out.println("NO");
        }
    }
    
    // 종이의 굴곡이 0과 1로 문자열로 주어진다.
    // 1000110
    private boolean foldable(String paper) {
        // assert paper.length() % 2 == 1;
        // 굴곡이 하나라면 확인할 필요가 없다. 반 접었으니
        if (paper.length() > 1) {
            // 절반 지점
            int half = paper.length() / 2;
            // 왼쪽 종이와 오른쪽 종이가 조건을 만족하는지 검사한다.
            // 조건이 만족되지 않으면 불가능
            if (!foldable(paper.substring(0, half))) return false;
            if (!foldable(paper.substring(half + 1))) return false;
            // 작은 부분들이 만족스러웠으면,
            // 현재 크기에서 서로 좌우 역대칭이 되는지 확인한다.
            for (int i = 1; i < half + 1; i++) {
                // 중간 지점에서 i만큼 + 또는 - 한 위치의 굴곡을 확인한다.
                // 굴곡의 모양이 일치하는 경우 조건이 만족되지 않는다.
                if (paper.charAt(half + i) == paper.charAt(half - i))
                    return false;
            }
        }
        return true;
    }

    public static void main(String[] args) throws IOException {
        new one1802().solution();
    }
}

쿼드트리

1992번: 쿼드트리

문제

흑백 영상을 압축하여 표현하는 데이터 구조로 쿼드 트리(Quad Tree)라는 방법이 있다. 흰 점을 나타내는 0과 검은 점을 나타내는 1로만 이루어진 영상(2차원 배열)에서 같은 숫자의 점들이 한 곳에 많이 몰려있으면, 쿼드 트리에서는 이를 압축하여 간단히 표현할 수 있다.

주어진 영상이 모두 0으로만 되어 있으면 압축 결과는 "0"이 되고, 모두 1로만 되어 있으면 압축 결과는 "1"이 된다. 만약 0과 1이 섞여 있으면 전체를 한 번에 나타내지를 못하고, 왼쪽 위, 오른쪽 위, 왼쪽 아래, 오른쪽 아래, 이렇게 4개의 영상으로 나누어 압축하게 되며, 이 4개의 영역을 압축한 결과를 차례대로 괄호 안에 묶어서 표현한다

https://www.acmicpc.net/JudgeOnline/upload/201007/qq.png

위 그림에서 왼쪽의 영상은 오른쪽의 배열과 같이 숫자로 주어지며, 이 영상을 쿼드 트리 구조를 이용하여 압축하면 "(0(0011)(0(0111)01)1)"로 표현된다. N ×N 크기의 영상이 주어질 때, 이 영상을 압축한 결과를 출력하는 프로그램을 작성하시오.

입력

첫째 줄에는 영상의 크기를 나타내는 숫자 N 이 주어진다. N 은 언제나 2의 제곱수로 주어지며, 1 ≤ N ≤ 64의 범위를 가진다. 두 번째 줄부터는 길이 N의 문자열이 N개 들어온다. 각 문자열은 0 또는 1의 숫자로 이루어져 있으며, 영상의 각 점들을 나타낸다.

출력

영상을 압축한 결과를 출력한다.

예제 입력 1

8
11110000
11110000
00011100
00011100
11110000
11110000
11110011
11110011

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

// https://www.acmicpc.net/problem/1992
public class one1992 {
    // 입력에 대한 정보를 클래스 필드로 저장
    private char[][] image;
    private StringBuilder quadTreeBuilder;
    private void solution() throws IOException {
        BufferedReader readaer = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(readaer.readLine());
        image = new char[n][];
        for (int i = 0; i < n; i++) {
            // String.toCharArray()를 사용하면 문자열을 char[]로 변환 가능
            image[i] = readaer.readLine().toCharArray();
        }
        quadTreeBuilder = new StringBuilder();
        compressQuad(n, 0, 0);
        System.out.println(quadTreeBuilder.toString());
    }

    private void compressQuad(
            int n, // 정사각형 한 변의 길이
            int x, // 정사각형의 왼쪽 끝 index
            int y // 정사각형의 위쪽 끝 index
    ) {
        // 조건을 만족했는지 검사하는 flag
        boolean success = true;
        // 모든 요소가 같지 않을 경우 success = false

        // x, y의 값을 저장해 두고
        // x -> x + n-1, y -> y + n - 1 까지 반복하면서
        // 초기의 값과 달라지는지 검사
        char init = image[x][y];
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if(image[x + i][y + j] != init) {
                    success = false;
                    break;
                }
            }
            if (!success) break;
        }
        // 원소들 검사 후 success = false라면 쪼개서 재귀 호출
        if(!success) {
            // 좌 괄호 입력
            quadTreeBuilder.append('(');
            // 4등분을 위한 half
            int half = n / 2;
            for (int i = 0; i < 2; i++) {
                for (int j = 0; j < 2; j++) {
                    compressQuad(half, x + half * i, y + half * j);
                }
            }
            // 4등분 영상이 압축이 끝나면
            quadTreeBuilder.append(')');
        }
        else {
            // 모든 원소가 일치했다면 첫 번째 검사한 원소를 입력
            quadTreeBuilder.append(init);
        }
    }

    public static void main(String[] args) throws IOException {
        new one1992().solution();
    }
}

QuickSort

병합 정렬과 같이 분할 정복 기법을 이용한 정렬 알고리즘이다. 이름과는 다르게 최악의 경우 O(N2)O(N^2)의 시간 복잡도를 가진다. 일반적으로는 O(nlogN)O(nlogN)의 시간 복잡도를 가진다. 정렬되지 않은 배열이 있을 때 배열의 원소 중 하나를 Pivot으로 설정하고, 배열의 원소들을 각각 Pivot들과 비교한다. Pivot보다 작은 원소는 왼쪽, Pivot보다 큰 원수는 오른쪽에 위치시키며 진행된다. 모든 원소를다 검사하면 Pivot은 정렬된 위치에 위치하게 된다. 이후 Pivot을 기준으로 왼쪽과 오른쪽을 나누어서 반복한다. Pivot으로 항상 오른쪽 끝 원소를 선택한다고 가정한 뒤 진행한다.

import java.util.Arrays;

public class QuickSort {
    public void sort(int[] arr) {
        // 비었거나 길이가 1 이하라면 정렬할 필요 없다
        if (arr == null || arr.length <= 1) return;
        quickSort(arr, 0, arr.length - 1);
    }
    private void quickSort(int[] arr, int low, int high) {
        // low == high인 경우 이미 끝까지 다 정렬 완료
        if (low < high) {
            // quickSort후 나누어진 index 반환
            int pivot = partition(arr, low, high);

            // 해당 index를 기준으로 좌우에 대하여 다시
            // quickSort 호출
            quickSort(arr, low, pivot - 1);
            quickSort(arr, pivot + 1, high);
        }
    }
    // pivot을 정하고 pivot이 최종적으로 위치하는 곳을 반환
    // pivot을 기준으로 좌우 배열의 원소들을 배열한 뒤 자신의 위치 반환
    private int partition(int[] arr, int low, int high) {
        // 오른쪽 끝이 pivot
        int pivot = arr[high];
        // 작은 원소가 들어갈 위치를 지정하는 i
        int i = low - 1;
        // j == low부터 high - 1까지 반복 (pivot 제외 전부 대조)
        for (int j = low; j < high; j++) {
            // 현재 원소의 값이 pivot보다 작은 경우
            if (arr[j] <= pivot) {
                i++;
                // 왼쪽 끝으로 보낸다
                int temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }
        // 이 과정이 끝나면 arr[i]에는 pivot보다 작은 원소가
        // i + 1 ~ high - 1의 원소는 pivot보다 큰 원소가 담긴다
        int temp = arr[i + 1];
        arr[i + 1] = arr[high];
        arr[high] = temp;

        // 마지막으로 pivot의 위치를 반환
        return i + 1;
    }

    public static void main(String[] args) {
        int[] arr = {9, 3, 1, 7, 4, 8, 6, 2, 5};
        new QuickSort().sort(arr);
        System.out.println(Arrays.toString(arr));
    }
}

이미 정렬된 원소의 나열에서 어떤 특정 원소를 찾기 위해 검색 범위를 절반으로 줄여 나가는 검색 알고리즘이다. 가운데 위치를 고르고 찾는 원소인지 비교하고 찾는 원소와 일치하면 검색이 성공했다고 한다. 검색할 값이 더 작다면 왼쪽 절반을 다음 검색 대상으로, 검색할 값이 더 크다면 오른쪽 절반을 다음 검색 대상으로 선정한다.

public class BinarySearchAlgorithm {
    public int binarySearch(int[] arr, int target) {
        // 검색 범위를 한정하는 left와 right
        int left = 0;
        int right = arr.length - 1;

        // 왼쪽 인덱스가 오른쪽보다 커지면 실패
        while(left <= right) {
            int mid = left + (right - left) / 2;

            if (arr[mid] == target) return mid;
            if(arr[mid] > target) right = mid - 1;
            if(arr[mid] < target) left = mid + 1;
        }
        return -1;
    }

    public static void main(String[] args) {
        int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9};
        int target = 2;
        int index = new BinarySearchAlgorithm().binarySearch(arr, target);

        if (index != -1) {
            System.out.println(index);
        } else {
            System.out.println("탐색 실패");
        }
    }
}

Spring Security

OAuth2

요즈음 서비스에는 소셜 로그인 기능이 널리 보급화되어 있다. 서비스의 회원 가입 과정을 진행할 필요 없이, 제3의 서비스에 등록된 회원 정보를 활용하여 서비스에 로그인하는 기능이다. 이 기능은 OAuth2 (Open Authorization)을 통해 진행된다 OAuth2는 다른 서비스의 사용자 정보를 안전하게 위임받기 위한 표준이지 로그인을 대신해 주는 기술이 아니다. 사용자가 어떤 서비스에 소셜 로그인을 진행하고 싶을 때 해당 서비스에 직접 인증 정보를 주지 않아도 나의 정보를 조회할 수 있도록 권한을 위임하는 기술이다.

OAuth2의 흐름은 다음과 같다.

  1. 사용자가 로그인이 필요한 서비스 요청
  2. 사용자가 소셜 로그인 제공자 선택
  3. 사용자가 소셜 로그인 화면으로 redirect
  4. 제공자(소셜 서비스)인증 화면에 사용자가 인증 정보 전달
  5. 정상적인 인증 정보일 경우 access token 발급하여 미리 설정된 URL로 전달
  6. access token을 사용하여 제공자의 자원 서버로 전달
  7. 접속 요청 사용자의 정보 전달
  8. 사용자의 정보를 기반으로 정보 전달

여기서 주목할 부분은 OAuth가 사용자의 정보를 제공해 주는 것이지, 실제로 서비스에 로그인을 하는 등의 기능을 만드는 것은 아니다. 그래서 전달받은 사용자 정보를 바탕으로 사용자 인증 정보를 우리가 만드는 서비스에 맞게 조절하는 과정이 필요하며, 만약 사용자가 저희 서비스에 기록을 남길 수 있고, 데이터베이스 관계를 설정하고 싶다면 넘겨받은 정보를 바탕으로 사용자 데이터 또한 수동으로 작성해 주어야 합니다.

OAuth 과정은 HTTP 통신과 Redirect를 활용해서 만들어지게 된다. 상기 과정에서, 3. 사용자가 선택한 소셜 로그인 화면으로 redirect 하는 과정에서 사용자가 성공적으로 인증을 마무리 했을 경우 다시 돌아올 서비스의 URL(그림에서 지정된 callback url)을 제공하게 되며, 서비스 제공자 측에서 로그인을 마치고 나면 해당 URL에 사용자 정보를 조회하기 위한 access token을 발급받기 위한 정보를 포함해서 Redirect 해 주게 된다. 그러면 개발자는 Redirect를 통해 들어온 정보를 이용해 서비스 제공자 서버로 데이터 조회 요청을 보낸다.

아래 사이트에서 사용할 api를 선택한 뒤 애플리케이션 등록을 진행해 준다.

애플리케이션 - NAVER Developers

이때 네이버 로그인 Callback URL은 localhost:8080/login/oauth2/code/naver 고정이다.

개발 중이라고 떠 있을 때는 멤버 관리에서 멤버를 등록한 네이버 계정만 테스트해 볼 수 있다. 관리자는 디폴트로 등록되어 있다. 이후 build.gradle에 oauth 관련된 의존성을 추가해 준다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

Application.yaml

  1. 서비스 제공자에 대한 정보와 2. 서비스 제공자를 사용하기 위한 정보를 작성해야 한다.

서비스 제공자가 제공하는 endpointredirect할 로그인 페이지가 어디인지, access token을 발급받는 url이 어디인지, access을 제공하여 사용자 정보를 회수할 url이 어디인지를 설정해 줘야 한다.

	security:
    oauth2:
      client:
        # 1. 서비스 제공자에 대한 정보
        # redirect할 로그인 페이지가 어디인지
        # access token을 발급받는 URL이 어디인지
        # access token을 제공하여 사용자 정보를 회수할 URL은 어디인지
        provieder:
          # 서비스 제공자 식별자
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
  • authorization-uri : 사용자를 redirect 하기위한 URL을 작성한다.
  • token-uri : 사용자 정보 요청을 위한 Access Token을 받기 위한 URL을 작성한다.
  • user-info-uri : 사용자 정보를 조회하기 위한 URL을 작성한다.
  • user-name-attribute : 서비스 제공자로부터 받은 사용자 정보 중 어떤 부분을 활용하는 지를 작성한다. 내부적으로 활용되기 보다는, 데이터 처리 과정에서 동적으로 데이터를 선별하기 위해 사용되는 편이다.

서비스 제공자를 사용하기 위한 정보를 살펴 보자. client ID가 필요하고 리다이렉트한 url이 어디로 들어올 것인지도 작성해 줘야 한다. 즉 클라이언트 (우리 서버)를 식별하기 위한 정보를 기입해 줘야 한다.

	security:
    oauth2:
      client:
        # 1. 서비스 제공자에 대한 정보
        # redirect할 로그인 페이지가 어디인지
        # access token을 발급받는 URL이 어디인지
        # access token을 제공하여 사용자 정보를 회수할 URL은 어디인지
        provieder:
          # 서비스 제공자 식별자
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
        # 2. 서비스 제공자를 사용하기 위한 정보
        # 클라이언트(즉 우리 서버)를 식별하기 위한 정보
        registration:
          # 서비스 제공자 식별자
          naver:
            client-id: TOK0CgDk6yo9qKv8bY32
            client-secret: 8y9wNKgHaG
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            client-name: Naver
            scope:
              - nickname
              - email
              - profile_image

OAuth2UserServiceImpl

oath를 이용한 로그인 기능을 구현하기 위해 oauth 패키지 안에 JpaUserDetailManager과 비슷한 기능을 하는 OAuth2UserServiceImpl 클래스를 만들어 준다. DefaultOAuth2UserService의 구현체로 만들고 override 메소드를 구현해 준다.

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        
    }
}

전달받은 데이터 중 저희가 원하는 데이터를 활용하게끔 구현하기 위해 super.loadUser() 를 호출한 뒤 그 결과를 활용한다. 사용할 데이터를 불러와서 다시 정리하기 위해 Map 을 사용한다. 예를 들자면 네이버가 제공자이다와 같은 정보를 (”provider”, “naver”)로 저장하는 것이다. 이후 소셜로그인에서 제공하는 api 문서들을 잘 활용해 사용할 데이터를 잘 정리해서 진행한다.

@Slf4j
@Service
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        // TODO 사용할 데이터 재정의
        Map<String, Object> attributes = new HashMap<>();
        attributes.put("provider", "naver");

        // 받은 사용자 데이터를 정리
        Map<String, Object> responseMap = oAuth2User.getAttribute("response");
        attributes.put("id", responseMap.get("id"));
        attributes.put("email", responseMap.get("email"));
        attributes.put("nickname", responseMap.get("nickname"));
        String nameAttribute = "email";
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("USER")),
                attributes,
                nameAttribute
        );
    }
}

OAuth2SuccessHandler

실제로 반환받은 OAuth2User 객체를 받은 뒤 어떻게 동작할지를 정의하는 OAuth2SuccessHandler를 구현한다. OAuth2 통신이 성공적으로 끝났을 때 사용하는 클래스이다. JWT 토큰 방식으로 인증을 하고 있기 때문에, JwtTokenUtils 를 활용해 JWT를 생성하도록 한다. 왜냐하면 이 사람이 소셜 로그인으로 로그인을 해 왔을 경우 소셜로그인을 통한 JWT가 있다고 판단되어야 우리의 프로젝트에서는 토큰 인증이 완료된 사용자라고 여기기 때문이다. 발급된 JWT는 리다이렉트를 바탕으로 정보를 제공한다. 따라서 **JWT를 발급하고 클라이언트가 저장할 수 있도록 특정 URL로 Redirect 시키자는 것이다**.이후 이 JWT를 필요로하는 클라이언트한테 전달한다고 가정하고, JWT의 정보를 그대로 반환하는 엔드포인트로 Redirect 하도록 작성한다. 이때 Redirect 과정을 간소화 하기 위해 SimpleUrlAuthenticationSuccessHandler 를 사용할 수 있다.

@Slf4j
@Component
//OAuth2 통신이 성공적으로 끝났을 떄 사용하는 클래스
// JWT를 사용하고 있기 때문에
// ID Provider에게 받은 정보를 바탕으로 JWT를 발급하는 역할을 하는 용도
public class OAuth2SuccessHandler
    // 인증 성공 후 특정 URL로 리다이렉트 시키고 싶을 때 활용할 수 있는
    // success Handler
        extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtTokenUtils tokenUtils;

    public OAuth2SuccessHandler(JwtTokenUtils tokenUtils) {
        this.tokenUtils = tokenUtils;
    }
    // 인증 성공 시 호출되는 메소드
    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        OAuth2User oAuth2User =
                // OAuth2UserServiceImpl 에서 반환한 DefaultOAuth2User 저장
                (OAuth2User) authentication.getPrincipal();

        // JWT 발급
        String jwt = tokenUtils
                .generateToken(User.withUsername(oAuth2User.getName())
                        .password(oAuth2User.getAttribute("id"))
                        .build());
        
        // 목적지 url 설정
        // 우리 서비스의 front-end 구성에 따라 유연하게 대처해야 함
        String targetUrl = String.format(
                "http://localhost:8080/token/val?token=$s", jwt
        );
        
        // 실제 redirect 응답 생성
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

리다이렉트에 관한 부분은 TokenController에 추가한다.

@GetMapping("/val")
public Claims val(@RequestParam("token") String jwt) {
    return jwtTokenUtils.parseClaims(jwt);
}

WebSecurityConfig

이런 구성을 마무리 했다면, 이제 WebSecurityConfig 를 통해 OAuth2UserServiceImplOAuth2SuccessHandler 를 구성한다. 먼저 두 Bean 객체를 가져온다.

@Configuration
public class WebSecurityConfig {
    private final JwtTokenFilter jwtTokenFilter;
    **private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final OAuth2UserServiceImpl oAuth2UserService;**

    public WebSecurityConfig(
            JwtTokenFilter jwtTokenFilter,
            **OAuth2SuccessHandler oAuth2SuccessHandler,
            OAuth2UserServiceImpl oAuth2UserService**) {
        this.jwtTokenFilter = jwtTokenFilter;
        **this.oAuth2SuccessHandler = oAuth2SuccessHandler;
        this.oAuth2UserService = oAuth2UserService;**
    }

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    )
        throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authHttp -> authHttp
                        .requestMatchers("/token/**", "/views/**")
                        .permitAll()
                )
                **.oauth2Login(oauth2Login -> oauth2Login
                        .loginPage("/views/login") // oauth에서 자동으로 만든 로그인 페이지도 제공함
                        .successHandler(oAuth2SuccessHandler)
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(oAuth2UserService))
                )**
                .sessionManagement(
                        sessionManagement -> sessionManagement
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtTokenFilter, AuthorizationFilter.class);

        return http.build();
    }
    @Bean
    // 비밀번호 암호화를 위한 Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

이후 사이트를 열어 네이버 로그인을 진행해 보면 JWT Token 발급까지 완료된 것을 확인할 수 있다.

데이터베이스에 저장하기

단순 아이디 비밀번호 외에 소셜 로그인을 통해 계정을 생성한 뒤 서비스에 따라 데이터베이스에 저장하고 관리할 수도 있다. 이를 위해 소셜로그인 제공자와 소셜로그인에서 사용자를 식별하기 위해 제공한 값을 UserEntity에 추가해 준다.

@Entity
@Table(name = "users")
@Data
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // DB 제약사항 추가
    @Column(nullable = false, unique = true)
    private String username;
    private String password;

    private String email;
    private String phone;
    **private String provider;
    private String providerId;**
}

이후 CustomUserDetail도 수정해 준다.

@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {
    @Getter
    private Long id;
    private String username;
    private String password;
    @Getter
    private String email;
    @Getter
    private String phone;
    @Getter
    private String provider;
    @Getter
    private String providerId;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    public static CustomUserDetails fromEntity(UserEntity entity) {
        return CustomUserDetails.builder()
                .id(entity.getId())
                .username(entity.getUsername())
                .password(entity.getPassword())
                .email(entity.getEmail())
                .phone(entity.getPhone())
                **.provider(entity.getProvider())
                .providerId(entity.getProviderId())**
                .build();
    }

    public UserEntity newEntity() {
        UserEntity entity = new UserEntity();
        entity.setUsername(username);
        entity.setPassword(password);
        entity.setEmail(email);
        entity.setPhone(phone);
        **entity.setProvider(provider);
        entity.setProviderId(providerId);**
        return entity;
    }

    @Override
    public String toString() {
        return "CustomUserDetails{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='[PROTECTED]'" +
                ", email='" + email + '\'' +
                ", phone='" + phone + '\'' +
                '}';
    }
}

이후 OAuth2SuccessHandler를 수정해 준다. 이 과정을 통해 소셜 로그인을 한 새로운 사용자를 우리의 UserEntity로 전환하기 위한 작업이다.

@Slf4j
@Component
// OAuth2 통신이 성공적으로 끝났을 때, 사용하는 클래스
// JWT를 활용한 인증 구현하고 있기 때문에
// ID Provider에게 받은 정보를 바탕으로 JWT를 발급하는 역할을 하는 용도
// JWT를 발급하고 클라이언트가 저장할 수 있도록 특정 URL로 리다이렉트 시키자.
public class OAuth2SuccessHandler
        // 인증 성공 후 특정 URL로 리다이렉트 시키고 싶을 때 활용할 수 있는
        // successHandler
        extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtTokenUtils tokenUtils;
    private final UserDetailsManager userDetailsManager;

    public OAuth2SuccessHandler(JwtTokenUtils tokenUtils, UserDetailsManager userDetailsManager) {
        this.tokenUtils = tokenUtils;
        this.userDetailsManager = userDetailsManager;
    }

    @Override
    // 인증 성공시 호출되는 메소드
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        // OAuth2UserServiceImpl에서 반환한 DefaultOAuth2User
        // 가 저장된다.
        OAuth2User oAuth2User
                = (OAuth2User) authentication.getPrincipal();

        // email을 @ 기준으로 나누고 뒤쪽에 ID Provider (Naver) 같은 값으로 조치
        **String email = oAuth2User.getAttribute("email");
        String provider = oAuth2User.getAttribute("provider");
        String username = String.format("{%s}%s", provider, email.split("@")[0]);
        String providerId = oAuth2User.getAttribute("id").toString();**

        /*
        // JWT 생성
        String jwt = tokenUtils
                .generateToken(User
                        .withUsername(oAuth2User.getName())
                        .password(oAuth2User.getAttribute("id").toString())
                        .build());

         */

        **String jwt = tokenUtils
                .generateToken(CustomUserDetails.builder()
                        .username(username)
                        .password(providerId)
                        .email(email)
                        .provider(provider)
                        .providerId(providerId)
                        .build());**
        // 목적지 URL 설정
        // 우리 서비스의 Frontend 구성에 따라 유연하게 대처해야 한다.
        String targetUrl = String.format(
                "http://localhost:8080/token/val?token=%s", jwt
        );
        // 실제 Redirect 응답 생성
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

이후 같은 이름을 가진 사용자가 없다면 새로운 유저를 만드는 방법으로 코드를 수정해 보자.

@Slf4j
@Component
// OAuth2 통신이 성공적으로 끝났을 때, 사용하는 클래스
// JWT를 활용한 인증 구현하고 있기 때문에
// ID Provider에게 받은 정보를 바탕으로 JWT를 발급하는 역할을 하는 용도
// JWT를 발급하고 클라이언트가 저장할 수 있도록 특정 URL로 리다이렉트 시키자.
public class OAuth2SuccessHandler
        // 인증 성공 후 특정 URL로 리다이렉트 시키고 싶을 때 활용할 수 있는
        // successHandler
        extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtTokenUtils tokenUtils;
    private final UserDetailsManager userDetailsManager;

    public OAuth2SuccessHandler(JwtTokenUtils tokenUtils, UserDetailsManager userDetailsManager) {
        this.tokenUtils = tokenUtils;
        this.userDetailsManager = userDetailsManager;
    }

    @Override
    // 인증 성공시 호출되는 메소드
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        // OAuth2UserServiceImpl에서 반환한 DefaultOAuth2User
        // 가 저장된다.
        OAuth2User oAuth2User
                = (OAuth2User) authentication.getPrincipal();

        // email을 @ 기준으로 나누고 뒤쪽에 ID Provider (Naver) 같은 값으로 조치
        String email = oAuth2User.getAttribute("email");
        String provider = oAuth2User.getAttribute("provider");
        String username = String.format("{%s}%s", provider, email.split("@")[0]);
        String providerId = oAuth2User.getAttribute("id").toString();

        **if(!userDetailsManager.userExists(username)) {
            userDetailsManager.createUser(CustomUserDetails.builder()
                    .username(username)
                    .password(providerId)
                    .email(email)
                    .provider(provider)
                    .providerId(providerId)
                    .build());
        }**

        /*
        // JWT 생성
        String jwt = tokenUtils
                .generateToken(User
                        .withUsername(oAuth2User.getName())
                        .password(oAuth2User.getAttribute("id").toString())
                        .build());

         */

        **UserDetails details = userDetailsManager.loadUserByUsername(username);
        String jwt = tokenUtils.generateToken(details);**
        // 목적지 URL 설정
        // 우리 서비스의 Frontend 구성에 따라 유연하게 대처해야 한다.
        String targetUrl = String.format(
                "http://localhost:8080/token/val?token=%s", jwt
        );
        // 실제 Redirect 응답 생성
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

이럴 경우 다음과 같은 오류가 뜬다.

세 개의 Bin들의 의존성이 서로를 바라보고 있는 Circular Dependency 문제가 발생한 것이다. 의존성 연결을 끊어 주기 위해 PasswordEncoderConfig를 따로 빼 준다.

@Configuration
public class PasswordEncoderConfig {
    @Bean
    // 비밀번호 암호화를 위한 Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

이후 WebSecurityConfig에서는 PasswordEncoder Bean을 삭제해 준다. 이후 다시 실행한 뒤 네이버 로그인을 진행하면 잘 뜨는 것을 확인할 수 있다.

![]

KAKAO Login

Kakao Developers

이후 Cliend ID를 앱 키에서 볼 수 있고, Client Secret을 보안 탭에서 알 수 있다. 이후 플랫폼을 등록한다.

이후 Redirect URL를 등록한다.

이후 보안에서 Secret code를 만들어 준다.

이후 application.yaml에 카카오를 추가해 준다.

spring:
  datasource:
    url: jdbc:sqlite:db.sqlite
    driver-class-name: org.sqlite.JDBC
    username: sa
    password: password

  jpa:
    hibernate:
      ddl-auto: create
    database-platform: org.hibernate.community.dialect.SQLiteDialect
    show-sql: true

  security:
    oauth2:
      client:
        # 1. 서비스 제공자에 대한 정보
        # redirect할 로그인 페이지가 어디인지
        # access token을 발급받는 URL이 어디인지
        # access token을 제공하여 사용자 정보를 회수할 URL은 어디인지
        provider:
          # 서비스 제공자 식별자
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
          **kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id**

        # 2. 서비스 제공자를 사용하기 위한 정보
        # 클라이언트(즉 우리 서버)를 식별하기 위한 정보
        registration:
          # 서비스 제공자 식별자
          naver:
            client-id: TOK0CgDk6yo9qKv8bY32
            client-secret: 8y9wNKgHaG
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            client-name: Naver
            scope:
              - nickname
              - email
              - profile_image
          **kakao:
            client-id: 9eb02691b086a99e24d5f10a2da0451b
            client-secret: vgRfIBs8P3FVaFR5Hjvl3ZNn4Bu8Gqqq
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            client-name: Kakao
            scope:
              - profile_nickname
              - account_email
              - profile_image**

이후 앱을 실행하면 카카오 로그인 화면까지는 가지만 등록되지는 않는다. 그 이유는 우리가 구현한 코드가 네이버 전용 로직이기 때문이다. 따라서 카카오의 토큰 응답에 따라 다시 카카오 전용 로직을 구현해야 한다.

@Slf4j
@Service
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        
        **// applicaton.yaml의 providerID가 나온다
        String registrationId = userRequest.getClientRegistration().getRegistrationId();**

        // 사용할 데이터를 다시 정리하는 목적의 Map
        Map<String, Object> attributes = new HashMap<>();
        String nameAttribute = null;
        
        **// TODO Naver
        if (registrationId.equals("naver")) {**
            attributes.put("provider", "naver");
            // 받은 사용자 데이터를 정리한다.
            Map<String, Object> responseMap = oAuth2User.getAttribute("response");
            attributes.put("id", responseMap.get("id"));
            attributes.put("email", responseMap.get("email"));
            attributes.put("nickname", responseMap.get("nickname"));
            nameAttribute = "email";
        }

				**// TODO KAKAO
        if (registrationId.equals("kakao"))** {
            attributes.put("provider", "kakao");
            // 받은 사용자 데이터를 정리한다.
            attributes.put("provider", "kakao");
            attributes.put("id", oAuth2User.getAttribute("id"));
            Map<String, Object> propMap
                    = oAuth2User.getAttribute("properties");
            attributes.put("nickname", propMap.get("nickname"));
            Map<String, Object> accountMap
                    = oAuth2User.getAttribute("kakao_account");
            attributes.put("email", accountMap.get("email"));
            nameAttribute = "email";
        }
        // 기본설정으로는 여기까지 오면 인증 성
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("USER")),
                attributes,
                nameAttribute
        );
    }
}

0개의 댓글