부자 되기 위한 블로그, 머니킹

안녕하세요! 오늘도 열심히 개발중에 있습니다. 최근 매 프로젝트마다 로그인 시스템을 만드는게 귀찮고 운용하는데에도 문제가 있을 것 같아서 개인적인 프로젝트에 사용할 통합 인증 시스템을 구현하려고 합니다. 이에 많은 프레임워크를 찾았지만 스프링과 호환성이 좋으면서 레퍼런스도 어느정도 있고 쉽게 구현할 수 있는 KeyCloak 프레임워크에 대해서 알아보았습니다.

 

Keycloak 설치

 

keycloak 공식 홈페이지에 들어가면 바로 다운로드 버튼이 보이실텐데요. (keycloak은 오픈소스로 무료입니다) 들어가면 최신 버전이 나와있는데 개인적으로 16버전이상부터 폴더 구조가 많이 변경되어 레퍼런스를 찾기 어려우니 15버전 이하를 사용하시는 것을 권장드립니다.

(문서 또한 최신 버전은 불친절하다가 느껴졌습니다.)

 

화면 하단의 for previous release go 버튼을 클릭하면 이전버전도 다운받을 수 있습니다. 다운로드 페이지에서 가장 상단의 keycloak zip 파일을 다운받아 풀어주시면 됩니다.

 

[keycloak - bin] 폴더에 들어가면 standalone 파일이 있는데요 리눅스/맥라면 sh 파일을 윈도우라면 bat 파일을 실행시켜주면 됩니다. 저같은 경우에는 터미널에서 sh 파일을 실행시켜 주었습니다. 

 

그러면 이런식으로 자동으로 서버가 실행되는데요. localhost를 통해 접속하면 되는데 저 같은 경우에는 시작할 때 아래 명령어를 써서 포트를 8180번으로 지정하고 브라우저를 통해 접속해주었습니다.

 

./standalone.sh -Djboss.http.port=8180

 

keycloak 기본 설정

localhost:8180에 접속하면 처음에 왼쪽에 어드민 계정을 만들라고 말합니다. 어드민 계정의 아이디와 비밀번호를 입력해주면 자동으로 로그인 페이지가 나오는데요. 입력한 어드민 계정 및 비밀번호를 입력하면 해당 페이지가 나옵니다.

 

첫번째로 왼쪽 상단에 마우스 버튼을 가져다 대면 Add realm이 나오는데 이를 클릭하여 realm을 만들어줍시다. realm은 client의 그룹 단위로 이 realm에 속한 client 끼리 SSO 시스템을 만들 수 있습니다.

 

client 탭을 클릭하고 create를 통해 client를 만들어줍니다. 

 

client 설정에서 Access type을 confidential로 설정해줍니다. 하단으로 스크롤 하시면 Valid Redirect URIs가 나오는데 보통 해당 클라이언트를 만들 주소를 집어넣습니다. (localhost:8000 주소로 프로젝트를 연결시키려면 이 주소를 넣으면 됩니다)  테스트 단계에서 귀찮으시면 *를 넣어서 모든 경로를 설정해줍니다.

 

해당 Client의 Role(권한) 종류들을 만들 수 있는데 이는 나중에 User을 등록할 때 해당 클라이언트마다의 role mapping을 할 수 있습니다. add role을 통해 role을 만들 수 있으며 스프링과 연동하는 경우 ROLE_{ROLE 타입} 이런 형식으로 만들면 됩니다.

 

User탭에서 사용자를 등록할 수 있습니다. Add User를 클릭하여 적절한 계정 id 값으로 user을 생성해줍시다.

 

user을 클릭하여 Credentials 탭에서 비밀번호를 선택할 수 있습니다.

 

Role Mapping 탭에서 해당 유저의 권한을 설정할  수 있는데 Client Roels에서 생성한 클라이언트를 선택하면 추가할 수 있는 Role 목록들이 나옵니다.

 

추가로 테스트 개발 단계에서는 Session 탭 활용이 많은데 Session 탭에서 Logout all을 클릭하면 자동으로 클라이언트 앱의 로그아웃이 됩니다.

 

 

회원가입 부분은 realm 설정에 들어가서 User registeration을 활성화 시키면 로그인 페이지에 회원가입 페이지로 이동할 수 있는 버튼이 하나 생깁니다.

 

스프링 Client와 인증 시스템 연동

 

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <parent>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-parent</artifactId>
      <version>2.6.7</version>
      <relativePath/> <!-- lookup parent from repository -->
   </parent>
   <groupId>com.example</groupId>
   <artifactId>keycloak-example1</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <name>keycloak-example1</name>
   <description>keycloak-example1</description>
   <properties>
      <java.version>11</java.version>
   </properties>
   <dependencies>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-security</artifactId>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-thymeleaf</artifactId>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <dependency>
         <groupId>org.thymeleaf.extras</groupId>
         <artifactId>thymeleaf-extras-springsecurity5</artifactId>
      </dependency>

      <dependency>
         <groupId>org.projectlombok</groupId>
         <artifactId>lombok</artifactId>
         <optional>true</optional>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-test</artifactId>
         <scope>test</scope>
      </dependency>
      <dependency>
         <groupId>org.springframework.security</groupId>
         <artifactId>spring-security-test</artifactId>
         <scope>test</scope>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-security</artifactId>
      </dependency>
      <dependency>
         <groupId>org.keycloak</groupId>
         <artifactId>keycloak-spring-security-adapter</artifactId>
         <version>15.1.0</version>
      </dependency>
   </dependencies>

   <build>
      <plugins>
         <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
               <excludes>
                  <exclude>
                     <groupId>org.projectlombok</groupId>
                     <artifactId>lombok</artifactId>
                  </exclude>
               </excludes>
            </configuration>
         </plugin>
      </plugins>
   </build>

</project>

 

maven 설정은 다음과 같이 하였습니다.

 

package com.example.keycloakexample1.config;

import java.io.InputStream;

import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.KeycloakDeployment;
import org.keycloak.adapters.KeycloakDeploymentBuilder;
import org.keycloak.adapters.spi.HttpFacade;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakSpringConfigResolverWrapper;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class KeyCloakSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
        auth.authenticationProvider(keycloakAuthenticationProvider);
    }

    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(
                new SessionRegistryImpl());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        System.out.println("this is Auth Request");
        System.out.println(http.authorizeRequests());
        super.configure(http);

        http.authorizeRequests()
                .antMatchers("/app*").permitAll()
                .antMatchers("/tester/**").hasAnyRole("TESTER")
                .antMatchers("/user/**").hasAnyRole("USER")
                //.mvcMatchers("/tester*").hasAnyRole("TESTER")
                .antMatchers("/manager/**").hasAnyRole("MANAGER")
                .anyRequest().authenticated();

        http.csrf().disable();
    }

    @Bean
    public KeycloakConfigResolver keycloakConfigResolver() {
        //return new KeycloakSpringConfigResolverWrapper();
        return new KeycloakConfigResolver() {
            private KeycloakDeployment keycloakDeployment;
            @Override
            public KeycloakDeployment resolve(HttpFacade.Request facade) {
                if (keycloakDeployment != null) {
                    return keycloakDeployment;
                }
                InputStream configInputStream = getClass().getResourceAsStream("/keycloak.json");
                return KeycloakDeploymentBuilder.build(configInputStream);
            }
        };
    }
}

SecurityConfig 설정은 다음과 같이 하였는데 저 같은 경우 인터넷 블로그를 참고하여 keycloak 관련 설정을 따로 json 파일로 빼서 임포트 시켰기 때문에 keycloakConfigResolver를 Bean으로 등록시켜주었습니다.

 

{
  "realm": "[realm name : oingdaddy]",
  "auth-server-url": "[keycloak address : http://localhost:8180/aut]",
  "ssl-required": "external",
  "resource": "[client name : oingapp1]",
  "credentials": {
    "secret": "[client secret key]"
  },
  "confidential-port": 0,
  "use-resource-role-mappings": true
}

resource 바로 하위에 위와 같은 keycloak.json 파일을 위치시켰습니다.

 

server:
  port: 8080

spring:
  application:
    name: keycloak-example
  main:
    allow-circular-references: true

application.yml은 다음과 같이 설정하였습니다.

 

package com.example.keycloakexample1.controller;

import org.keycloak.KeycloakPrincipal;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
import org.keycloak.representations.AccessToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;

@RestController
public class SampleController {
    @RequestMapping("/hello")
    public String hello() {
        return "Hello KeyCloak!";
    }
    @RequestMapping("/app1")
    public String tracingTest() {
        return "This is permitAll!";
    }

    @RequestMapping("/tester/test")
    public String tester() {
        return "This is tester permit";
    }

    @RequestMapping("/user/test")
    public String user() {
        return "This is user permit";
    }

    @RequestMapping("/manager/test")
    public String manager() {
        System.out.println("manager router connect");
        return "This is manager permit";
    }

    @GetMapping("/userinfo")
    public String userInfoController(Model model) {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        KeycloakPrincipal principal = (KeycloakPrincipal)auth.getPrincipal();


        KeycloakSecurityContext session = principal.getKeycloakSecurityContext();
        AccessToken accessToken = session.getToken();
        String username = accessToken.getPreferredUsername();
        String emailID = accessToken.getEmail();
        String lastname = accessToken.getFamilyName();
        String firstname = accessToken.getGivenName();
        String realmName = accessToken.getIssuer();
        AccessToken.Access realmAccess = accessToken.getRealmAccess();
        System.out.println("username = " + username);

        //KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) principal;
        //AccessToken accessToken = keycloakAuthenticationToken.getAccount().getKeycloakSecurityContext().getToken();
        //
        //model.addAttribute("username", accessToken.getGivenName());
        //System.out.println("accessToken.getGivenName() = " + accessToken.getGivenName());
        return "page";
    }
}

Keycloak 관련 Controller은 테스트 삼아 위와 같이 구성하였습니다. 기본 Spring Security와 마찬가지로 Principal 객체를 통해 사용자 정보를 가져올 수 있습니다.

 

또한 SecurityConfig에 설정한 보안 정책처럼 해당 라우터에 권한이 없는 사용자가 접근시에 페이지가 나오지 않습니다. 또한 권한이 필요한 페이지에 접속하면 자동으로 로그인 페이지로 이동합니다.

 

keycloak CURL 테스트

curl -X POST "http://localhost:8180/auth/realms/oingdaddy/protocol/openid-connect/token" \
--header "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=oingapp1" \
--data-urlencode "client_secret=2osZsOk3WJ5GMGAMN5QrbNV1o02Jie3z" \
--data-urlencode "username=testmanager" \
--data-urlencode "password=1234" | jq

jq를 터미널에서 설치하고 curl 명령어를 통해 위와 같이 입력해주면 암호화된 토큰을 받습니다.

 

% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2358  100  2238  100   120  44032   2360 --:--:-- --:--:-- --:--:-- 51260
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhSlI2bXhsNVo2WnNfNk9MREtsREJqYWVvYTJhZmZZNzdUNjZZMnZtNDVJIn0.eyJleHAiOjE2NTE1NjIyOTIsImlhdCI6MTY1MTU1ODY5MiwianRpIjoiNDRjYzJmY2QtNDczMS00OGEzLWI3MjktMGNiYzVkYWI2MmM4IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MTgwL2F1dGgvcmVhbG1zL29pbmdkYWRkeSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI4ZWY4ZmY4OS0xNTMwLTRmMTktYjhiYS04NWZiZmE3YmRkODIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJvaW5nYXBwMSIsInNlc3Npb25fc3RhdGUiOiJiNzBmOGZlNy0xNTA1LTQ2MTYtOTBjMi1kZDkwZmZiNTMyYTYiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIioiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLW9pbmdkYWRkeSJdfSwicmVzb3VyY2VfYWNjZXNzIjp7Im9pbmdhcHAxIjp7InJvbGVzIjpbIlJPTEVfTUFOQUdFUiIsIlJPTEVfVEVTVEVSIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiJiNzBmOGZlNy0xNTA1LTQ2MTYtOTBjMi1kZDkwZmZiNTMyYTYiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InRlc3RtYW5hZ2VyIn0.Wnk4MCw9uL_slUPzKrj40d-JoC6CKRJ1WeaoasnOBy6-2O1W1kjV8lTPTG56dzF7A0QYpdmFK9MavZQNnKNoL_0F7RybuYUTp9c3AEjGn0tuTTeqwonDV2NiO8RAkgU105RZRCNInNLPzknt7aKbe0eB40xYjAVdIyrvuDeIKcdT1MNugruyxfcfZIM1puzT1LzFDlrZObHWjHNl_WulDgfDIRJFBMISUKdtzw5RTFIJ7Rqz7uLnhRbXOVpLOewXE1sX-qiYgdv-vjqu38M4A2YNd0g_l7acjKp7uP1tfLiGTvZr0Gv7DhHs6bY8QJrhWx4cQP_u-aXHCm9u6HO5Sw",
  "expires_in": 3600,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkOWM5NDc3NS00YzIzLTQyZTEtYWZkOS01ZTk0M2Y4NWU5MDMifQ.eyJleHAiOjE2NTE1NjA0OTIsImlhdCI6MTY1MTU1ODY5MiwianRpIjoiMGFiNTdkYWMtYTI2NS00ZmM3LTljN2ItZjI0ZGVlNDc1NjRlIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MTgwL2F1dGgvcmVhbG1zL29pbmdkYWRkeSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODE4MC9hdXRoL3JlYWxtcy9vaW5nZGFkZHkiLCJzdWIiOiI4ZWY4ZmY4OS0xNTMwLTRmMTktYjhiYS04NWZiZmE3YmRkODIiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoib2luZ2FwcDEiLCJzZXNzaW9uX3N0YXRlIjoiYjcwZjhmZTctMTUwNS00NjE2LTkwYzItZGQ5MGZmYjUzMmE2Iiwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwic2lkIjoiYjcwZjhmZTctMTUwNS00NjE2LTkwYzItZGQ5MGZmYjUzMmE2In0.dDOVYM6AUrCoMAje9VchfbwlYggy7Ol4pweO4jyadYk",
  "token_type": "Bearer",
  "not-before-policy": 1651558635,
  "session_state": "b70f8fe7-1505-4616-90c2-dd90ffb532a6",
  "scope": "profile email"
}

해당 토큰은 jwt 형식으로 암호화되어있습니다. 따라서 확인하고자 한다면 jwt.io 홈페이지에 들어가서 토큰을 붙여넣으면 어떤 값이 넘어왔는지 확인이 가능합니다.

 

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io