티스토리 뷰

개요

  • 마이크로서비스 시대에서 요청자에 대한 인증(Authentication)과 인가(Authorization)는 더욱 더 중요한 개념으로 자리매김하고 있다. 대부분의 개발자가 주요 기능을 먼저 설계하고 인증과 인가를 가장 늦게 설계해본 경험이 있을 것이다. 꽤나 귀찮고 반복적이면서 실수가 용납되지 않는 것이 인증과 인가라고 말할 수 있다. 만약, 국제적인 인증, 인가 표준(OIDC, SAML, OAuth 2.0 등)을 모두 제공하는 서비스가 완성품으로 무료로 제공한다면? 쓰지 않을 이유가 없을 것이다. 이번 글에서 설명할 JBoss Keycloak이 바로 이러한 인증, 인가를 대행해주는 오픈 소스 솔루션이다.

국내 중요정보 보관에 대한 법적 근거

  • 인증, 인가를 본격적으로 다루기에 앞서 중요정보에 대한 국내에서의 법적 근거를 확인해볼 필요가 있다. 그간 금융권에서는 정부가 2016년 전자금융감독규정을 개정, 클라우드 서비스 이용 가이드를 마련하여 개인신용정보고유식별정보를 제외한 비중요정보에 한정하여 클라우드 사용을 허용한 바 있다. 이로 인해 중요정보를 다루는 핀테크 업계의 스타트업 입장에서 국내 IDC에 자체 물리 서버를 구축하고 관리해야 하는 부담을 주었다.
  • 2018-07-15 금융위원회금융권 클라우드 이용 확대 방안을 발표했다. 이에 따르면 2019년 1월부터 금융사 및 핀테크 기업은 고객의 개인신용정보고유식별정보의 저장소로 그동안 법으로 금지되었던 클라우드를 이용할 수 있게 된다. 즉, 네이버 클라우드 플랫폼과 같은 국내 기업의 클라우드부터 AWS와 같은 국외 기업의 국내 리전까지 중요정보를 저장할 수 있게 되는 것이다. AWS의 경우 2016-01-07부터 서울 리전을 제공하고 있으며 2017-12-27 부로 한국인터넷진흥원이 발급하는 ISMS 인증을 획득한 상태이다. [관련 링크1] [관련 링크2]
  • 한편, 개인정보보호법 제23조와 행정자치부한국인터넷진행원이 2017-01 발간한 개인정보의 암호화 조치 안내서에 따르면 저장소에 중요정보 저장시 암호화할 것을 법으로 강제하고 있다. 이에 따르면 비밀번호, 바이오 정보, 주민등록번호, 신용카드번호, 계좌번호, 여권번호, 운전면허번호, 외국인등록번호가 암호화 대상으로 언급되어 있어 인증, 인가 서비스 설계시 고려해야 한다. [관련 링크]

2019-01-01 전자금융감독규정 개정 시행

  • 2019-01-01 금융위원회가 예고한대로 전자금융감독규정이 개정 시행되었다. 개정안에 따르면 2019-01-01부터 금융회사는 중요정보국내 클라우드 사업자, 국내에 전산센터를 둔 해외 클라우드 사업자(AWS, MS, IBM)를 이용하여 보관할 수 있다. (비중요정보는 전과 같이 해외 소재 클라우드를 이용하여 보관할 수 있다.) [관련 링크]
  • 중요정보 보관에 있어 유의할 점은 반드시 원본 데이터를 제3자가 알아볼 수 없도록 암호화한 후 보관해야 한다는 것이다. 암호화의 대상과 방법은 개인정보의 암호화 조치 안내서를 따라 결정한다.
  • 개인정보의 암호화 조치 안내서에 따르면 주민등록번호는 대칭키 암호화를 사용해야 하며, 생년월일과 성별을 포함한 앞 7자리를 제외하고 뒷 6자리만 암호화하는 것도 허용된다. 비밀번호의 경우 일방향 암호화만 허용된다.
1. 비밀번호 (일방향 암호화)
2. 주민등록번호 (대칭키 암호화)
3. 운전면허번호 (대칭키 암호화)
4. 외국인등록번호 (대칭키 암호화)
5. 신용카드번호 (대칭키 암호화)
6. 계좌번호 (대칭키 암호화)
7. 바이오정보 (대칭키 암호화)

JBoss Keycloak 아키텍쳐

  • JBoss Keycloak은 하나의 완성품으로 작동하는 웹 애플리케이션이다. 내부적으로는 JBoss Undertow 기반 위에 JBoss RESTEasy(JAX-RS 2.1 구현체)로 서비스되며 노드 간의 세션 클러스터링과 캐시를 위해 JBoss Infinispan을 사용한다.

JBoss Keycloak 설치

  • CentOS 7 환경에서 JBoss Keycloak의 설치는 아래와 같이 진행한다.
### 운영체제에 keycloak 사용자를 생성하고 비밀번호 설정
$ useradd keycloak
$ passwd keycloak

### /opt 디렉토리에 JBoss Keycloak를 설치하고 keycloak 사용자에게 소유권 이전
$ cd /opt
$ wget https://downloads.jboss.org/keycloak/4.5.0.Final/keycloak-4.5.0.Final.zip
$ unzip keycloak-4.5.0.Final.zip
$ rm -f keycloak-4.5.0.Final.zip
$ mv keycloak-4.5.0.Final keycloak
$ chown -R keycloak:keycloak keycloak

JBoss Keycloak의 실행

  • JBoss Keycloak의 실행 방법은 아래와 같다.
### keycloak 사용자로 로그인
$ su - keycloak
$ cd /opt/keycloak/bin

### 일반적인 JBooss Keycloak의 실행, 루프백 인터페이스로부터만 접속이 가능
$ ./standalone.sh

### 보안이 완화된 JBooss Keycloak의 실행, 외부 인터페이스로부터의 접속도 가능
$ ./standalone.sh -b 0.0.0.0

### 리스닝 포트를 기본값인 8080에 100을 더한 8180으로 실행
$ ./standalone.sh -b 0.0.0.0 -Djboss.socket.binding.port-offset=100
  • 한편, Keycloak은 보안을 위해 localhost(또는 127.0.0.1) 루프백 인터페이스로부터의 요청만을 허가한다. 따라서 외부 인터페이스로부터의 요청을 허가하려면 Keycloak 실행시 -b 0.0.0. 옵션을 추가해야 한다.

관리자 계정 생성

  • Keycloak을 설치한 후 가장 먼저 해야할 일은 관리자 계정을 생성하는 것이다. 아래와 같이 스크립트를 실행하여 생성한다. 관리자 계정 생성 후 실행 중인 Keycloak를 종료하고 다시 실행(또는 서비스 재시작)해야 한다.
### admin 이라는 어드민 계정을 생성
$ ./add-user-keycloak.sh -u admin
Password: *****
Added 'admin' to '/opt/keycloak/standalone/configuration/keycloak-add-user.json', restart server to load user

### Keycloak 애플리케이션 실행
$ ./standalone.sh -b 0.0.0.0

Admin Console 접속

  • 이제 Keycloak의 어드민 콘솔에 접속해볼 차례이다. 웹 브라우저를 실행하고 http://localhost:8080/auth에 접속한다. (/auth 문자열을 생략해도 자동으로 리다이렉트된다.) 접속 후 Administration Console을 클릭하면 로그인 페이지가 등장한다. 앞서 생성한 어드민 계정으로 로그인하면 어드민 콘솔에 접속할 수 있다.

인증, 인가 관련 용어

  • OIDC(Open ID Connect): OAuth 2.0이 인가만 다루는 스펙이라면 OIDCOAuth 2.0을 포함하여 인증과 인가를 모두 포괄하는 스펙이다. 한 사이트에서 로그인하면 다른 사이트에서도 로그인이 되는 SSO(Single Sign-On) 기능 구현을 위한 대표적인 수단으로 사용된다. JBoss Keycloak의 경우 클라이언트를 위한 OpenID 서버로서 기능할 수 있다. 웹사이트의 사용자는 JBoss Keycloak에 접속하여 로그인을 수행하며 이에 대한 결과로 ID Token(로그인 사용자의 정보)과 Access Token(권한 위임에 대한 정보)이 JWT의 형태로 발급되어 SSO를 가능하게 한다. [관련 링크1] [관련 링크2]

JBoss Keycloak 관련 용어

  • 본격적으로 JBoss Keycloak을 구성하기 전에 관련 용어에 대해서 간단히 숙지할 필요가 있다. 필수적으로 이해가 필요한 주요 용어는 아래와 같다.
  • Realm: 인증, 인가가 작동하는 범위를 나타내는 단위이다. SSO(Single Sign-On)를 예로 들면 특정 클라이언트들이 SSO를 공유한다면 그 범위는 그 클라이언트들이 공통적으로 속한 Realm에 한정된다. 기본적으로 삭제가 불가능한 Master라는 Realm이 제공된다.
  • Client: JBoss Keycloack에게 인증, 인가 행위를 대행하도록 맡길 애플리케이션을 나타내는 단위이다. 웹사이트일수도 있고, REST API를 제공하는 서비스일수도 있다. 하나의 Realm은 자신에게 종속된 n개의 Client를 생성하고 관리할 수 있다.
  • User: 실제 각 Client에 로그인할 사용자를 나타내는 단위이다. 하나의 Realm은 자신에게 종속된 n개의 User를 생성하고 관리할 수 있다. 기본적으로 User 개체는 Username, Email, First Name, Last Name 4개 항목을 가질 수 있는데 Custom User Attributes 기능을 통해 커스텀 항목을 자유롭게 추가할 수 있다. (다만, 추가된 항목이 사용자 등록 및 관리 화면에 출력되려면 커스텀 테마 등록 및 수정이 필요하다.)

Keycloak 로컬 인스턴스 실행

  • 아래는 예제를 실행하기 위한 목적의 Keycloak 로컬 인스턴스를 실행하는 예이다.
# Keycloak 도커 컨테이너를 실행
# 어드민 웹 콘솔 포트: 8888
$ docker run --rm -d --name keycloak -p 8888:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin jboss/keycloak
  • 도커 컨테이너를 실행 후 브라우저에서 http://localhost:8888 주소로 접속하면 Keycloak의 어드민 웹 콘솔이 보여진다. 테스트 목적의 초기 어드민 계정으로 admin/admin을 설정했다.

build.gradle.kts

  • 이제 JBoss Keycloak에게 인증, 인가 행위를 위탁할 Client 애플리케이션을 개발해볼 차례이다. 프로젝트 루트의 build.gradle.kts에 아래 내용을 추가한다.
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.keycloak:keycloak-spring-boot-starter:12.0.4")
    implementation("org.keycloak:keycloak-admin-client:12.0.4")
}

Configuration 작성

  • Keycloak을 활성화하기 위한 Configuration을 아래와 같이 작성한다.
import okhttp3.OkHttpClient
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver
import org.keycloak.adapters.springsecurity.KeycloakConfiguration
import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents
import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter
import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticatedActionsFilter
import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticationProcessingFilter
import org.keycloak.adapters.springsecurity.filter.KeycloakPreAuthActionsFilter
import org.keycloak.adapters.springsecurity.filter.KeycloakSecurityContextRequestFilter
import org.keycloak.adapters.springsecurity.management.HttpSessionManager
import org.keycloak.authorization.client.AuthzClient
import org.keycloak.authorization.client.Configuration
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
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.http.SessionCreationPolicy
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper
import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy
import java.util.concurrent.TimeUnit

@KeycloakConfiguration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@ComponentScan(basePackageClasses = [KeycloakSecurityComponents::class])
class KeycloakWebSecurityConfig(
    val keycloakClientRequestFactory: KeycloakClientRequestFactory,
    @Value("\${keycloak.auth-server-url}") val authServerUrl: String,
    @Value("\${keycloak.realm}") val realm: String,
    @Value("\${keycloak-client.id}") val clientId: String,
    @Value("\${keycloak-client.secret}") val clientSecret: String,
) : KeycloakWebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {

        super.configure(http)
        http
            .cors()
            .and().csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/**").permitAll()
            .anyRequest().permitAll()
    }

    override fun sessionAuthenticationStrategy(): SessionAuthenticationStrategy {

        return NullAuthenticatedSessionStrategy()
    }

    @Autowired
    fun configureGlobal(auth: AuthenticationManagerBuilder) {

        val keycloakAuthenticationProvider = keycloakAuthenticationProvider()
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(SimpleAuthorityMapper())

        auth.authenticationProvider(keycloakAuthenticationProvider)
    }

    @Bean
    fun keycloakAuthenticationProcessingFilterRegistrationBean(
        filter: KeycloakAuthenticationProcessingFilter
    ): FilterRegistrationBean<*> {

        val registrationBean: FilterRegistrationBean<*> = FilterRegistrationBean(filter)
        registrationBean.isEnabled = false

        return registrationBean
    }

    @Bean
    fun keycloakPreAuthActionsFilterRegistrationBean(
        filter: KeycloakPreAuthActionsFilter
    ): FilterRegistrationBean<*> {

        val registrationBean: FilterRegistrationBean<*> = FilterRegistrationBean(filter)
        registrationBean.isEnabled = false

        return registrationBean
    }

    @Bean
    fun keycloakAuthenticatedActionsFilterBean(
        filter: KeycloakAuthenticatedActionsFilter
    ): FilterRegistrationBean<*> {

        val registrationBean: FilterRegistrationBean<*> = FilterRegistrationBean(filter)
        registrationBean.isEnabled = false

        return registrationBean
    }

    @Bean
    fun keycloakSecurityContextRequestFilterBean(
        filter: KeycloakSecurityContextRequestFilter
    ): FilterRegistrationBean<*> {

        val registrationBean: FilterRegistrationBean<*> = FilterRegistrationBean(filter)
        registrationBean.isEnabled = false

        return registrationBean
    }

    @Bean
    @ConditionalOnMissingBean(HttpSessionManager::class)
    override fun httpSessionManager(): HttpSessionManager {

        return HttpSessionManager()
    }

    @Bean
    fun keycloakConfigResolver(): KeycloakSpringBootConfigResolver? {

        return KeycloakSpringBootConfigResolver()
    }

    @Bean
    fun authzClient(): AuthzClient {

        val clientCredentials = mapOf("secret" to clientSecret, "grant_type" to "password")
        val configuration = Configuration(authServerUrl, realm, clientId, clientCredentials, null)

        return AuthzClient.create(configuration)
    }

    @Bean
    fun keycloakOkHttpClient(): OkHttpClient {

        return OkHttpClient()
            .newBuilder().apply {
                connectTimeout(60, TimeUnit.SECONDS)
                readTimeout(60, TimeUnit.SECONDS)
                writeTimeout(60, TimeUnit.SECONDS)
            }.build()
    }
}

application.yml 작성

  • 프로젝트 내 /src/main/resources/application.yml 파일에 아래 내용을 추가한다.
server:
  port: 8081

keycloak:
  enabled: true
  realm: foobar-dev
  auth-server-url: http://localhost:8888/auth
  ssl-required: external
  resource: foobar-dev-api
  use-resource-role-mappings: false
  bearer-only: true

keycloak-client:
  id: foobar-api-dev
  secret: {client_secret}
  • server.port 항목은 Client 웹 애플리케이션 시작시 리스닝할 포트 번호를 의미한다. 같은 로컬 내의 8080 포트를 이미 앞서 실행한 JBoss Keycloak 애플리케이션이 사용하기 때문에 충돌을 피해 8081 포트로 설정했다. 운영 환경에서는 서로 다른 물리 서버에서 기동되므로 이 설정은 큰 의미가 없다.
  • keycloack 항목은 인증, 인가를 위한 모든 정보를 담고 있다. auth-server-url에는 기동 중인 JBooss Keycloak의 서비스 주소를 입력한다.
  • realm 항목에는 인증, 인가를 공유하는 Realm을 입력한다. 본인이 생성한 값을 입력한다.
  • resource 항목에는 본 애플리케이션에 해당하는 Clientfmf 입력한다. 본인이 생성한 값을 입력한다.
  • patterns[] 항목에는 인증, 인가가 요구되는 본 애플리케이션의 리소스 경로를 입력한다.

로그인 작성

  • 사용자 로그인 기능을 아래와 같이 작성할 수 있다.
data class UserLoginRequest(
    var username: String
    var password: String
)

@RequestMapping(value = ["/users"])
@RestController
class UserController(
    val authzClient: AuthzClient
) {
    @PostMapping("/login", produces = [MediaType.APPLICATION_JSON_VALUE])
    fun login(@RequestBody request: UserLoginRequest): ResponseEntity<*> {

        val response = try {
            authzClient.obtainAccessToken(request.username, request.password)
        } catch (ex: HttpResponseException) {
            when (ex.statusCode) {
                401 -> {
                    // 예외 처리 작성
                }
                else -> {
                    // 예외 처리 작성
                }
            }
        }

        return ResponseEntity.ok(response)
    }
}

권한 검사 작성

  • 요청 헤더에 유효한 액세스 토큰이 명시된 Authorization: Bearer {access_token}이 존재한다면, Keycloak에 의해 권한 검사가 수행된다. 컨트롤러 메서드 작성 예는 아래와 같다.
// user, admin 권한을 가진 사용자만 접근 가능
// 권한이 없을 경우 org.springframework.security.access.AccessDeniedException 예외 발생
@PreAuthorize("hasAnyRole('user', 'admin')")
@GetMapping("/protected-resource")
fun getProtectedResource(): ResponseEntity<Void> {

    return ResponseEntity.ok().build()
}

액세스 토큰 재발급 작성

  • 만료된 액세스 토큰에 대해 리프레쉬 토큰을 이용하여 아래와 같이 액세스 토큰을 재발급하는 엔드포인트를 작성할 수 있다. refresh_token이 요구된다.
data class UserLoginByRefreshTokenRequest(
    var refreshToken: String
)

@RequestMapping(value = [&quot;/users&quot;])
@RestController
class UserController(
    val keycloakOkHttpClient: OkHttpClient
) {
    @PostMapping("/login_by_refresh_token", produces = [MediaType.APPLICATION_JSON_VALUE])
    fun loginByRefreshToken(@RequestBody request: UserLoginByRefreshTokenRequest): ResponseEntity<*> {

        val keycloakRequest: Request = Request.Builder()
            .url("http://{keycloak-host}/auth/realms/{realm}}/protocol/openid-connect/token")
            .post(
                FormBody
                    .Builder()
                    .add("client_id", "{client_id}")
                    .add("client_secret", "{client_secret}")
                    .add("grant_type", "refresh_token")
                    .add("refresh_token", request.refreshToken)
                    .build()
            )
            .build()

        val response = keycloakOkHttpClient.newCall(keycloakRequest).execute()
        if (response.code != 200) {
            val errorResponse: KeycloakErrorResponse = jacksonObjectMapper().readValue(response.body?.string() ?: "")
            println(errorResponse)
            if (errorResponse.errorDescription == "Invalid refresh token") {
                // 예외 처리 작성
            }
        }

        return ResponseEntity.ok(response.body?.string() ?: "OK")
    }

로그아웃 작성

  • 현재 로그인 중인 사용자에 대해 아래와 같이 로그아웃 엔드포인트를 작성할 수 있다. acces_token, refresh_token이 요구된다.
data class UserLogoutRequest(
    var refreshToken: String
)

@RequestMapping(value = ["/users"])
@RestController
class UserController(
    val keycloakOkHttpClient: OkHttpClient
) {
    @PreAuthorize("hasAnyRole('user', 'admin')")
    @PostMapping("/logout")
    fun logout(@RequestBody request: UserLogoutRequest, principal: Principal): ResponseEntity<Void> {

        val keycloakPrincipal = principal as KeycloakAuthenticationToken
        val accessToken = keycloakPrincipal.account.keycloakSecurityContext.tokenString

        val keycloakRequest: Request = Request.Builder()
            .url("http://{keycloak-host}}/auth/realms/{realm}}/protocol/openid-connect/logout")
            .addHeader("Authorization", "Bearer $accessToken")
            .post(
                FormBody
                    .Builder()
                    .add("client_id", "{client_id}")
                    .add("client_secret", "{client_secret}")
                    .add("refresh_token", request.refreshToken)
                    .build()
            )
            .build()

        val response = keycloakOkHttpClient.newCall(keycloakRequest).execute()
        if (response.code != 204) {
            val errorResponse: KeycloakErrorResponse = jacksonObjectMapper().readValue(response.body?.string() ?: "")
            if (errorResponse.errorDescription == "Invalid refresh token") {
                // 예처 처리 작성
            }
        }

        return ResponseEntity.ok().build()
    }
}

커스텀 테마

  • JBoss Keycloak은 기본 테마로 base, keycloak(기본 테마) 2개를 제공한다. 아마 웹사이트를 제작하면서 기본 테마를 그대로 사용할 일은 없을 것이다. 대부분 각 환경에 알맞는 커스텀 테마를 제작하게 된다. JBoss KeycloakFreeMarker 템플릿 엔진 기반의 커스텀 테마를 지원한다. 기존 테마와 동일한 구조의 커스텀 테마를 제작한 후 /opt/keycloak/themes 디렉토리에 업로드하면 된다. 커스텀 테마를 제작하면서 앞서 언급한 User에 대한 커스텀 애트리뷰트도 노출시킬 수 있다.

참고 글

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함