이전 글에서 OAuth 2의 개념과 구현 시 고려 사항들에 대해 살펴봤습니다. 이번 글에서는 SSO 환경을 구성하기 위해 어떻게 OAuth 2를 확장하는 지와 스프링부트 및 스프링 시큐리티 OAuth로 SSO 환경을 구축하는 방법에 대해 설명하겠습니다.

1. SSO 환경 구성을 위한 OAuth 확장

OAuth를 사용해서 SSO를 구성하기 위해 인가 승인 유형 중 인가 코드 승인 방식을 사용하였고, OAuth가 기본으로 제공하는 2개의 Endpoint와 별도 구현한 2개의 Endpoint로 SSO 서버를 구성하였습니다.  SSO 환경에서의 로그인, SSO 처리, 로그아웃 시나리오를 차례대로 살펴보겠습니다.

1.1. 로그인 시나리오

구성한 SSO 환경의 로그인 시나리오는 아래와 같습니다. 각 시스템들은 인증되지 않은 사용자의 요청을 SSO 서버로 리다이렉트합니다.

  1. 유효한 인증 이력이 없는 인증 요청에 대해 SSO 서버는 로그인 페이지로 응답합니다.
  2. 유효한 로그인 정보로 로그인 요청을 받은 SSO 서버는 이 요청을 시스템으로 리다이렉트 (인가 코드 포함)합니다.
  3. 인가 코드를 포함한 요청을 받은 시스템은 SSO 서버로부터 접근 토큰과 사용자 정보를 획득하여 해당 사용자의 로그인 처리를 한 후 원래 요청의 결과로 응답합니다.

1.2. SSO 처리 시나리오

SSO 구성 환경에서 SSO 처리 시나리오는 아래와 같습니다. 한 시스템을 통해 로그인한 사용자가 다른 시스템의 인증이 요구되는 페이지를 요청하면 해당 시스템은 이 요청을 SSO 서버로 리다이렉트 합니다.

  1. 유효한 인증 이력이 있는 요청에 대해 SSO 서버는 이 요청을 시스템으로 리다이렉트 (인가 코드 포함)합니다.
  2. 인가 코드를 포함한 요청을 받은 시스템은 SSO 서버로부터 접근 토큰과 사용자 정보를 획득하여 해당 사용자의 로그인 처리를 한 후 원래 요청의 결과로 응답합니다.

1.3. 로그아웃 시나리오

로그인한 사용자가 SSO 환경에서 로그아웃 시 처리 과정은 아래와 같습니다.

  1. 로그인한 사용자가 한 시스템에게 로그아웃을 요청하면 시스템은 이 요청을 SSO 서버의 로그아웃 페이지로 리다이렉트 합니다.
  2. 로그아웃 요청을 받은 SSO 서버는 해당 사용자로 로그인 된 모든 시스템에게 로그아웃을 요청합니다.

2. SSO 환경 구축

앞에서 설명한 내용으로 SSO 환경을 구축하기 위해 스프링 부트와 스프링 시큐리티 OAuth2를 사용하였습니다. 먼저 SSO 서버 구축에 대해 설명한 후 클라이언트 시스템 구축에 대해 알아보겠습니다.

2.1. SSO 서버 구축

2.1.1. 프로젝트 구성

SSO 서버 프로젝트의 전체 구조는 아래 그림과 같습니다. 프로젝트 구성을 간단히 하기 위해 테스트 관련 사항들은 기술하지 않았습니다.

메이븐(Maven)으로 프로젝트를 생성한 후 스프링 부트와 기타 라이브러리를 사용할 수 있도록 아래와 같이 pom.xml 파일을 편집합니다.

<project ......>
  ......
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.5.2.RELEASE</version>
  </parent>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.security.oauth</groupId>
      <artifactId>spring-security-oauth2</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-jpa</artifactId>
      </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-thymeleaf</artifactId>
      </dependency>
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <optional>true</optional>
    </dependency>

    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.httpcomponents</groupId>
      <artifactId>httpclient</artifactId>
    </dependency>

    <dependency>
      <groupId>org.webjars</groupId>
      <artifactId>jquery</artifactId>
      <version>3.2.0</version>
    </dependency>
    <dependency>
      <groupId>org.webjars</groupId>
      <artifactId>bootstrap</artifactId>
      <version>3.3.7</version>
    </dependency>
  </dependencies>
  ......
</project>

2.1.2. 스프링 부트 애플리케이션 설정 파일

src/main/resources 폴더 하위에 애플리케이션 설정 파일인 application.yml을 아래와 같이 작성합니다.

server:
  port: 8080
  session:
    cookie:
      name: APPSESSIONID

spring.h2.console:
  enabled: true
  path: /h2-console

spring:
  jpa:
    generate-ddl: false
    hibernate:
      ddl-auto: none

logging.level:
  root: warn
  org.springframework:
    web: warn
    security: info
    boot: info
  org.hibernate:
    SQL: warn
  com.nextree: debug

애플리케이션이 동작할 톰캣 서버의 포트를 8080으로, 세션 쿠키의 이름을 “APPSESSIONID”로 설정하였습니다.

SSO 서버 프로젝트에서 기본 데이터베이스로 H2 데이터베이스를 사용하는데 H2 데이터베이스의 콘솔을 사용하도록 하고 콘솔의 URL 경로를 “/h2-console”로 설정하였습니다.

또한, 데이터베이스에 저장된 클라이언트 및 접근 토큰 정보에 접근하기 위해 JPA를 사용며 애플리케이션 구동 시 DDL을 생성 하지 않도록 설정하였습니다.

2.1.3. 스프링 부트 애플리케이션 클래스

아래와 같이 SpringBootApplication 어노테이션을 적용한 클래스를 작성하였습니다. 이 클래스를 구동시키면 SSO 서버가 시작됩니다.

@SpringBootApplication
public class SsoServerApplication {
  //
  public static void main(String[] args) {
    //
    SpringApplication.run(SsoServerApplication.class, args);
  }
}

2.1.4. 웹 보안 설정 클래스

스프링 시큐리티 설정을 위해 EnableWebSecurity 어노테이션을 적용한 클래스를 아래와 같이 정의하였습니다.

@Configuration
@EnableWebSecurity
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
  //
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    //
    http
      .authorizeRequests()
        .antMatchers("/home", "/webjars/**", "/css/**", "/userInfo").permitAll()
        .anyRequest().authenticated()
        .and()
      .formLogin()
        .loginProcessingUrl("/login")
        .loginPage("/loginForm")
        .permitAll()
        .and()
      .csrf()
        .requireCsrfProtectionMatcher(new AntPathRequestMatcher("/user*"))
        .disable()
      .logout()
        .permitAll();
  }

  @Autowired
  public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
    //
    auth
      .inMemoryAuthentication()
      .withUser("tsong").password("aaa").roles("USER").and()
      .withUser("jmpark").password("aaa").roles("USER").and()
      .withUser("jkkang").password("aaa").roles("USER").and()
      .withUser("test").password("aaa").roles("USER");
  }
}

configure() 메소드에서 인증이 필요 없는 URI 등록, 폼 로그인 등의 설정을 정의하였고, configureGlobal() 메소드에서는 인증 계정 관리를 메모리 상에서 처리하도록 했으며 테스트용 계정 정보를 설정하였습니다.

2.1.5. 웹 MVC 설정 클래스

서비스 레이어의 처리가 필요 없는 요청(홈 페이지, 로그인 폼 페이지)을 설정 하기 위해 WebMvcConfig 클래스를 아래와 같이 정의하였습니다.

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
  //
  @Override
  public void addViewControllers(ViewControllerRegistry registry) {
    //
    registry.addViewController("/home").setViewName("home");
    registry.addViewController("/loginForm").setViewName("loginForm");
  }
}

2.1.6. 인가 서버 설정 클래스

스프링 부트로 애플리케이션 작성 시 EnableAuthorizationServer 어노테이션을 적용한 클래스를 정의하면 OAuth 인가 서버로 동작하게 할 수 있습니다.

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
  //
  @Autowired
  private AuthorizationCodeServices authorizationCodeServices;

  @Autowired
  private ApprovalStore approvalStore;

  @Autowired
  private TokenStore tokenStore;

  @Override
  public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    //
    endpoints
      .tokenStore(tokenStore)
      .authorizationCodeServices(authorizationCodeServices)
      .approvalStore(approvalStore);
  }

  @Bean
  public AuthorizationCodeServices jdbcAuthorizationCodeServices(DataSource dataSource) {
    //
    return new JdbcAuthorizationCodeServices(dataSource);
  }

  @Bean
  public ApprovalStore jdbcApprovalStore(DataSource dataSource) {
    //
    return new JdbcApprovalStore(dataSource);
  }

  @Bean
  @Primary
  public ClientDetailsService jdbcClientDetailsService(DataSource dataSource) {
    //
    return new JdbcClientDetailsService(dataSource);
  }

  @Bean
  public TokenStore jdbcTokenStore(DataSource dataSource) {
    //
    return new JdbcTokenStore(dataSource);
  }
}

SSO 인증 대상 클라이언트, 인가 코드 및 발급한 토큰 정보를 데이터베이스에 저장하기 위해 인가 서버 설정 클래스에 스프링 시큐리티 OAuth2에서 제공하는 JdbcAuthorizationCodeServices, JdbcApprovalStore, JdbcClientDetailsService, JdbcTokenStore를 빈으로 설정하였습니다.

2.1.7. 데이터베이스 스키마 생성 및 초기 데이터 적재 스크립트

인가 서버 클래스 정의에서 등록한 데이터베이스 관련 빈들에서 사용하는 데이터베이스 스키마를 생성하기 위해 src/main/resources 디렉토리에 DDL 문이 포함된 schema.sql 파일을 작성하면 됩니다.

또한, 초기 데이터 적재가 필요한 경우 src/main/resources 디렉토리에 DML 문이 포함된 data.sql 파일을 작성하면 됩니다. 스프링 부트 애플리케이션 구동 시 이 스크립트들을 실행시켜 스키마를 생성하고 초기 데이터를 적재합니다. 아래는 schema.sql, data.sql 파일 내용입니다.

create table oauth_client_details (
  client_id VARCHAR(256) PRIMARY KEY,
  resource_ids VARCHAR(256),
  client_secret VARCHAR(256),
  scope VARCHAR(256),
  authorized_grant_types VARCHAR(256),
  web_server_redirect_uri VARCHAR(256),
  logout_uri VARCHAR(256),
  base_uri VARCHAR(256),
  authorities VARCHAR(256),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additional_information VARCHAR(4096),
  autoapprove VARCHAR(256)
);

create table oauth_access_token (
  token_id VARCHAR(256),
  token LONGVARBINARY,
  authentication_id VARCHAR(256) PRIMARY KEY,
  user_name VARCHAR(256),
  client_id VARCHAR(256),
  authentication LONGVARBINARY,
  refresh_token VARCHAR(256)
);

create table oauth_refresh_token (
  token_id VARCHAR(256),
  token LONGVARBINARY,
  authentication LONGVARBINARY
);

create table oauth_code (
  code VARCHAR(256), authentication LONGVARBINARY
);

create table oauth_approvals (
  userId VARCHAR(256),
  clientId VARCHAR(256),
  scope VARCHAR(256),
  status VARCHAR(10),
  expiresAt TIMESTAMP,
  lastModifiedAt TIMESTAMP
);

insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System1_id', 'System1_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18010/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18010/logout', 'http://localhost:18010/me');

insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System2_id', 'System2_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18020/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18020/logout', 'http://localhost:18020/me');

insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System3_id', 'System3_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18030/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18030/logout', 'http://localhost:18030/me');

insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System4_id', 'System4_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18040/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18040/logout', 'http://localhost:18040/me');

2.1.8. 엔터티 클래스

데이터베이스에 저장되어 있는 클라이언트 및 접근 토큰 정보를 조회하기 JPA를 사용하였고 이 때 사용하는 엔터티 클래스들을 아래와 같이 정의하였습니다.

@Entity
@Table(name="oauth_client_details")
public class Client {
  //
  @Id
  @Column(name="client_id")
  private String clientId;

  @Column(name="web_server_redirect_uri")
  private String redirectUri;

  @Column(name="logout_uri")
  private String logoutUri;

  @Column(name="base_uri")
private String baseUri;

// getter/setter
}
@Entity
@Table(name="oauth_access_token")
public class AccessToken {
  //
  @Id
  private String tokenId;

  private String token;

  @Column(name="user_name")
  private String userName;

  @Column(name="authentication_id")
  private String authenticationId;

  @Column(name="client_id")
  private String clientId;

  private String authentication;

  // getter /setter
}

2.1.9. 데이터베이스 처리를 위한 JPA 리파지토리

Client, AccessToken 엔터티들의 처리를 위해 스프링에서 제공하는 CrudRepository를 사용하는 인터페이스들을 정의하였습니다.

public interface ClientRepository extends CrudRepository<Client, String> {
  //
}
public interface AccessTokenRepository extends CrudRepository<AccessToken, String> {
  //
  AccessToken findByTokenIdAndClientId(String tokenId, String clientId);

  int deleteByUserName(String userName);

  List<AccessToken> findByUserName(String userName);
}

2.1.10. 서비스 인터페이스 및 서비스 구현 클래스

접근 토큰 값과 클라이언트 아이디로 AccessToken을 조회하는 메소드와 클라이언트 아이디와 사용자 이름으로 로그아웃을 처리하는 메소드를 정의하는 서비스 인터페이스를 작성하였습니다.

public interface SsoService {
  //
  AccessToken getAccessToken(String token, String clientId);

  String logoutAllClients(String clientId, String userName);
}

앞에서 정의한 서비스 인터페이스를 구현한 클래스는 아래와 같습니다.

@Service("ssoService")
public class SsoServiceImpl implements SsoService {
  //
  private static final Logger log = LoggerFactory.getLogger(SsoServiceImpl.class);

  @Autowired
  private AccessTokenRepository accessTokenRepository;

  @Autowired
  private ClientRepository clientRepository;

  @Override
  public AccessToken getAccessToken(String token, String clientId) {
    //
    String tokenId = extractTokenId(token);

    return accessTokenRepository.findByTokenIdAndClientId(tokenId, clientId);
  }

  private String extractTokenId(String value) {
    //
    if (value == null) {
      return null;
    }

    try {
      MessageDigest digest = MessageDigest.getInstance("MD5");

      byte[] bytes = digest.digest(value.getBytes("UTF-8"));
      return String.format("%032x", new BigInteger(1, bytes));
    }
    catch (NoSuchAlgorithmException e) {
      throw new IllegalStateException("MD5 algorithm not available.  Fatal (should be in the JDK).");
    }
    catch (UnsupportedEncodingException e) {
      throw new IllegalStateException("UTF-8 encoding not available.  Fatal (should be in the JDK).");
    }
  }

  @Override
  @Transactional
  public String logoutAllClients(String clientId, String userName) {
    //
    requestLogoutToAllClients(userName);

    removeAccessTokens(userName);

    Client client = clientRepository.findOne(clientId);

    return client.getBaseUri();
  }

  private void requestLogoutToAllClients(String userName) {
    //
    List<AccessToken> tokens = accessTokenRepository.findByUserName(userName);

    for (AccessToken token : tokens) {
      requestLogoutToClient(token);
    }
  }

  private void requestLogoutToClient(AccessToken token) {
    //
    Client client = clientRepository.findOne(token.getClientId());

    String logoutUri = client.getLogoutUri();
    String authorizationHeader = null;

    Map<String, String> paramMap = new HashMap<>();
    paramMap.put("tokenId", token.getTokenId());
    paramMap.put("userName", token.getUserName());

    HttpPost post = buildHttpPost(logoutUri, paramMap, authorizationHeader);
    executePostAndParseResult(post, Object.class);
  }
}

접근 토큰을 조회하는 getAccessToken() 메소드는 아래 작업들을 처리하도록 구현하였습니다.

  1. 인자로 받은 접근 토큰 값을 MD5 해쉬 값으로 변환합니다.
  2. 변환된 값과 클라이언트 아이디 인자로 AccessTokenRepository 객체의 findByTokenIdAndClientId() 메소드를 호출하여 데이터베이스에 저장되어 있는 AccessToken 객체를 조회합니다.
  3. 조회한 AccessToken 객체를 리턴합니다.

SSO로 로그인한 사용자의 로그아웃을 처리하는 logoutAllClients() 메소드는 아래 작업들을 처리합니다.

  1. AccessTokenRepository 객체의 findByUserName() 메소드를 사용해서 사용자 이름으로 발급된 AccessToken 객체들을 조회합니다.
  2. 조회한 AccessToken 객체의 클라이언트 아이디로 ClientRepository의 findOne() 메소드를 호출하여 Client 객체를 조회합니다.
  3. Client의 logoutUri로 토큰 아이디와 사용자 이름을 포함한 Http Post 요청을 전송합니다.
  4. AccessTokenRepository 객체의 deleteByUserName() 메소드를 호출하여 저장된 접근 토큰들을 삭제합니다.

2.1.11. 확장한 Endpoint를 위한 웹 컨트롤러 클래스

사용자 정보 조회, 로그아웃 Endpoint를 위한 웹 컨트롤러 클래스는 아래와 같습니다.

@Controller
public class SsoController {
  //
  @Autowired
  private SsoService ssoService;

  @RequestMapping(value="/userInfo", method=RequestMethod.POST)
  @ResponseBody
  public UserInfoResponse userInfo(@RequestParam(name="token") String token,
      @RequestParam(name="clientId") String clientId) {
    //
    AccessToken accessToken = ssoService.getAccessToken(token, clientId);

    UserInfoResponse response = new UserInfoResponse();
    if (accessToken == null) {
      //
      response.setResult(false);
      response.setMessage("사용자 정보를 조회할 수 없습니다.");
    }
    else {
      //
      response.setUserName(accessToken.getUserName());
    }

    return response;
  }

  @RequestMapping(value="/userLogout", method=RequestMethod.GET)
  public String userLogout(@RequestParam(name="clientId") String clientId,
      HttpServletRequest request) {
    //
    String userName = request.getRemoteUser();
    String baseUri = ssoService.logoutAllClients(clientId, userName);

    request.getSession().invalidate();

    return "redirect:" + baseUri;
  }
}

접근 토큰을 발급받은 클라이언트의 사용자 정보 조회를 처리하는 userInfo() 메소드는 SsoService의 getAccessToken() 메소드를 호출하여 AccessToken 객체를 조회한 후 그 결과를 UserInfoResponse 객체에 설정하여 리턴합니다.

클라이언트의 로그아웃 요청을 처리하는 userLogout() 메소드는 SsoService의 logoutAllClients() 메소드를 호출한 후 세션의 invalidate() 메소드를 호출하여 사용자 브라우저의 인증된 세션을 무효화합니다.

2.2. 클라이언트 시스템 구축

클라이언트 시스템에서 SSO 환경을 구축하기 위해 구현해야 할 항목들은 아래와 같습니다.

  • SSO 요청 시 SSO 서버의 인가(Authorization) Endpoint로 리다이렉트 하기 위한 웹 요청 처리
  • SSO 서버의 클라이언트 인가 처리 후 인가 코드 값을 포함하는 리다이렉트 웹 요청 처리
  • 사용자의 로그아웃 웹 요청 처리
  • SSO 서버의 로그아웃 웹 요청 처리

위에서 기술한 웹 요청 처리를 스프링 MVC로 어떻게 구현했는지 순서대로 살펴보겠습니다.

2.2.1. 클라이언트 인가 Endpoint로 리다이렉트하기 위한 웹 요청 처리

@RequestMapping(value="/sso", method=RequestMethod.GET)
public String sso(HttpServletRequest request) {
  //
  String state = UUID.randomUUID().toString();
  request.getSession().setAttribute("oauthState", state);

  StringBuilder builder = new StringBuilder();
  builder.append("redirect:");
  builder.append("http://localhost:8080/oauth/authorize");
  builder.append("?response_type=code");
  builder.append("&client_id=");
  builder.append(getOAuthClientId());
  builder.append("&redirect_uri=");
  builder.append(getOAuthRedirectUri());
  builder.append("&scope=");
  builder.append("read");
  builder.append("&state=");
  builder.append(state);

  return builder.toString();
}

SSO 서버로 리다이렉트하기 전 상태(state) 값을 랜덤하게 생성한 후 세션에 “oauthState”를 키 값으로 이를 저장합니다. responsetype, clientid, redirect_uri, scope, state 파라미터들을 설정한 리다이렉트 URI를 구성한 후 리턴합니다.

2.2.2. 인가 코드 값을 포함하는 리다이렉트 웹 요청 처리

@RequestMapping(value="/oauthCallback", method=RequestMethod.GET)
public String oauthCallback(@RequestParam(name="code") String code,
    @RequestParam(name="state") String state,
    HttpServletRequest request, ModelMap map) {
  //
  String oauthState = (String)request.getSession().getAttribute("oauthState");
  request.getSession().removeAttribute("oauthState");

  TokenRequestResult tokenRequestResult = null;
  if (oauthState == null || oauthState.equals(state) == false) {
    //
    tokenRequestResult = new TokenRequestResult();
    tokenRequestResult.setError("not matched state");
  }
  else {
    tokenRequestResult = oauthService.requestAccessTokenToAuthServer(code, request);
  }

  if (tokenRequestResult.getError() == null) {
    return "redirect:/me";
  }
  else {
    map.put("result", tokenRequestResult);
    return "authResult";
  }
}

SSO 서버로부터 리다이렉트된 이 요청은 code값과 state값이 파라미터로 넘어옵니다. 먼저, 세션에 저장한 oauthState값과 SSO 서버로부터 넘어온 state값이 동일한지 체크합니다. 동일한 경우에만 code값과 request 값을 인자로 AuthService의 requestAccessTokenToAuthServer() 메소드를 호출합니다.

public TokenRequestResult requestAccessTokenToAuthServer(String code,
    HttpServletRequest request) {
  //
  TokenRequestResult tokenRequestResult = requestAccessTokenToAuthServer(code);

  if (tokenRequestResult.getError() != null) {
    return tokenRequestResult;
  }

  UserInfoResponse userInfoResponse =
  requestUserInfoToAuthServer(tokenRequestResult.getAccessToken());
  if (userInfoResponse.getResult() == false) {
    //
    tokenRequestResult.getError(userInfoResponse.getMessage());
    return tokenRequestResult;
}

  User user = userService.getUser(userInfoResponse.getUserName());
  request.getSession().setAttribute("user", user);

  userService.updateTokenId(user.getUserName(),
  extractTokenId(tokenRequestResult.getAccessToken()));

  return tokenRequestResult;
}

AuthService의 requestAccessTokenToAuthServer() 메소드에서는 아래 과정으로 요청을 처리합니다.

  1. code값을 인자로 requestAccessTokenToAuthServer() 메소드를 호출하여 SSO 서버의 토큰 Endpoint로 요청을 전송하고 그 결과를 TokenRequestResult 객체로 받아옵니다.
  2. 위 요청에 오류가 있는 경우 TokenRequestResult 객체를 바로 반환합니다.
  3. SSO 서버의 토큰 Endpoint에서 발급받은 접근 토큰을 인자로 requestUserInfoToAuthServer() 메소드를 호출하여 사용자 정보(User Info) Endpoint로 요청을 전송하고 그 결과를 UserInfoResponse 객체로 받아옵니다.
  4. 위 요청에 오류가 있는 경우 TokenRequestResult 객체에 에러 메시지를 설정한 후 이를 바로 반환합니다.
  5. UserService의 getUser() 메소드를 호출하여 User 객체를 얻어온 후 세션에 이를 저장합니다.
  6. 발급 받은 접근 토큰 값을 MD5 해쉬한 후 UserService의 updateTokenId() 메소드를 호출하여 데이터베이스에 저장합니다.

SSO 서버의 토큰, 사용자 정보 Endpoint로 요청을 전송하고 그 결과를 얻어오는 requestAccessTokenToAuthServer(), requestUserInfoToAuthServer() 메소드의 내용은 아래와 같습니다.

private TokenRequestResult requestAccessTokenToAuthServer(String code) {
  //
  String reqUrl = "http://localhost:8080/oauth/token";
  String authorizationHeader = getAuthorizationRequestHeader();

  Map<String, String> paramMap = new HashMap<>();
  paramMap.put("grant_type", "authorization_code");
  paramMap.put("redirect_uri", getOAuthRedirectUri());
  paramMap.put("code", code);

  HttpPost post = buildHttpPost(reqUrl, paramMap, authorizationHeader);

TokenRequestResult result = executePostAndParseResult(post, TokenRequestResult.class);

  return result;
}
private User requestUserInfoToAuthServer(String token) {
  //
  String reqUrl = "http://localhost:8080/userInfo";
  String authorizationHeader = null;

  Map<String, String> paramMap = new HashMap<>();
  paramMap.put("token", token);
  paramMap.put("clientId", getOAuthClientId());

  HttpPost post = buildHttpPost(reqUrl, paramMap, authorizationHeader);

  User result = executePostAndParseResult(post, User.class);

  return result;
}

두 메소드의 정의에서 주목할 부분은 토큰 Endpoint 요청 시 클라이언트 인증을 위한Authorization 헤더를 설정했다는 것입니다.

2.2.3. 사용자의 로그아웃 웹 요청 처리

사용자로부터 로그아웃 요청이 들어오면 클라이언트 아이디를 파라미터로 설정하여 SSO 서버의 로그아웃(Logout) Endpoint로 리다이렉트 합니다.

@RequestMapping(value="/logout", method=RequestMethod.GET)
public String logout() {
  //
  return "redirect:http://localhost:8080/userLogout?clientId=" + getOAuthClientId();
}

2.2.4. SSO 서버의 로그아웃 웹 요청 처리

@RequestMapping(value="/logout", method=RequestMethod.POST)
@ResponseBody
public Response logoutFromAuthServer(
    @RequestParam(name="tokenId") String tokenId,
    @RequestParam(name="userName") String userName) {
  //
  Response response = oauthService.logout(tokenId, userName);
  return response;
}

SSO 서버로부터 로그아웃 요청이 들어오면 AuthService의 logout() 메소드를 호출하여 다음과 같은 작업을 진행합니다.

  1. 사용자정보 조회
  2. 접근 토큰 정합성 체크
  3. 조회된 사용자에 대한 접근 토큰 정보 삭제

3. 맺음말

지금까지 OAuth 2에 대해 살펴본 후 SSO 환경 구성을 위한 OAuth 2 확장 방법과 스프링 부트 및 스프링 시큐리티 OAuth로 SSO 환경을 구축하는 방법에 대해 설명하였습니다.

소스 코드

소스 코드는 아래 사이트에서 확인할 수 있습니다.

https://github.com/nextreesoft/oauth

참고 문헌 및 사이트