이 연재글은 SpringBoot2로 Oauth2 서버 만들기의 2번째 글입니다.

앞 장에서는 테스트를 위해 메모리에 정보를 올려놓고 테스트하였으나, 이번에는 DB를 사용해 처리할 수 있도록 개선해 보겠습니다.

최신 소스는 아래 GitHub 주소를 참고해 주세요.
https://github.com/codej99/SpringOauth2AuthorizationServer.git

  • 클라이언트 정보를 DB 사용하는 방식으로 수정.
  • 로그인 시 회원 정보를 DB 사용하는 방식으로 수정.
  • 인증 및 토큰 정보를 DB 사용하는 방식으로 수정.

클라이언트 정보를 DB 사용하는 방식으로 수정.

resources 아래에 schema.sql 파일을 생성 후 다음 쿼리를 넣습니다. 해당 테이블은 클라이언트 정보를 넣는 테이블입니다. 서버가 start 될 때 해당 schema.sql에 내용이 있으면 실행됩니다.

create table IF NOT EXISTS 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),
  authorities VARCHAR(256),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additional_information VARCHAR(4096),
  autoapprove VARCHAR(256)
);

Oauth2AuthorizationConfig 수정

inMemory로 설정된 부분을 삭제하고 다음과 같이 설정을 수정합니다.

@Autowired
private DataSource dataSource;

/**
 * 클라이언트 정보 주입 방식을 jdbcdetail로 변경
 *
 * @param clients
 * @throws Exception
 */
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource).passwordEncoder(passwordEncoder);
}

Client 정보 insert

어드민 화면이 없으므로 수동으로 Client정보를 1건 입력합니다. Client정보를 넣을 때 secret정보는 암호화하여 넣어야 합니다. 암호화해서 넣지 않으면 인증 후에 다음과 같은 오류가 발생할 수 있습니다.
There is no PasswordEncoder mapped for the id “null”
그러므로 아래와 같이 간단히 프로그램을 만들어 암호화 정보를 얻습니다.

package com.rest.api;

import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

public class EncodingTest {
    public static void main(String[] args) {
        PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        System.out.printf("testSecret : %s\n", passwordEncoder.encode("testSecret"));
    }
}

서버를 실행하고 h2 console에서 다음 쿼리를 실행합니다. secret 필드엔 위에서 얻은 암호화된 데이터를 넣습니다. 아래의 쿼리는 테스트 클라이언트를 하나 생성합니다. 서비스에서는 해당 테이블에 데이터를 넣을 수 있도록 운영 툴을 만들어 클라이언트를 관리할 수 있도록 합니다.

insert into oauth_client_details(client_id, resource_ids,client_secret,scope,authorized_grant_types,web_server_redirect_uri,authorities,access_token_validity,refresh_token_validity,additional_information,autoapprove)
values('testClientId',null,'{bcrypt}$2a$10$H2oQgFY7qCRHWqkvAV4P6ONy2v74wfr3fQv.xERw3BJYSqh/Gcgrq','read,write','authorization_code,refresh_token','http://localhost:8081/oauth2/callback','ROLE_USER',36000,50000,null,null);

DB의 클라이언트 정보로 인증이 되는지 테스트합니다.

http://localhost:8081/oauth/authorize?client_id=testClientId&redirect_uri=http://localhost:8081/oauth2/callback&response_type=code&scope=read

로그인 후 아래처럼 토큰 정보가 출력되면 테스트가 성공한 것입니다.

{
    "access_token":"5bf1b64e-d0fa-4f47-b747-aee27cf1dc0a",
    "token_type":"bearer",
    "refresh_token":"52ad7031-e8e8-45a3-87cb-72d5417078dd",
    "expires_in":29974,
    "scope":"read"
}

좀 더 확실한 테스트를 위해 h2 console에서 oauth_client_details의 데이터를 삭제하고 다시 인증을 시도해 봅니다.

delete from oauth_client_details;

다시 인증을 시도하면 승인받을 클라이언트가 없으므로 아래와 같이 나오면서 실패하게 됩니다.

로그인 시 사용하는 회원 정보를 DB에서 조회하도록 수정.

User entity 생성

회원 정보를 담을 User entity를 생성합니다. Security에 필요한 정보를 담기 위해 UserDetails를 상속받아 관련 필드를 Overriding 합니다.

@Builder // builder를 사용할수 있게 합니다.
@Entity // jpa entity임을 알립니다.
@Getter // user 필드값의 getter를 자동으로 생성합니다.
@NoArgsConstructor // 인자없는 생성자를 자동으로 생성합니다.
@AllArgsConstructor // 인자를 모두 갖춘 생성자를 자동으로 생성합니다.
@Table(name = "user") // 'user' 테이블과 매핑됨을 명시
public class User implements UserDetails {
    @Id // pk
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long msrl;
    @Column(nullable = false, unique = true, length = 50)
    private String uid;
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(length = 100)
    private String password;
    @Column(nullable = false, length = 100)
    private String name;
    @Column(length = 100)
    private String provider;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

User Repo 생성

User 테이블에 질의 요청을 하기 위해 JpaRepository를 상속받아 UserJpaRepo를 생성합니다.

package com.rest.oauth2.repo;

import com.rest.oauth2.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserJpaRepo extends JpaRepository<User, Long> {
    Optional<User> findByUid(String email);
}

로그인 정보의 유효성을 검증하기 위한 AuthenticationProvider 작성

회원 이름으로 DB를 조회하여 회원정보의 비밀번호와 입력된 비밀번호의 매칭 여부를 확인합니다. 회원이 없거나 비밀번호가 틀린 경우엔 Exception을 발생시키고, 로그인 정보가 유효한 경우 회원 정보와 권한 정보로 인증 정보를 생성하여 리턴합니다.

@Slf4j
@RequiredArgsConstructor
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final PasswordEncoder passwordEncoder;

    private final UserJpaRepo userJpaRepo;

    @Override
    public Authentication authenticate(Authentication authentication) {

        String name = authentication.getName();
        String password = authentication.getCredentials().toString();

        User user = userJpaRepo.findByUid(name).orElseThrow(() -> new UsernameNotFoundException("user is not exists"));

        if (!passwordEncoder.matches(password, user.getPassword()))
            throw new BadCredentialsException("password is not valid");

        return new UsernamePasswordAuthenticationToken(name, password, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(
                UsernamePasswordAuthenticationToken.class);
    }
}

SpringSecurityConfig 수정

noOpPasswordEncoder 선언은 삭제합니다.
configure(AuthenticationManagerBuilder auth) 메서드는 위에서 생성한 CustomAuthenticationProvider를 사용하도록 설정합니다.

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomAuthenticationProvider authenticationProvider;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity security) throws Exception {
        security
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests().antMatchers("/oauth/**", "/oauth/token", "/oauth2/callback", "/h2-console/*").permitAll()
                .and()
                .formLogin().and()
                .httpBasic();
    }
}

PasswordEncoder 등록

WebMvcConfig에 다음 내용을 추가합니다. 기본 설정의 Encoder는 bcrypt암호화를 사용하게 됩니다.

@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

테스트 User 등록

테스트 User를 생성하기 위해 UserJpaRepoTest를 하나 생성하고 실행합니다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class UserJpaRepoTest {
    @Autowired
    private UserJpaRepo userJpaRepo;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Test
    public void insertNewUser() {
        userJpaRepo.save(User.builder()
                .uid("happydaddy@gmail.com")
                .password(passwordEncoder.encode("1234"))
                .name("happydaddy")
                .roles(Collections.singletonList("ROLE_USER"))
                .build());
    }
}

위에서 입력한 회원 데이터로 로그인하여 Token을 얻을 수 있는지 테스트합니다. 이전에 client정보를 db에서 삭제했다면 다음과 같이 다시 넣고 테스트해야 합니다.

insert into oauth_client_details(client_id, resource_ids,client_secret,scope,authorized_grant_types,web_server_redirect_uri,authorities,access_token_validity,refresh_token_validity,additional_information,autoapprove)
values('testClientId',null,'{bcrypt}$2a$10$H2oQgFY7qCRHWqkvAV4P6ONy2v74wfr3fQv.xERw3BJYSqh/Gcgrq','read,write','authorization_code','http://localhost:8081/oauth2/callback','ROLE_USER',36000,50000,null,null);

http://localhost:8081/oauth/authorize?client_id=testClientId&redirect_uri=http://localhost:8081/oauth2/callback&response_type=code&scope=read

인증 이후에 아래와 같이 token을 얻을 수 있으면 성공입니다.

{
    "access_token":"6fc01380-acd5-41a8-b9db-2bb540736247",
    "token_type":"bearer",
    "refresh_token":"52ad7031-e8e8-45a3-87cb-72d5417078dd",
    "expires_in":35999,
    "scope":"read"
}

인증 및 토큰 정보를 DB 사용하는 방식으로 수정

사실 위에서 회원이 승인한 정보나 토큰 정보 등은 DB에 저장되어 다음에 다시 참고되어야 합니다. 몇 번의 테스트를 거치며 눈치 채신 분도 있겠지만 테스트할 때마다 다시 앱 승인을 받아야 합니다. 이것은 서버를 리스타트 할 때마다 해당 정보가 초기화되기 때문입니다.

관련 정보를 저장하기 위한 테이블 생성을 위해 아래 DDL을 scheme.sql에 넣습니다.

create table IF NOT EXISTS oauth_client_token (
  token_id VARCHAR(256),
  token LONGVARBINARY,
  authentication_id VARCHAR(256) PRIMARY KEY,
  user_name VARCHAR(256),
  client_id VARCHAR(256)
);

create table IF NOT EXISTS 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 IF NOT EXISTS oauth_refresh_token (
  token_id VARCHAR(256),
  token LONGVARBINARY,
  authentication LONGVARBINARY
);

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

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

토큰 처리 시 DB를 사용하도록 Oauth2AuthorizationConfig에 다음 내용을 추가합니다.

/**
 * 토큰 정보를 DB를 통해 관리한다.
 * @return
 */
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(new JdbcTokenStore(dataSource));
}

다시 테스트를 합니다. 인증이 성공하면 token 정보를 h2 console에서 조회해 봅니다. 다음과 같이 조회가 되면 성공입니다. token 정보가 브라우저 화면에 출력된 것과 다른 이유는 token정보도 암호화되어 들어가기 때문입니다.

이제 서버를 리스타트하고 인증을 재시도해봅니다.

로그인 이후에 바로 토큰 정보를 얻을 수 있게 됩니다. 앱에 대한 승인은 최초에 1번만 수행되고 그 이후엔 물어보지 않게 되는 것입니다. OAUTH_ACCESS_TOKEN 테이블에는 로그인 후 인증한 정보가 회원의 id와 매핑되어 저장되어 있음을 확인할 수 있습니다. OAUTH_ACCESS_TOKEN 테이블에서 데이터를 지운 후 다시 인증을 하면 승인 요청이 뜨는 것을 확인할 수 있습니다.

토큰 타입을 JWT로 변경

access_token은 bearer토큰 형식입니다. 단순히 암호화된 문자열이라고 보면 됩니다. bearer토큰으로 리소스 서버에 리소스를 요청하면 해당 토큰이 유효한지 그리고 토큰 인증한 회원이 누구인지 인증서버에 확인을 추가로 해야 합니다.

JWT는 이러한 단점을 보완하기 위해 만들어진 토큰입니다. JWT 토큰은 Json String이 암호화된 문자열입니다. 그런데 bearer토큰과 다르게 token자체에 특정한 정보를 세팅할 수 있습니다. 따라서 JWT 토큰을 생성할 때 회원의 id, 권한 같은 인증에 필요한 정보를 세팅해두면 bearer토큰처럼 회원을 확인하기 위해 인증서버를 한번 더 거칠 필요가 없게 됩니다.

Oauth2AuthorizationConfig 수정

JdbcTokenStore는 주석 처리하고 jwtAccessTokenConverter를 사용하도록 설정합니다. JWT를 사용할 경우 토큰 자체로 인증정보가 관리되므로 token정보를 관리하는 Table은 사용하지 않게 됩니다.

/**
 * 토큰 정보를 DB를 통해 관리한다.
 *
 * @return
 */
//    @Override
//    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//        endpoints.tokenStore(new JdbcTokenStore(dataSource));
//    }

/**
 * 토큰 발급 방식을 JWT 토큰 방식으로 변경한다. 이렇게 하면 토큰 저장하는 DB Table은 필요가 없다.
 *
 * @param endpoints
 * @throws Exception
 */
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        super.configure(endpoints);
        endpoints.accessTokenConverter(jwtAccessTokenConverter());
}

/**
 * jwt converter를 등록
 *
 * @return
 */
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
        return new JwtAccessTokenConverter();
}

JWT 토큰 발급 테스트

http://localhost:8081/oauth/authorize?client_id=testClientId&redirect_uri=http://localhost:8081/oauth2/callback&response_type=code&scope=read

인증 화면에서 로그인을 하면 다음과 같은 화면이 나옵니다. bearer 토큰일 때와 화면이 다른 것을 알 수 있습니다. JWT의 경우 토큰 자체 정보만을 이용하기 때문에 유저가 이전에 해당 클라이언트에 리소스를 허용했는지 여부를 알 수가 없습니다. 따라서 JWT토큰의 경우 리소스 요청은 간단해지지만 새로 인증할 때마다 아래처럼 리소스 접근 요청 화면이 뜨게 되는 단점이 생기게 됩니다.

JWT로 토큰 타입을 세팅하고 인증시 화면
bearer로 토큰 타입을 세팅하고 인증시 화면

Authorize를 클릭하면 토큰을 얻을 수 있습니다. bearer토큰에 비해 사이즈가 커진 것을 확인할 수 있습니다. 부가적인 정보를 담아서 암호화한 토큰이라 그렇습니다.

{
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTYxMjg2MzYsInVzZXJfbmFtZSI6ImhhcHB5ZGFkZHlAZ21haWwuY29tIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6IjE3YWJlYWQ5LWZhYjYtNGM0Yy05ZTk5LTU5NzQ4ZWExN2U5OSIsImNsaWVudF9pZCI6InRlc3RDbGllbnRJZCIsInNjb3BlIjpbInJlYWQiXX0.hZHHI6Egio8nThCNvPd1YRGcdrbq6bp5nN7zZOtbdus",
"token_type":"bearer",
"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJoYXBweWRhZGR5QGdtYWlsLmNvbSIsInNjb3BlIjpbInJlYWQiXSwiYXRpIjoiMTdhYmVhZDktZmFiNi00YzRjLTllOTktNTk3NDhlYTE3ZTk5IiwiZXhwIjoxNTU2MTQyNjM2LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiZWU4YjU5MjYtYTZlZi00N2I2LWJmOWUtNzkwMTczY2U4ZWYyIiwiY2xpZW50X2lkIjoidGVzdENsaWVudElkIn0.rCswE10Qbn2XLRwkvOX5dgXo_ByzV8ROiAsxHur7_e8",
"expires_in":35999,
"scope":"read"
}

refresh_token을 이용한 access_token 재발급 테스트

access_token은 refresh_token으로 갱신하여 재발급 받을 수 있습니다. JWT Token을 갱신하기 위해서는 아래와 같은 작업이 필요합니다.

AuthorizationServerEndpoint에 userDetailService 세팅

refresh 토큰이 정상인지 확인하려면 회원 정보를 조회해봐야 하므로 다음과 같이 Oauth2AuthorizationConfig에 userDetailsService를 세팅해 줍니다.

private final CustomUserDetailService userDetailService;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        super.configure(endpoints);
 endpoints.accessTokenConverter(jwtAccessTokenConverter()).userDetailsService(userDetailService);
}

jwt signkey 세팅

이전 테스트까지는 signkey를 명시적으로 세팅하지 않아 시스템에서 자동으로 세팅되었습니다. 그렇게 되면 refresh토큰을 복호화할 때 signKey가 달라서 오류가 발생합니다. 그러므로 명시적인 signkey세팅이 필요합니다.

application.yml에 다음 내용을 추가합니다.

security:
  oauth2:
    jwt:
      signkey: 123@#$

Oauth2AuthorizationConfig의 JwtAccessTokenConverter에 signkey를 추가합니다.

@Value("${security.oauth2.jwt.signkey}")
private String signKey;

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(signKey);
        return converter;
}

토큰 갱신 여부를 확인할 테스트 코드를 Oauth2Controller에 추가로 작성합니다.

@GetMapping(value = "/token/refresh")
public OAuthToken refreshToken(@RequestParam String refreshToken) {

        String credentials = "testClientId:testSecret";
        String encodedCredentials = new String(Base64.encodeBase64(credentials.getBytes()));

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.add("Accept", MediaType.APPLICATION_JSON_VALUE);
        headers.add("Authorization", "Basic " + encodedCredentials);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("refresh_token", refreshToken);
        params.add("grant_type", "refresh_token");
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<String> response = restTemplate.postForEntity("http://localhost:8081/oauth/token", request, String.class);
        if (response.getStatusCode() == HttpStatus.OK) {
            return gson.fromJson(response.getBody(), OAuthToken.class);
        }
        return null;
}

토큰 refresh 테스트

인증을 통해 아래와 같은 토큰 정보를 얻습니다.

{
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTYxMzE3MTEsInVzZXJfbmFtZSI6ImhhcHB5ZGFkZHlAZ21haWwuY29tIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6Ijk5MTAxMTY5LWZlY2EtNDVlMS05YmU4LWJmY2M2YmIzNGI4OCIsImNsaWVudF9pZCI6InRlc3RDbGllbnRJZCIsInNjb3BlIjpbInJlYWQiXX0.INqTsuYEJ9BCXqf9Q0rZRKGj0euHbx-DJ6mi2N4rOB8",
"token_type":"bearer",
"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJoYXBweWRhZGR5QGdtYWlsLmNvbSIsInNjb3BlIjpbInJlYWQiXSwiYXRpIjoiOTkxMDExNjktZmVjYS00NWUxLTliZTgtYmZjYzZiYjM0Yjg4IiwiZXhwIjoxNTU2MTQ1NDUyLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiODZhMDliYjctMzA2NS00NGQ5LWIxYjEtNWJlOGFlMjdlOWZkIiwiY2xpZW50X2lkIjoidGVzdENsaWVudElkIn0.fL51VTaLMHEf89Ao41nUrW-MzaNGBupg_4Us5nEtL_4",
"expires_in":35999,
"scope":"read"
}

위 결과의 refresh_token 값으로 아래 주소를 실행합니다.

http://localhost:8081/oauth2/token/refresh?refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJoYXBweWRhZGR5QGdtYWlsLmNvbSIsInNjb3BlIjpbInJlYWQiXSwiYXRpIjoiOTkxMDExNjktZmVjYS00NWUxLTliZTgtYmZjYzZiYjM0Yjg4IiwiZXhwIjoxNTU2MTQ1NDUyLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiODZhMDliYjctMzA2NS00NGQ5LWIxYjEtNWJlOGFlMjdlOWZkIiwiY2xpZW50X2lkIjoidGVzdENsaWVudElkIn0.fL51VTaLMHEf89Ao41nUrW-MzaNGBupg_4Us5nEtL_4

실행 결과를 보면 access_token값이 갱신된 것을 확인할 수 있습니다.

{
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTYxMzI1NzgsInVzZXJfbmFtZSI6ImhhcHB5ZGFkZHlAZ21haWwuY29tIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6ImJiMTI2Yzc4LTlkZTUtNDYzMC1hMjU4LTcwNzg4M2I1Nzg2ZiIsImNsaWVudF9pZCI6InRlc3RDbGllbnRJZCIsInNjb3BlIjpbInJlYWQiXX0.nLufRLebdxuc4GqMVoVHauesY4hQYeq5kCQtipKUEak",
"token_type":"bearer",
"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJoYXBweWRhZGR5QGdtYWlsLmNvbSIsInNjb3BlIjpbInJlYWQiXSwiYXRpIjoiYmIxMjZjNzgtOWRlNS00NjMwLWEyNTgtNzA3ODgzYjU3ODZmIiwiZXhwIjoxNTU2MTQ1NDUyLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiODZhMDliYjctMzA2NS00NGQ5LWIxYjEtNWJlOGFlMjdlOWZkIiwiY2xpZW50X2lkIjoidGVzdENsaWVudElkIn0.axgvpcBksFqhyPyfJmfGFItNGFS3FqCGogsVqpSvZ60",
"expires_in":35999,
"scope":"read"
}

[내용 추가] Second App 클라이언트 등록 및 인증 처리 방법

테스트에서 사용한 test app에 이어 두번째 클라이언트를 등록합니다. 유저가 로그인 후 해당 app에 연결하여 token을 얻기위해 다음 작업을 진행합니다.

oauth_client_detail에 신규 클라이언트 추가

  • client_id / secret 암호화 한 값을 DB에 입력합니다.
  • secondapp / secondsecret 암호화 -> {bcrypt}$2a$10$A9pj3UFTItSr9ujk5eYuPOvlxzAsm4Y1p3rBWAkwU1rNayBVbmtu2
insert into oauth_client_details(client_id, resource_ids,client_secret,scope,authorized_grant_types,web_server_redirect_uri,authorities,access_token_validity,refresh_token_validity,additional_information,autoapprove)
values('secondapp',null,'{bcrypt}$2a$10$A9pj3UFTItSr9ujk5eYuPOvlxzAsm4Y1p3rBWAkwU1rNayBVbmtu2','read,write','authorization_code,refresh_token','http://localhost:8081/oauth2/secondapp/callback','ROLE_USER',36000,50000,null,null);

secondapp용 endpoint url 추가

Oauth2Controller.java와 내용이 같은 Controller를 하나 생성합니다. 변경 사항은 총 5군데이며 소스에 주석으로 표시하였습니다.

@RequiredArgsConstructor
@RestController
@RequestMapping("/oauth2")
public class Oauth2SecondAppController {

    private final Gson gson;
    private final RestTemplate restTemplate;

    @GetMapping(value = "/secondapp/callback") // 1. endpoint 주소 수정
    public OAuthToken callbackSocial(@RequestParam String code) {

        String credentials = "secondapp:secondsecret"; // 2. credentials 정보 수정
        String encodedCredentials = new String(Base64.encodeBase64(credentials.getBytes()));

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.add("Accept", MediaType.APPLICATION_JSON_VALUE);
        headers.add("Authorization", "Basic " + encodedCredentials);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("code", code);
        params.add("grant_type", "authorization_code");
        params.add("redirect_uri", "http://localhost:8081/oauth2/secondapp/callback"); // 3. redirect_uri 정보 수정
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<String> response = restTemplate.postForEntity("http://localhost:8081/oauth/token", request, String.class);
        if (response.getStatusCode() == HttpStatus.OK) {
            return gson.fromJson(response.getBody(), OAuthToken.class);
        }
        return null;
    }

    @GetMapping(value = "/secondapp/token/refresh") // 4. endpoint 주소 수정
    public OAuthToken refreshToken(@RequestParam String refreshToken) {

        String credentials = "secondapp:secondsecret"; // 5. credentials 정보 수정
        String encodedCredentials = new String(Base64.encodeBase64(credentials.getBytes()));

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.add("Accept", MediaType.APPLICATION_JSON_VALUE);
        headers.add("Authorization", "Basic " + encodedCredentials);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("refresh_token", refreshToken);
        params.add("grant_type", "refresh_token");
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<String> response = restTemplate.postForEntity("http://localhost:8081/oauth/token", request, String.class);
        if (response.getStatusCode() == HttpStatus.OK) {
            return gson.fromJson(response.getBody(), OAuthToken.class);
        }
        return null;
    }
}

신규 app으로 로그인

client_id, redirect_uri를 새로 등록한 정보에 맞게 수정하여 페이지를 호출합니다.

http://localhost:8081/oauth/authorize?client_id=secondapp&redirect_uri=http://localhost:8081/oauth2/secondapp/callback&response_type=code&scope=read

로그인이 정상적으로 수행되면 아래와 같이 secondapp을 승인하겠냐는 물음 화면이 출력됩니다.

Authorize를 클릭하면 secondapp의 token이 발급됩니다.

{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTUyNTYyNjUsInVzZXJfbmFtZSI6ImhhcHB5ZGFkZHlAZ21haWwuY29tIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6ImQzNWI2YTViLWUzMDQtNDYwNS04MzJkLTgwMzgyNmZlMTZmOSIsImNsaWVudF9pZCI6InNlY29uZGFwcCIsInNjb3BlIjpbInJlYWQiXX0.UetKc6wtv1LxB0IaZj1_OKqPs7ipfcPcGwAQ04XlMTNgkjUSJi6ttW0zc4N2BXKRuBRukGlWVqwhwzRHsKXAw2N6uwYMGzLHTfAfaTmySd8tXJeR7kMdOXd4diZ7Ex-lcyuMWZRkhNwlyWbz5nxC-ilu9TpkQ0ZCFxjti07GluSTsu-AjLKvC-x6zAkBtU0aV6VeYJ-PE97gcr8J7tjw6neP3UbyhLkbmuCqtFpCpDZHrgCeV2IlLKlp6OpPWcPPna9BxPIojE4SkXp_mLcbUxM9PnNN1xqMNg2aI2LOFPOS5CEw82Lv_cyRuuFsk8MEKIm9tVX8rwLKgDlYyhyc2Q","token_type":"bearer","refresh_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJoYXBweWRhZGR5QGdtYWlsLmNvbSIsInNjb3BlIjpbInJlYWQiXSwiYXRpIjoiZDM1YjZhNWItZTMwNC00NjA1LTgzMmQtODAzODI2ZmUxNmY5IiwiZXhwIjoxNjE1MjcwMjY1LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMjUxMmNmMjctYzU2MS00NDc0LTgyYmMtMTI1YmVjMTY5ZDQ2IiwiY2xpZW50X2lkIjoic2Vjb25kYXBwIn0.K56TjqE3g7VgnDd_dusC4gK6xcVjJzGKq0ohe7nx9HtUk3rQMFMUHEKhegbpfntH8Lp-4TPIdjmV1PG4bUIXuXQzMnyoqu3oZD4YqYtLM_RndeUSKeMq-g44dSkyiV6BPyvgtWFKQJkr_O3Jye_PYd3FP00M-eCMJSv2irJrdEjNwaFrMIn9GkwpHx2KvNJ-JQYw6SZLqHOVmljOhwJKCXGOR06ZwP4-zRGrvmFrIdsk9iTI7CJxs7BOdx455ttheKogdIctNKqvllgEpRzp9q111w5oyyhr0TFtaNehUgyd9ZvBVEzpN8GwrAnksD85k5bwTGTv1JiSTvniMIdWLw","expires_in":35999,"scope":"read"}
연재글 이동[이전글] Spring Boot Oauth2 – AuthorizationServer
[다음글] Spring Boot Oauth2 – ResourceServer