본문으로 바로가기

728x90

지난 포스팅에서 Jwt를 이용하여 인증서버를 구성하는 방법을 설명하였습니다. 이 시스템에서는 서비스영역과 인증영역이 병합되어 있어 추후 시스템의 확장 또는 제공 서비스에 따라 시스템을 분리하기가 어렵습니다. 본 포스팅에서는Authorization Server와 Resource Server를 분리하는 방법에 대해서 설명합니다.

Resource Server 분리하기

Jwt 방식을 사용하는 경우

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.util.FileCopyUtils;

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
	private static final Logger log = LoggerFactory.getLogger(ResourceServerConfig.class);
	
	@Override
	public void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests().anyRequest().authenticated()
			.and()
			.requestMatchers().antMatchers("/api/**");
	}
	
	@Bean
	public TokenStore tokenStore() {
		return new JwtTokenStore(accessTokenConverter());
	}

	@Bean
	public JwtAccessTokenConverter accessTokenConverter() {
		JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
		Resource resource = new ClassPathResource("kr.ejsoft.oauth2.publickey.txt");
		String publickey = null;
		try {
			publickey = asString(resource);
			
			log.info("Jwt Verifier Key : {} ", publickey);
			
		} catch(final IOException e) {
			throw new RuntimeException(e);
		}
		
		converter.setVerifierKey(publickey);
		return converter;
	}
	
	public static String asString(Resource resource) throws IOException {
		Reader reader = new InputStreamReader(resource.getInputStream(), "UTF-8");
		return FileCopyUtils.copyToString(reader);
	}
}

 

Jwt 방식을 사용하지 않는 경우

Jwt 방식을 사용하지 않는 경우에는 Resource 서버에서 token 검증 요청을 Authorization 서버로 보내게 됩니다. 토큰의 검증을 위해 Authorization Server의 /oauth/check_token 경로를 호출하며 호출전에 미리 접근권한이 설정이 되어 있어야 합니다.

 

/oauth/check_token 활성화

AuthorizationServerConfig에서 아래와 같이 .checkTokenAccess("isAuthenticated()")을 추가하여 접근권한을 부여합니다. 기본적으로 접근을 할 수 없습니다.

@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
    security.tokenKeyAccess("permitAll()")
            .checkTokenAccess("isAuthenticated()") //allow check token
            .allowFormAuthenticationForClients();
}

ResourceServerConfig 수정

설정시에는 Authorization Server의 경로와 Client Id, Client Secret를 전달해주어야 합니다.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.provider.token.RemoteTokenServices;

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
	private static final Logger log = LoggerFactory.getLogger(ResourceServerConfig.class);
	
	@Override
	public void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests().anyRequest().authenticated()
			.and()
			.requestMatchers().antMatchers("/api/**");
	}

	@Primary
	@Bean
	public RemoteTokenServices tokenService() {
		RemoteTokenServices tokenService = new RemoteTokenServices();
		tokenService.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
		tokenService.setClientId("foo");
		tokenService.setClientSecret("bar");
		return tokenService;
	}
}

여기서 의문이 생겼습니다. "여러 종류의 Client가 동일 서비스를 사용하지 않게 한 이유는 뭘까? 왜 Client Id/Secret를 지정해야 하지?" Stackoverflow에서 찾은 결론은 "ResourceServer에서는 사용자 위주로 권한을 부여하기 때문에 Client 식별값이 없으면 토큰에 대한 명확한 사용자를 식별할 수 없다" 입니다. 아래의 주소를 찾고하십시오.
Why Resource Server has to know client_id in Spring OAuth2?
 

Why Resource Server has to know client_id in Spring OAuth2?

I'm implementing OAuth2 authorization using Spring Boot. I have already Authorization Server and Resource Server, now I want to access resources from Resource Server using client_credentials grant ...

stackoverflow.com

 

서비스 제공 예시

Service Controller 추가

서비스를 제공하기 위한 임의의 서비스 컨트롤러 객체를 생성합니다.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;


@Controller
public class RestResourceSample {
	private static final Logger log = LoggerFactory.getLogger(RestResourceSample.class);

	@RequestMapping(value="/api/users", method=RequestMethod.GET)
	@ResponseBody
	public String  profile() {
		// Build some dummy data to return for testing
		Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
		log.debug("Principal : {}", principal);
		
		String username = "";
		String email = "";
		if(principal instanceof User) {
			User user = (User) principal;
			username = user.getUsername();
			email = user.getUsername() + "@email.com";
		} else {
			username = (String) principal;
			email = username + "@email.com";
		}
		
		UserProfile profile = new UserProfile();
		profile.setName(username);
		profile.setEmail(email);

		log.debug("/api/users : {}", profile);
		
		return profile.toJson();
	}
}

class UserProfile {
	private String name;
	private String email;

	// Setters and getters
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}

	@Override
	public String toString() {
		return "UserProfile [name=" + name + ", email=" + email + "]";
	}

	public String toJson() {
		return String.format("{\"name\" : \"%s\", \"email\" : \"%s\", }", name, email);
	}
}

 

서비스 호출

GET방식으로 서비스를 요청합니다.

요청주소 : http://localhost:9091/api/user

 

요청헤더 : 

Content-Type=application/x-www-form-urlencoded 
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzQ4NDA5NTUsInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJ1c2VyIl0sImp0aSI6ImQ2NTU1MzAzLTllMzAtNDBkMi1hYWU4LTIzZjNjNDIwNGEwNSIsImNsaWVudF9pZCI6ImNsaWVudCIsInNjb3BlIjpbInJlYWRfcHJvZmlsZSJdfQ.rxXCbRXQ0vOkKkaMNh4bBq2bHNtewtF4VqX9PCEaBIIjLdZHpxQ_vNl7yLsfJqi5fuGnpibekkXpeKtbiq817NYWnYvoXq_WRpFrJIcHritqWCWf_8yzqP7ElLsFui7Fm3nI8Bbd9K1Pkyeo08LktQe1lBuNifm3ZLR_TOUKu_pGPHXXum0-LRA_teJ0dKzhdWlwT7OLxrElR0pi7VUaHzoYFWrI7psXFHAERXpoA0YfuJGAXLYrj5nbgFgnmWZ3UnkEy9oE43d0_Cyi5QAmKgjp5pYVXOjRppC-h4F-3ZxamVFwCPGWcJOXbg9htVqU3N6n8fippiGDdb_m-ZXOIA

 

헤더의 Authorization 값은 발급된 Access Token을 사용합니다.

 

요청본문

GET /api/users HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzQ4NDA5NTUsInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJ1c2VyIl0sImp0aSI6ImQ2NTU1MzAzLTllMzAtNDBkMi1hYWU4LTIzZjNjNDIwNGEwNSIsImNsaWVudF9pZCI6ImNsaWVudCIsInNjb3BlIjpbInJlYWRfcHJvZmlsZSJdfQ.rxXCbRXQ0vOkKkaMNh4bBq2bHNtewtF4VqX9PCEaBIIjLdZHpxQ_vNl7yLsfJqi5fuGnpibekkXpeKtbiq817NYWnYvoXq_WRpFrJIcHritqWCWf_8yzqP7ElLsFui7Fm3nI8Bbd9K1Pkyeo08LktQe1lBuNifm3ZLR_TOUKu_pGPHXXum0-LRA_teJ0dKzhdWlwT7OLxrElR0pi7VUaHzoYFWrI7psXFHAERXpoA0YfuJGAXLYrj5nbgFgnmWZ3UnkEy9oE43d0_Cyi5QAmKgjp5pYVXOjRppC-h4F-3ZxamVFwCPGWcJOXbg9htVqU3N6n8fippiGDdb_m-ZXOIA
Content-Type: application/x-www-form-urlencoded
User-Agent: http4e/5.0.12
Host: localhost:9091

 

응답본문

HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 47
Date: Wed, 27 Nov 2019 07:20:01 GMT

{"name" : "user", "email" : "user@email.com", }

 

참고사이트

 

소스코드

소스코드는 여기에서 다운로드 가능합니다.

728x90