keycloak연동시 default redirect_uri 무시하고 내가원하는 redirect_uri대로 동작하게 하기

뿌이·2022년 3월 14일
0

스프링 클라우드

목록 보기
28/32

나는 드디어 api gateway에 keycloak연동하는것을 성공했다.
정말 여러문제가있었다.

어떤 문제였냐면..
keycloak에서는 로그인 성공시, redirect_uri를 통해 access_token을 발급해준다.
그런데 redirect_uri 연결이 매우매우 까다롭다는 것.

많이 구글링을 했음에도 불구하고 대부분의 개발자들이 redirect_uri를 *로 설정해주고있었다
하지만 이것은 내가원하는 방식이 아니었다.
나는 access_token을 저장하고 토큰을 decode까지 해 줄 하나의 '서비스'를 개발하고 싶었기 때문이다.

따라서 소개하고자 한다.
엄청나게 삽질을 했었기 때문에 누군가에게 도움이 되길 바라며....

keycloak- admin console 설정


일단 redirect_uri가 핵심이라고 할 수 있다.
test.com은 내가 테스트해보려고 한 url일 뿐이니 무시하자.
http://localhost:8000/user/auth 로 redirect_uri 설정해줌

그리고 블로그를 찾아보면 대부분의 예제들은 뒤에 *을 붙여주는데
나는 그걸 원하는것이 아니라 정말 auth로 갔을 때에만, access token을 발급받고 싶기때문에
*은 생략했다.

gateway에 keycloak 연동하기

keycloak을 연동하는 방법은 여러가지가 있다.
가장 유명한 방법은 keycloakConfig.java 사용하기 + yml 파일에 keycloak관련 내용 정의해서 연결 하거나
keycloakConfig.java + keycloak.json 이렇게 하기

근데 문제점은 keycloakConfig.java 내부에서 자동으로 keycloak 내부에서 설정되어있는
default uri로 보내준 다는 점.
그럼 오류가 난다. 그래서 대부분의 블로그들이 redirect_uri를 *로 설정해주고잇다
하지만 나는 이문제를 타파하고, 정확한 위치에서 토큰을 받아, 토큰을 저장하는 서비스를 만들것이다.

GatewayWebSecurityConfiguration.java


패키지를 생성해 준 후 ,

package com.dream.gatewayservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.client.oidc.web.server.logout.
OidcClientInitiatedServerLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;

@Order(99)
@Configuration
@EnableWebFluxSecurity
public class GatewayWebSecurityConfiguration {
	
	@Bean
	public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, 
    ReactiveClientRegistrationRepository clientRegistrationRepository) {
	// Authenticate through configured keycloak SSO Provider
	http.oauth2Login();
	// Also logout at the keycloak SSO Connect Provider
	http.logout(logout -> logout.logoutSuccessHandler(new OidcClientInitiatedServerLogoutSuccessHandler(
    clientRegistrationRepository)));
	// Require authentication for all request
//	http.authorizeExchange().anyExchange().authenticated(); 이거땜시 default 유알아이로 계속 가진거 개노무시키
	// Allow showing /home within a frame
	http.headers().frameOptions().mode(Mode.SAMEORIGIN);
	// Disable CSRF in the gateway to prevent conflicts with proxied server CSRF
	http.csrf().disable();
	
	return http.build();
	}
	
}

내가 개노무시키라고 적어놓은 행의 명령어를 지워주자.
그러면 default uri로 가는거 없어짐 ㅠㅠ 감격

application.yml (gateway)

server:
  port: 8000

eureka:
  client:
    fetch-registry: true # 유레카 클라이언트 활성화
    register-with-eureka: true # 유레카 클라이언트 활성화
    service-url:
      defaultZone: http://localhost:8761/eureka # 유레카 클라이언트로 등록

spring:
  application:
    name: gateway-service

  cloud:
    gateway:
      routes:          
        - id: menu-service
          uri: lb://MENU-SERVICE # uri: http://localhost:8001 
          # 포워딩 할 주소, http://localhost:8000/menu 로 들어오면 http://localhost:8001로 포워딩
          predicates:
            - Path=/menu/** # 해당 gateway 서버의 /menu/**로 들어오는 menu-service로 인식하겠다는 조건
          filters:
            - RewritePath=/menu/?(?<segment>.*), /$\{segment}   
              
        - id: product-service
          uri: lb://PRODUCT-SERVICE 
          predicates:
            - Path=/product/** 
          filters:
            - RewritePath=/product/?(?<segment>.*), /$\{segment}                      
              
        - id: manage-service
          uri: lb://MANAGE-SERVICE 
          predicates:
            - Path=/manage/** 
          filters:
            - RewritePath=/manage/?(?<segment>.*), /$\{segment}
        
        - id: loan-service
          uri: lb://LOAN-SERVICE 
          predicates:
            - Path=/apply/** 
          filters:
            - RewritePath=/apply/?(?<segment>.*), /$\{segment} 
                                 
        - id: list-service
          uri: lb://LIST-SERVICE 
          predicates:
            - Path=/list/** 
          filters:
            - RewritePath=/list/?(?<segment>.*), /$\{segment}   
            
        - id: save-service
          uri: lb://SAVE-SERVICE 
          predicates:
            - Path=/save/** 
          filters:
            - RewritePath=/save/?(?<segment>.*), /$\{segment}
                            
        - id: user-service
          uri: lb://USER-SERVICE # uri: http://localhost:64412 # 포워딩할 주소, http://localhost:8000/user 로 들어오면 http://localhost:64412 로 포워딩
          predicates:
            - Path=/user/** # 해당 gateway 서버의 /user/**로 들어오는 요은 user-service로 인식하겠다는 조건
          filters:
            - RewritePath=/user/?(?<segment>.*), /$\{segment}
        
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://192.168.1.54:8080/auth/realms/MSA
            user-name-attribute: preferred_username
        registration: 
          keycloak:
            client-id: memberService
            client-secret: RePeZAfKZ9XFiFc2Z5LzzFstYeduPQSd
            authorization-grant-type: authorization_code
            redirect-uri: "http://localhost:8000/user/auth" //꼭 써주기!

yml파일에서 keycloak 안의 redirect-uri 반드시 내가 보내주고싶은
( keycloak admin-console에서 설정해놓은 uri와 일치해야함) 곳으로 보내주기.

build.gradle (gateway)

plugins {
	id 'org.springframework.boot' version '2.6.3'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.dream'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

ext {
	set('springCloudVersion', "2021.0.1")
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
	implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

dependencyManagement {
	imports {
		mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
	}
}

tasks.named('test') {
	useJUnitPlatform()
}

그리고 나는 user-service와 연결햇따.

user-service에서 token 발급+decode하기

package com.dream.userservice.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.Base64;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.json.JSONObject;

import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
public class UserController {
	
	@RequestMapping(value="/", method = RequestMethod.GET)
	public String home() {
		System.out.println("controller stasrt");
		
		return "index";
	}
	
	@GetMapping("/loanList")
	public String loanList() {
		return "loanList";
	}
	
	@GetMapping("/startup")
	public String startup() {
		return "startup";
	}
	
	@GetMapping("/info")
    public String info(@Value("${server.port}") String port) {
        return "User info에 오신것을 환영합니다 Port: {" + port + "}";
    }
	
	 @GetMapping(path = "/service")//service로 접근하면 일단 무조건 로그인으로 보내줌
	   public void user_service(HttpServletRequest request, HttpServletResponse response) 
       throws ServletException, IOException {
	   
	      String id_token = "";
	      String access_token = "";
	      log.info("log : 들어와지나 userServiced입니다.");
	      System.out.println("들어와지나 userServiced입니다");
//	      if (id_token == null || id_token == "") {
//	         login(request, response);
//	         //response.sendRedirect("http://localhost:8480/login");
//	         
//	      }
	      
	      //access_token 존재확인
	      if (access_token == null || id_token == "") {
	         login(request, response);
	         //response.sendRedirect("http://localhost:8480/login");
	      }

	      
	      
	   }

	   @GetMapping(path = "/login") //로그인에서는 keycloak으로 로그인하는데, token을 받을 수 있는 url로 보내줌
	   public void login(HttpServletRequest request, HttpServletResponse response) 
       throws ServletException, IOException {
		   //todo make keycloak login url
	      try {//redirect URL 알아보기
	         response.sendRedirect("http://localhost:8080/auth/realms/MSA/protocol/openid-connect/auth?response_type=code&client_id=memberService&redirect_uri=http://localhost:8000/user/auth&scope=openid&nonce=asb3"); //여기도 너님들이 설정해야할 redirect_uri
	      }
	      catch (IOException e) {
	         // TODO Auto-generated catch block
	         e.printStackTrace();
	         System.out.println("login오류창으로 이동");
	      }
	      
	      return;
	   }
	  
	   @GetMapping(path = "/auth") //로그인 성공시 받을 수 있는 url : keycloak에서 설정한 redirect url
	   public String auth(HttpServletRequest request, HttpServletResponse response,Model model) throws ServletException, IOException {
	      //to do token save
		   System.out.println("auth들어왓습니다잉???");
	      String code = request.getParameter("code");
	      String query = "code=" + URLEncoder.encode(code,"UTF-8");
	      query += "&client_id=" + "memberService";
	      query += "&client_secret=" + "RePeZAfKZ9XFiFc2Z5LzzFstYeduPQSd";
	      query += "&redirect_uri=" + "http://localhost:8000/user/auth"; //너님들이 설정해야할 redirect_uri
	      query += "&grant_type=authorization_code";
	      
	      String tokenJson = getHttpConnection("http://localhost:8080/auth/realms/MSA/protocol/openid-connect/token", query);
	      System.out.println("tokenJson 먹었습니다~~~~~~~~~~");
	      return tokenJson;

	   }
	   
	   private String getHttpConnection(String uri, String param) throws ServletException, IOException {
	      URL url = new URL(uri);
	      HttpURLConnection conn = (HttpURLConnection) url.openConnection();
	      conn.setRequestMethod("POST");
	      conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
	      conn.setRequestProperty("Accept", "application/json"); //응답 형식 유형 설정
	      conn.setDoOutput(true); //콘텐츠를 보내는 데 연결이 사용되는지 확인
	      try (OutputStream stream = conn.getOutputStream()) {
	      try (BufferedWriter wd = new BufferedWriter(new OutputStreamWriter(stream))) {
	      wd.write(param);//param은 /auth에서 날린 parameter들
	      }
	      }
	      int responseCode = conn.getResponseCode();
	      System.out.println(responseCode);
	      String line;
	      StringBuffer buffer = new StringBuffer();
	      try (InputStream stream = conn.getInputStream()) {
	      try (BufferedReader rd = new BufferedReader(new InputStreamReader(stream))) {
	      while ((line = rd.readLine()) != null) {
	      buffer.append(line);
	      buffer.append('\r');
	      }
	      }
	      } catch (Throwable e) {
	      e.printStackTrace();
	      }
	      
	      //json파싱시작
	      JSONObject jObject = new JSONObject(buffer.toString());
	      String access_token= jObject.getString("access_token");
	      String id_token= jObject.getString("id_token");
	      
	      System.out.println("access_token==============="+access_token);
	      System.out.println("id_token==============="+id_token);
	      
	      //jwt decode
	      String[] access_chunks = access_token.split("\\."); //body부분 header부분 나눠
	      String[] id_chunks = access_token.split("\\."); //body부분 header부분 나눠
	      
	      Base64.Decoder decoder = Base64.getUrlDecoder();
	      
	      String access_payload = new String(decoder.decode(access_chunks[1]));
	      String id_payload = new String(decoder.decode(id_chunks[1]));
	      
	      System.out.println("access_payload============"+access_payload);
	      System.out.println("id_payload================"+id_payload);
	      
	      //json 형식으로 decode 된 token을 json파싱
	      JSONObject aObject = new JSONObject(access_payload);
	      String username= aObject.getString("preferred_username"); //username = 유저 아이디
	      String scope= aObject.getString("scope");
	      
	      System.out.println("preferred_username========="+username);
	      System.out.println("scope========="+scope);
	      //userId, scope를 role로할지,,? hasRole로 차단하던지 아니면 각 서비스별로 확인시키던지?
	      
	      return buffer.toString(); //buffer에 json형태를 다 문자열로 바꿔서 view에 보여주고있다
	      }
}

일단 이렇게 하면 8000번 포트의 url에 맞게 다 이동할 것이다~

build.gradle (user-service)

plugins {
	id 'org.springframework.boot' version '2.6.3'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.dream'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

ext {
	set('springCloudVersion', "2021.0.0")
}

dependencies {
	implementation 'org.json:json:20190722' //json파싱하기위해 사용
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	developmentOnly("org.springframework.boot:spring-boot-devtools")
}

dependencyManagement {
	imports {
		mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
	}
}

tasks.named('test') {
	useJUnitPlatform()
}

이렇게하면

결과창

http://localhost:8080/user/service 로 무조건 접속을 해야한느데, 하면
token잇는지 없는지 확인해준다.
없으면 키클락 로그인 창으로 이동

로그인 실패하면 이 창에 계속 머무르고,
로그인 성공시,
http://localhost:8080/user/auth 로 이동하여 (redirect_uri)

이런 화면을 보여준다.
나는 이제 json파싱해서 decode해서 scope와 username을 가져다가 쓸 수 있다.
코드에는 디코드하는 코드도 다 넣어놨다.

게이트웨이에 키클락을 연동해두었기 때문에 gateway 포트번호인 8000번 안에서 돌아다니는 것은 sso가 유지될 것이다.
이제 토큰을 디코드해서 얻은 scope정보나 , username정보를 어떻게 각 서비스별로 이동시킬지 고민해봐야한다.

profile
기록이 쌓이면 지식이 된다.

1개의 댓글

comment-user-thumbnail
2023년 8월 1일

안녕하십니까 작성하신 글들 잘봤습니다. 감사합니다.

다름이 아니라 한가지 여쭤보고 싶은 것이 있는데, 하나의 로그인 웹페이지에서, 로그인을 성공한다면 keycloak과 통신하여 해당 realm에 저장되어 있는 user 정보를 가져와서, 그 user로 로그인을 성공했을 때의 화면이 보여지게 할 수 있을까요? keycloak으로 부터 access token을 가져와서, 통신하는 것 까지는 되는데, 해당 user의 로그인이 성공했을 때의 페이지는 가져올 수가 없네요..

답글 달기