개요

OAuth2 기반의 인증 서비스, 각종 인증수단을 중앙에서 관리함으로써 각 연결되는 서비스들이 관리 포인트를 줄일 수 있게 만드는 것이 목적이다

OAuth2

auth oauth2 architecture

OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다. 이 매커니즘은 여러 기업들에 의해 사용되는데, 이를테면 아마존, 구글, 페이스북, 마이크로소프트, 트위터가 있으며 사용자들이 타사 애플리케이션이나 웹사이트의 계정에 관한 정보를 공유할 수 있게 허용한다 - Wikipedia

JWT

JSON 웹 토큰(JSON Web Token, JWT)은 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로, 페이로드는 몇몇 클레임(claim) 표명(assert)을 처리하는 JSON을 보관하고 있다. 토큰은 비공개 시크릿 키 또는 공개/비공개 키를 사용하여 서명된다. 이를테면 서버는 "관리자로 로그인됨"이라는 클레임이 있는 토큰을 생성하여 이를 클라이언트에 제공할 수 있다. 그러면 클라이언트는 해당 토큰을 사용하여 관리자로 로그인됨을 증명한다. 이 토큰들은 한쪽 당사자의 비공개 키(일반적으로 서버의 비공개 키)에 의해 서명이 가능하며 이로써 해당 당사자는 최종적으로 토큰이 적법한지를 확인할 수 있다. 일부 적절하고 신뢰할만한 수단을 통해 다른 당사자가 상응하는 공개키를 소유하는 경우 이 경우 또한 토큰의 적법성 확인이 가능하다. 토큰은 크기가 작고 URL 안전으로 설계되어 있으며 특히 웹 브라우저 통합 인증(SSO) 컨텍스트에 유용하다. JWT 클레임은 아이덴티티 제공자와 서비스 제공자 간(또는 비즈니스 프로세스에 필요한 클레임)의 인가된 사용자의 아이덴티티를 전달하기 위해 보통 사용할 수 있다. - Wikipedia

설정

정보

설정 간 필요한 URI 목록은 아래와 같다

{
  "issuer" : "http://auth.stable.codes",
  "authorization_endpoint" : "http://auth.stable.codes/oauth2/authorize",
  "pushed_authorization_request_endpoint" : "http://auth.stable.codes/oauth2/par",
  "device_authorization_endpoint" : "http://auth.stable.codes/oauth2/device_authorization",
  "token_endpoint" : "http://auth.stable.codes/oauth2/token",
  "token_endpoint_auth_methods_supported" : [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth" ],
  "jwks_uri" : "http://auth.stable.codes/oauth2/jwks",
  "response_types_supported" : [ "code" ],
  "grant_types_supported" : [ "authorization_code", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange" ],
  "revocation_endpoint" : "http://auth.stable.codes/oauth2/revoke",
  "revocation_endpoint_auth_methods_supported" : [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth" ],
  "introspection_endpoint" : "http://auth.stable.codes/oauth2/introspect",
  "introspection_endpoint_auth_methods_supported" : [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth" ],
  "code_challenge_methods_supported" : [ "S256" ],
  "tls_client_certificate_bound_access_tokens" : true,
  "dpop_signing_alg_values_supported" : [ "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512" ]
}

서버

Spring Security 와 OAuth2 의존성을 추가한다

build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
}

OAuth2 관련 서버 설정 값을 추가한다

application.properties
# 12. Security Properties
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=[위 설정 정보 중 jwks_uri]

Spring Security 구성

OAuth2 와 Spring Security 를 연동하기 위한 설정이 필요하다.

SecurityConfig.kt
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.config.annotation.web.configuration.WebSecurityConfigurerAdapter

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http.authorizeRequests().anyRequest().permitAll() // 일단, 모든 경로에 대해 인증을 풀어버린다

        http.oauth2ResourceServer().jwt() // OAuth2(+Jwt) 활성화
    }
}

연동 예제

위 설정이 마무리되면 발급 된 토큰을 이용하여 아래의 예제과 같이 연동이 가능하다. 위 설정과 같은 Global Method 인증 방식으로 설명했다

TestController.kt
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/test")
class TestController(
    private val service: TestService
) {

    @GetMapping("/info")
    @PreAuthorize("isAuthenticated()")
    fun info(
        @AuthenticationPrincipal jwt: Jwt
    ) = service.getVerySensitiveData(
        jwt
    )
}
TestService.kt
import org.slf4j.LoggerFactory
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Service

@Service
class TestService {

    private val logger = LoggerFactory.getLogger(javaClass)

    fun getVerySensitiveData(jwt: Jwt): String {
        val id = jwt.subject

        logger.info("매우 민감한 정보에 접근하는 호출 - $id")

        return "인증없이 출력되면 매우 민감한 문장"
    }
}

목록

OAuth2

인가 요청

요청 예제

Parameter Description

client_id

Client Id

redirect_uri

Redirect URI

response_type

code 로 고정

GET /oauth2/authorize?client_id=laboratory&redirect_uri=android-app%3A%2F%2Fcodes.stable.apps.laboratory%2Foauth2%2Fredirect&response_type=code HTTP/1.1
Host: auth.stable.codes
Cookie: SESSION=d7c8f102-27a4-49ed-9c19-1b1c438f0182

응답 예제

HTTP/1.1 302 Found
x-content-type-options: nosniff
x-xss-protection: 0
cache-control: no-cache, no-store, max-age=0, must-revalidate
pragma: no-cache
expires: 0
x-frame-options: DENY
location: android-app://codes.stable.apps.laboratory/oauth2/redirect?code=IsoIRRCrFMscWbjYj0r9clIYGrFFmLiK7qTnA0kJUZPgs2-tXq3YMRzMul_SGhOX594ZP7vln3yc57NBdm27-vRqgFwrIRqpuA7rRkUvX6A1T_Rtvc2kgN1ZLSn0y-5-
x-cloud-trace-context: abfabea9f3ea72e54186c2ecf5225dae
date: Mon, 30 Jun 2025 01:18:40 GMT
content-type: text/html
server: Google Frontend

토큰 발급

요청 예제

Name Description

Authorization

client_id:client_secret 을 Basic Auth 로 변환 한 값

Parameter Description

grant_type

authorization_code 로 고정

redirect_uri

Redirect URI

code

code 값

POST /oauth2/token HTTP/1.1
Authorization: Basic bGFib3JhdG9yeTpzZWNyZXQ=
Content-Type: application/x-www-form-urlencoded; charset=ISO-8859-1
Host: auth.stable.codes
Content-Length: 245

grant_type=authorization_code&redirect_uri=android-app%3A%2F%2Fcodes.stable.apps.laboratory%2Foauth2%2Fredirect&code=IsoIRRCrFMscWbjYj0r9clIYGrFFmLiK7qTnA0kJUZPgs2-tXq3YMRzMul_SGhOX594ZP7vln3yc57NBdm27-vRqgFwrIRqpuA7rRkUvX6A1T_Rtvc2kgN1ZLSn0y-5-

응답 예제

HTTP/1.1 200 OK
x-content-type-options: nosniff
x-xss-protection: 0
cache-control: no-cache, no-store, max-age=0, must-revalidate
pragma: no-cache
expires: 0
x-frame-options: DENY
content-type: application/json;charset=UTF-8
date: Mon, 30 Jun 2025 01:18:41 GMT
server: Google Frontend
Transfer-Encoding: chunked
Content-Length: 866

{
  "access_token" : "eyJraWQiOiIwOTQzZThlYS00NDZhLTQzOTktOWNjNC1lYjBmYTE5NGYyYTIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoibGFib3JhdG9yeSIsIm5iZiI6MTc1MTI0NjMyMSwiaXNzIjoiaHR0cDovL2F1dGguc3RhYmxlLmNvZGVzIiwiZXhwIjoxNzUxMjQ2NjIxLCJpYXQiOjE3NTEyNDYzMjEsImp0aSI6IjZiMzg2YTBhLTUzYWYtNGI0Ni05ZjM4LTU2N2QwZGRhZDkwNiJ9.aJ0udM1-D39vETmaI4DHABUoIxC05i5oTj-1yOE_lkqlZ9ShfubJrn0GiR3XM_ntAx9PtbwNhzN0Nufmf1M0ST7VBIiYQGv2Osjy6QQ1fk-JWKmA8v4rkxQGEZJdrMpjnG4JNxQ0W4dIVJtMAxR6EQT46VSNoqMYnzrKAChhSmHq622VrBltWBi-FrsYbtCPWo60lif5l-8zx2SAenD5k0MHV8iDKL3yhCss5Z9t9ZT0rP-2GIdA5EDFDTMRtUlf_0EQD-39qVHLaN21HtpPCilWjEcsxXWOUH624FinvyrDrbFTu0Z4BVSij-Wh4yDvd12QurjNVxhl44Z0evsytw",
  "refresh_token" : "rhHcP3ihD2U9QYwQvgQ9Huf-ZmVdJkSzyg21Co7FCxpoBCsql29L7e3wDyoK0gAFjWrEzQs8w92WBVO3wUPQCu7letKQgavNJBPfv22Vwq_vBo9muF-yAj4d8qWWz1DY",
  "token_type" : "Bearer",
  "expires_in" : 299
}
Path Type Description

access_token

String

Access Token

refresh_token

String

Refresh Token

token_type

String

토큰 타입 (예: Bearer)

expires_in

Number

토큰 만료까지 남은 시간 (초)

토큰 검증

요청 예제

Name Description

Authorization

client_id:client_secret 을 Basic Auth 로 변환 한 값

Parameter Description

token

Access Token or Refresh Token

token_type_hint

access_token or refresh_token

POST /oauth2/introspect HTTP/1.1
Authorization: Basic bGFib3JhdG9yeTpzZWNyZXQ=
Content-Type: application/x-www-form-urlencoded; charset=ISO-8859-1
Host: auth.stable.codes
Content-Length: 675

token=eyJraWQiOiIwOTQzZThlYS00NDZhLTQzOTktOWNjNC1lYjBmYTE5NGYyYTIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoibGFib3JhdG9yeSIsIm5iZiI6MTc1MTI0NjMyMSwiaXNzIjoiaHR0cDovL2F1dGguc3RhYmxlLmNvZGVzIiwiZXhwIjoxNzUxMjQ2NjIxLCJpYXQiOjE3NTEyNDYzMjEsImp0aSI6IjZiMzg2YTBhLTUzYWYtNGI0Ni05ZjM4LTU2N2QwZGRhZDkwNiJ9.aJ0udM1-D39vETmaI4DHABUoIxC05i5oTj-1yOE_lkqlZ9ShfubJrn0GiR3XM_ntAx9PtbwNhzN0Nufmf1M0ST7VBIiYQGv2Osjy6QQ1fk-JWKmA8v4rkxQGEZJdrMpjnG4JNxQ0W4dIVJtMAxR6EQT46VSNoqMYnzrKAChhSmHq622VrBltWBi-FrsYbtCPWo60lif5l-8zx2SAenD5k0MHV8iDKL3yhCss5Z9t9ZT0rP-2GIdA5EDFDTMRtUlf_0EQD-39qVHLaN21HtpPCilWjEcsxXWOUH624FinvyrDrbFTu0Z4BVSij-Wh4yDvd12QurjNVxhl44Z0evsytw&token_type_hint=access_token

응답 예제

HTTP/1.1 200 OK
x-content-type-options: nosniff
x-xss-protection: 0
cache-control: no-cache, no-store, max-age=0, must-revalidate
pragma: no-cache
expires: 0
x-frame-options: DENY
content-type: application/json
date: Mon, 30 Jun 2025 01:18:41 GMT
server: Google Frontend
Transfer-Encoding: chunked
Content-Length: 278

{
  "active" : true,
  "sub" : "user",
  "aud" : [ "laboratory" ],
  "nbf" : 1751246321,
  "iss" : "http://auth.stable.codes",
  "exp" : 1751246621,
  "iat" : 1751246321,
  "jti" : "6b386a0a-53af-4b46-9f38-567d0ddad906",
  "client_id" : "laboratory",
  "token_type" : "Bearer"
}
Path Type Description

active

Boolean

토큰 활성화 여부

sub

String

토큰 주체 (사용자 ID)

aud

Array

토큰 대상 (클라이언트 ID 목록)

nbf

Number

토큰 사용 가능 시작 시간

iss

String

토큰 발급자 (Issuer)

exp

Number

토큰 만료 시간

iat

Number

토큰 발급 시간

jti

String

토큰 식별자 (JWT ID)

client_id

String

클라이언트 ID

token_type

String

토큰 타입 (예: Bearer)

토큰 갱신

Access Token 만료 시 Refresh Token 을 이용한 토큰 갱신

요청 예제

Name Description

Authorization

client_id:client_secret 을 Basic Auth 로 변환 한 값

Parameter Description

grant_type

refresh_token 으로 고정

refresh_token

Refresh Token

POST /oauth2/token HTTP/1.1
Authorization: Basic bGFib3JhdG9yeTpzZWNyZXQ=
Content-Type: application/x-www-form-urlencoded; charset=ISO-8859-1
Host: auth.stable.codes
Content-Length: 167

grant_type=refresh_token&refresh_token=rhHcP3ihD2U9QYwQvgQ9Huf-ZmVdJkSzyg21Co7FCxpoBCsql29L7e3wDyoK0gAFjWrEzQs8w92WBVO3wUPQCu7letKQgavNJBPfv22Vwq_vBo9muF-yAj4d8qWWz1DY

응답 예제

HTTP/1.1 200 OK
x-content-type-options: nosniff
x-xss-protection: 0
cache-control: no-cache, no-store, max-age=0, must-revalidate
pragma: no-cache
expires: 0
x-frame-options: DENY
content-type: application/json;charset=UTF-8
date: Mon, 30 Jun 2025 01:18:42 GMT
server: Google Frontend
Transfer-Encoding: chunked
Content-Length: 866

{
  "access_token" : "eyJraWQiOiIwOTQzZThlYS00NDZhLTQzOTktOWNjNC1lYjBmYTE5NGYyYTIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoibGFib3JhdG9yeSIsIm5iZiI6MTc1MTI0NjMyMiwiaXNzIjoiaHR0cDovL2F1dGguc3RhYmxlLmNvZGVzIiwiZXhwIjoxNzUxMjQ2NjIyLCJpYXQiOjE3NTEyNDYzMjIsImp0aSI6ImM2NjYxODM5LWIxMjctNGE2NC1hN2QzLTdiYjBmMjc0NWMwNCJ9.OwwQtK4u3mfdM-NTFDAyDnIRh0MO4ltuCLQeKN_PCqRLFpQfXMf5Gi3_9VdRbqyUhBNbiYmaYySgjx4GzFkXzobmINCBRFKr4IW1LKsOWF3YHEqKw4yhxMHVVYWAn9rj85AlJ85GNSgHHGFi0Fn_OfqabhYIJmCj3djt3YxkKq1XTvL935iOfspITCH3-aR3PUjNtLMWVu0lbuwoxyT9ykmcKlQ9NujevcJyQDZnmJ_XcnP5D3Gfxe1e-Jjyy1h-dOxxZn-8ZtLwv9L-CN199LWk5EPc_4Gj54CbmkxqbUSbe1sG8P1WEm_OpZenzFqhU2LkmfcN-rBA4uG-qM9dqg",
  "refresh_token" : "rhHcP3ihD2U9QYwQvgQ9Huf-ZmVdJkSzyg21Co7FCxpoBCsql29L7e3wDyoK0gAFjWrEzQs8w92WBVO3wUPQCu7letKQgavNJBPfv22Vwq_vBo9muF-yAj4d8qWWz1DY",
  "token_type" : "Bearer",
  "expires_in" : 299
}
Path Type Description

access_token

String

Access Token

refresh_token

String

Refresh Token

token_type

String

토큰 타입 (예: Bearer)

expires_in

Number

토큰 만료까지 남은 시간 (초)

토큰 파기

Access Token 혹은 Refresh Token 을 명시적 서버요청으로 파기 할 수 있는 기능

요청 예제

Name Description

Authorization

client_id:client_secret 을 Basic Auth 로 변환 한 값

Parameter Description

token

Token 값

token_type_hint

Token 형태 (access_token, refresh_token)

POST /oauth2/revoke HTTP/1.1
Authorization: Basic bGFib3JhdG9yeTpzZWNyZXQ=
Content-Type: application/x-www-form-urlencoded; charset=ISO-8859-1
Host: auth.stable.codes
Content-Length: 675

token=eyJraWQiOiIwOTQzZThlYS00NDZhLTQzOTktOWNjNC1lYjBmYTE5NGYyYTIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoibGFib3JhdG9yeSIsIm5iZiI6MTc1MTI0NjMyMSwiaXNzIjoiaHR0cDovL2F1dGguc3RhYmxlLmNvZGVzIiwiZXhwIjoxNzUxMjQ2NjIxLCJpYXQiOjE3NTEyNDYzMjEsImp0aSI6IjZiMzg2YTBhLTUzYWYtNGI0Ni05ZjM4LTU2N2QwZGRhZDkwNiJ9.aJ0udM1-D39vETmaI4DHABUoIxC05i5oTj-1yOE_lkqlZ9ShfubJrn0GiR3XM_ntAx9PtbwNhzN0Nufmf1M0ST7VBIiYQGv2Osjy6QQ1fk-JWKmA8v4rkxQGEZJdrMpjnG4JNxQ0W4dIVJtMAxR6EQT46VSNoqMYnzrKAChhSmHq622VrBltWBi-FrsYbtCPWo60lif5l-8zx2SAenD5k0MHV8iDKL3yhCss5Z9t9ZT0rP-2GIdA5EDFDTMRtUlf_0EQD-39qVHLaN21HtpPCilWjEcsxXWOUH624FinvyrDrbFTu0Z4BVSij-Wh4yDvd12QurjNVxhl44Z0evsytw&token_type_hint=access_token

응답 예제

HTTP/1.1 200 OK
x-content-type-options: nosniff
x-xss-protection: 0
cache-control: no-cache, no-store, max-age=0, must-revalidate
pragma: no-cache
expires: 0
x-frame-options: DENY
x-cloud-trace-context: 8b2effe98c6fd2e115c770d692e359b6
date: Mon, 30 Jun 2025 01:18:42 GMT
content-type: text/html
server: Google Frontend