나는 드디어 api gateway에 keycloak연동하는것을 성공했다.
정말 여러문제가있었다.
어떤 문제였냐면..
keycloak에서는 로그인 성공시, redirect_uri
를 통해 access_token
을 발급해준다.
그런데 redirect_uri 연결이 매우매우 까다롭다는 것.
많이 구글링을 했음에도 불구하고 대부분의 개발자들이 redirect_uri를 *로 설정해주고있었다
하지만 이것은 내가원하는 방식이 아니었다.
나는 access_token을 저장하고 토큰을 decode까지 해 줄 하나의 '서비스'
를 개발하고 싶었기 때문이다.
따라서 소개하고자 한다.
엄청나게 삽질을 했었기 때문에 누군가에게 도움이 되길 바라며....
일단 redirect_uri가 핵심
이라고 할 수 있다.
test.com은 내가 테스트해보려고 한 url일 뿐이니 무시하자.
http://localhost:8000/user/auth 로 redirect_uri 설정해줌
그리고 블로그를 찾아보면 대부분의 예제들은 뒤에 *
을 붙여주는데
나는 그걸 원하는것이 아니라 정말 auth로 갔을 때에만, access token을 발급받고 싶기때문에
*
은 생략했다.
keycloak을 연동하는 방법은 여러가지가 있다.
가장 유명한 방법은 keycloakConfig.java 사용하기 + yml 파일에 keycloak관련 내용 정의해서 연결 하거나
keycloakConfig.java + keycloak.json 이렇게 하기
근데 문제점은 keycloakConfig.java 내부에서 자동으로 keycloak 내부에서 설정되어있는
default uri로 보내준 다는 점.
그럼 오류가 난다. 그래서 대부분의 블로그들이 redirect_uri를 *
로 설정해주고잇다
하지만 나는 이문제를 타파하고, 정확한 위치에서 토큰을 받아, 토큰을 저장하는 서비스
를 만들것이다.
패키지를 생성해 준 후 ,
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로 가는거 없어짐 ㅠㅠ 감격
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와 일치해야함) 곳으로 보내주기.
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와 연결햇따.
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에 맞게 다 이동할 것이다~
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정보를 어떻게 각 서비스별로 이동시킬지 고민해봐야한다.
안녕하십니까 작성하신 글들 잘봤습니다. 감사합니다.
다름이 아니라 한가지 여쭤보고 싶은 것이 있는데, 하나의 로그인 웹페이지에서, 로그인을 성공한다면 keycloak과 통신하여 해당 realm에 저장되어 있는 user 정보를 가져와서, 그 user로 로그인을 성공했을 때의 화면이 보여지게 할 수 있을까요? keycloak으로 부터 access token을 가져와서, 통신하는 것 까지는 되는데, 해당 user의 로그인이 성공했을 때의 페이지는 가져올 수가 없네요..