OAuth를 이용한 자동로그인

merci·2023년 3월 14일
0

초기 설정

새로운 프로젝트 생성
의존성 추가

	testImplementation group: 'org.mybatis.spring.boot', name: 'mybatis-spring-boot-starter-test', version: '2.2.2'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'  
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.0'
	implementation 'org.springframework.boot:spring-boot-starter-mustache'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

yml 설정

server:
  port: 8080
  servlet:
    context-path: /
    encoding:
      force: true
      charset: utf-8

spring:
  datasource:
    url: jdbc:h2:mem:test;MODE=MySQL
    driver-class-name: org.h2.Driver
    username: sa
    password: 
  mvc:
    view:
      prefix: /WEB-INF/view/
      suffix: .jsp    
  sql:
    init:
      schema-locations:
      - classpath:db/table.sql
      data-locations:
      - classpath:db/data.sql
  h2:
    console:
      enabled: true
  output:
    ansi:
      enabled: always
  jackson:
    property-naming-strategy: com.fasterxml.jackson.databind.PropertyNamingStrategy.SnakeCaseStrategy
  
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true

mybatis:
  mapper-locations:
    - classpath:mapper/**.xml
  configuration:
    map-underscore-to-camel-case: true

기능만 테스트하기 위한 틀만 있는 폼을 만든다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>LoginPage</title>
</head>
<body>
    <h1>로그인 페이지</h1>
    <hr>
    <a href="#">카카오 로그인</a>
</body>
</html>



연습하기

카카오 인가 코드 받기를 참고해서 테스트하기 위해 하이퍼링크에 넣는다
( 원래는 노출하면 안되지만 연습용.. )

GET /oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code HTTP/1.1
Host: kauth.kakao.com

로그인 하면 다음 화면이 나온다

계속하기를 누르면 리다이렉션 uri 와 콜백코드를 리턴해준다

지금까지의 순서

처음 클라이언트가 받은 302 상태코드

클라이언트는 location을 받았기 때문에 현재 서버에 다시 요청을 날린다.

지금은 토큰을 노출할 위험이 존재하는데 일반적으로 SSL(https) 를 이용해서 암호화한다.

보안을 위해서는 토큰은 안전하게 저장되어야 하고, 최소한의 권한만 요청해야한다.
API 제공 업체의 정책을 준수하고 애플리케이션의 액세스 권한 부여를 클라이언트의 동의 등으로 고려해야 한다.

토큰을 받아보자

토큰을 받기 위한 요청 파라미터 목록



토큰 받기

토큰을 받아서 사용자의 데이터에 접근해보자

@Controller
public class UserController {
    
    @GetMapping("/loginForm")
    public String loginForm(){
        return "loginForm";
    }

    @GetMapping("/callback")
    @ResponseBody
    public String callBack(String code){
        // 1. code 값 존재 유무
        if(ObjectUtils.isEmpty(code)){
            return "bad_request";
        }
        // 2. code 값 카카오에게 전달 -> access token 받기
        String kakaoUrl = "https://kauth.kakao.com/oauth/token";
        RestTemplate rt = new RestTemplate();
        HttpHeaders headers = new HttpHeaders(); // 스프링헤더
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        // 카카오가 요구하는 2차 검증 키들
        MultiValueMap<String, String> xForm = new LinkedMultiValueMap<>();
        xForm.add("grant_type", "authorization_code");
        xForm.add("client_id", "받은 키");
        xForm.add("redirect_uri", "http://localhost:8080/callback");
        xForm.add("code", code);

        HttpEntity<?> httpEntity = new HttpEntity<>(xForm,headers);
        ResponseEntity<String> responseEntity = rt.exchange(kakaoUrl, HttpMethod.POST, httpEntity, String.class);
        // 3. access token 으로 서버가 리소스( 카카오가 가진 클라이언트의 데이터 ) 에 접근 가능해짐
        String responseBody = responseEntity.getBody();
        // 4. 서버가 카카오에 접근할 권한을 위임받게됨 // open auth -> OAuth
        return responseBody;
    }
}

간단하게 요구한 파라미터를 넣고 토큰을 받아왔다.

리턴받은 결과를 json viewer에 넣은 결과



토큰을 이용한 데이터 받기

토큰을 이용해서 사용자의 email을 받아 우리 사이트에 해당 email 이 없다면 회원가입을 존재하면 로그인을 하도록 만들어보자

@Controller
@RequiredArgsConstructor
public class UserController {

    private final UserRepository userRepository;
    private final ObjectMapper om;
    private final UserService userService;
    private final HttpSession session;
    
    @GetMapping("/loginForm")
    public String loginForm(){
        return "loginForm";
    }

    @GetMapping("/callback")
    @ResponseBody
    public String callBack(String code){
        if(ObjectUtils.isEmpty(code)){
            throw new CustomException("잘못된 요청입니다.");
        }
        String responseBody = Fetch.accessToken(code);
        TokenProperties tp = Fetch.parsing(responseBody, TokenProperties.class, om);
        String responseBody2 = Fetch.requestData(tp);
        UserProperties tp2 = Fetch.parsing(responseBody2, UserProperties.class, om);
        
        return "테스트"; 
    }
}

받은 코드를 이용해서 토큰을 받고 파싱후 데이터 요청을 하고 다시 파싱을 했다.

메소드를 아래와 같이 분리했다.
더 예쁘게 오버로딩을 해줄 수도 있는데 일단 구현한다고 깔끔 하지는 않다.

    public static String accessToken(String code){
        String kakaoUrl = "https://kauth.kakao.com/oauth/token";
        RestTemplate rt = new RestTemplate();
        HttpHeaders headers = new HttpHeaders(); 
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> xForm = new LinkedMultiValueMap<>();
        xForm.add("grant_type", "authorization_code");
        xForm.add("client_id", "a938dcxxxxxxxxxxxxxxxxxx");
        xForm.add("redirect_uri", "http://localhost:8080/callback");
        xForm.add("code", code);

        HttpEntity<?> httpEntity = new HttpEntity<>(xForm, headers);
        ResponseEntity<String> responseEntity = rt.exchange(kakaoUrl, 
        		HttpMethod.POST, httpEntity, String.class);
        String responseBody = responseEntity.getBody();
        return responseBody;
    }
    public static String requestData(TokenProperties tp) {
        String kakaoReqUrl2 = "https://kapi.kakao.com/v2/user/me";
        RestTemplate rt2 = new RestTemplate();
        HttpHeaders headers2 = new HttpHeaders(); 
        headers2.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers2.setBearerAuth(tp.getAccessToken());
        
        MultiValueMap<String, String> xForm2 = new LinkedMultiValueMap<>();
        HttpEntity<?> httpEntity2 = new HttpEntity<>(xForm2, headers2);
        
        ResponseEntity<String> responseEntity2 = rt2.exchange(kakaoReqUrl2, 
        					HttpMethod.GET, httpEntity2, String.class);
        String responseBody2 = responseEntity2.getBody();
        return responseBody2;
    }
    public static <T> T parsing(String responseBody, Class<T> clazz, ObjectMapper om){
        T t ;
        try {
            t = om.readValue(responseBody, clazz);
        } catch (Exception e) {
            throw new CustomException("파싱 실패");
        }
        return t;
    }

토큰을 이용해서 받아온 사용자의 데이터 정보

UserProperties(id=270xxxxxxx, 
connectedAt=Tue Mar 14 15:58:52 KST 2023, 
kakaoAccount=UserProperties.KakaoAccount(
	hasEmail=true, 
    emailNeedsAgreement=false, 
    isEmailValid=true, 
    isEmailVerified=true, 
    email=xxxxxxx@daum.net)
    )

이제 이러한 데이터를 이용해서 자동 로그인 기능을 만든다면 컨트롤러에 아래의 같은 코드를 추가하고

        String resultEmail = "kakao_" + tp2.getKakaoAccount().getEmail();   
        createSession(resultEmail, userRepository, session, userService); 
        User user2  = (User) session.getAttribute("principal");

회원가입 / 로그인을 나눠서 세션에 로그인 데이터를 저장했다.

    public static void createSession(String resultEmail, UserRepository userRepository, 
    								HttpSession session, UserService userService){
        User user = userRepository.findByEmail(resultEmail);
        if (user != null) {
            session.setAttribute("principal", user);
        } else {
            UserJoinReqDto uDto = UserJoinReqDto.builder()
                    .email(resultEmail)
                    .password("1234UUID") // UUID 해쉬값으로 변환했다고 가정
                    .build();
            User principal = userService.회원가입(uDto);
            session.setAttribute("principal", principal);
        } 
    }


DB 없이 간단하게 구현하기

테스트 하기 위한 클래스를 하나 만든다.

public class UserStore {
    public static List<User> userList = new ArrayList<>();
    static {
        userList.add(
            new User(
                1,
                "kakao_270xxxxxx",
                UUID.randomUUID().toString(),
                "ssarmango@gmail.com",
                "kakao")
            );
    }
    
    public static User findByUsername(String username){
        for (User user: userList) {
            if(user.getUsername().equals(username)){
                return user;
            }
        }
        return null;
    }

    public static void save(User user) {
        userList.add(user);
    }
}

이후 컨트롤러에서 이용

// 컨트롤러에서 UserProperties ~~ 이후

	User user = UserStore.findByUsername("kakao_"+tp2.getId());
        if(!ObjectUtils.isEmpty(user)){
            session.setAttribute("principal", user);
            return "redirect:/";
        }
        if(ObjectUtils.isEmpty(user)){
            User newUser = new User(
                2,
                "kakao_"+ tp2.getId(),
                UUID.randomUUID().toString(),
                tp2.getKakaoAccount().getEmail(),
                "kakao_"
        );
            UserStore.save(newUser);
            session.setAttribute("principal", newUser);
        }
        return "redirect:/";

계정 정보가 없으면 회원가입을 하고 세션에 정보를 넣었다.
화면에서 간단하게 세션에 접근해서 확인해보자

<body>
    <h1>메인 페이지</h1>
    <hr>
    {{#principal}} 
        username : {{username}} <br/>
        email : {{email}} <br/>
    {{/principal}}
    {{^principal}} 
        세션 없음
    {{/principal}}
</body>

이번에는 mustache 를 이용했다.
{{#principal}} 으로 if 조건 / {{^principal}} 으로 else 조건을 줄 수 있다.
세션에 접근하기 위해서는 yml에 다음의 코드를 추가하면 된다.

spring:
  # 머스태치 사용할때 세션에 접근하게 해줌
  mustache: 
    servlet:
      expose-session-attributes: true


간단하게 카카오 API를 참고해서 OAuth 로그인을 구현했는데 조금더 사용자 경험을 좋게 만들어줄 로직과 디자인이 중요할 것 같다.





( 추가적으로 보안에 대해 검색하다가 나온 사례 추가함 )

CSRF (Cross-Site Request Forgery)는 웹보안 공격 중 하나로, 인증된 사용자의 권한을 도용하여 공격자가 의도한 악의적인 요청을 서버에 보내는 공격입니다.

CSRF 공격은 일반적으로 인증된 사용자가 웹사이트에 로그인한 상태에서 이루어집니다. 공격자는 인증된 사용자가 접속하는 웹사이트와 관련된 다른 웹사이트에 악성 코드를 삽입하거나, 이메일 링크, 소셜 미디어 등을 통해 사용자를 유인하여 악성 코드가 실행되도록 합니다. 이후, 악성 코드는 인증된 사용자의 권한을 도용하여 서버로 요청을 보냅니다.

예를 들어, 사용자 A가 은행 웹사이트에 로그인한 상태에서 공격자가 제작한 악성 웹사이트를 방문하면, 악성 코드는 은행 웹사이트에서 이체를 요청하는 HTTP 요청을 생성하고, 인증된 사용자 A의 권한을 도용하여 해당 요청을 은행 웹사이트로 보냅니다. 이러한 공격은 인증된 사용자의 권한을 도용하기 때문에, 사용자가 요청을 보낸 것처럼 보이기 때문에 서버에서는 이를 구분하지 못하고 요청을 처리해버립니다.

CSRF 공격을 방지하기 위해서는 다양한 방법들이 있습니다. 대표적인 방법은 사용자 인증 토큰(CSRF Token)을 사용하는 것입니다. CSRF Token은 웹사이트에서 유일한 값을 생성하고, 해당 값은 요청을 보낼 때 HTTP 요청 헤더에 함께 보내져야 합니다. 이를 통해 서버는 사용자가 실제로 요청을 보낸 것인지, 악성 코드가 요청을 보낸 것인지를 구분할 수 있습니다. 또한, Referer 헤더를 확인하여 요청이 원래의 웹사이트에서 보내진 것인지 확인하는 방법도 있습니다.

profile
작은것부터

0개의 댓글

관련 채용 정보