Spring Boot OAuth2 Authorization Server 구축

By | 2021년 7월 18일
Table of Content

Spring Boot OAuth2 Authorization Server 구축

참조사이트 : https://daddyprogrammer.org/post/1287/spring-oauth2-authorizationserver-database/
참조사이트 : https://github.com/codej99/SpringOauth2AuthorizationServer
참조사이트 : https://github.com/Baeldung/spring-security-registration

@EnableAuthorizationServer 가 deprecated 상태입니다.

Spring Boot 버전 선택시,
우선적으로 @EnableAuthorizationServer 가 사용가능한지 확인해야 합니다.

목표

OAuth2 Authorization Server 를 구축합니다.

소스코드

여기 에 전체 소스코드가 올라가 있습니다.

프로젝트 생성

신규 프로젝트를 생성합니다.

프로젝트명은 oauth2authoricationserver 로 합니다.

의존성은 Spring Boot DevTools, Lombok, Spring Data JPA, MySQL Driver 을 선택합니다.

build.gradle 수정

의존성을 다음과 같이 수정합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-rest'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-jwt'

    // 버전을 명시적으로 지정해야 한다(?)
    implementation 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.5.2'

    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

application.yml 수정

spring:
  datasource:
    url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/db_oauth2
    username: root
    password:
    driver-class-name: com.mysql.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update    # 개발용 only
    show-sql: true

server:
  port: 9000

logging:
    level:
        org:
            springframework:
                web: DEBUG
                security: DEBUG

계정정보를 메모리에 저장합니다.
패스워드 암호화는 없도록 설정합니다.

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder noOpPasswordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("user")
                .password("pass")
                .roles("USER");
    }

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

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

AuthorizationServerConfig.java

@RequiredArgsConstructor
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final AuthenticationManager authenticationManager;

    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("foo")
                .secret("bar")
                .redirectUris("http://localhost:8080/test/auth")
                .authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")
                .scopes("read", "write", "email", "profile")
                .accessTokenValiditySeconds(30000);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager);
    }
}

프로젝트 실행

프로젝트를 실행합니다.

아래 명령으로 토큰키가 리턴되는 것을 확인할 수 있습니다.

curl foo:bar@localhost:9000/oauth/token -dgrant_type=password -dscope=read -d username=user -d password=pass

아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.

http://localhost:9000/oauth/authorize?response_type=code&client_id=foo&redirect_uri=http://localhost:8080/test/auth&scope=read

아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.

curl foo:bar@localhost:9000/oauth/token -dgrant_type=authorization_code -dcode=<인증코드> -dscope=read -dredirect_uri=http://localhost:8080/test/auth

발급된 토큰정보가 메모리에 저장됩니다.
인증서버를 재부팅하면 발급되었던 토큰 정보가 모두 무효화됩니다.

JDBC 방식으로 변경

Client 정보 JDBC 로 관리

클라이언트 정보를 MySQL 에서 읽어오도록 수정합니다.
비밀번호는 암호화하도록 설정을 변경합니다.

암호화 비밀번호 생성용 실행파일을 생성해 줍니다.

MyPasswordEncoder.java

public class MyPasswordEncoder {

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

schema.sql

create database db_oauth2;

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)
);

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('foo',null,'{bcrypt}$2a$10$wPb4BM6c/IqweuscNtQqgu0npxBn0i1qKbx3hGwJ26C3Wi5fHonuy','read,write,profile,email','authorization_code,password,client_credentials,implicit,refresh_token','http://localhost:8080/login/oauth2/code/local','ROLE_USER',36000,50000,null,null);

클라이언트 정보를 JDBC 에서 읽어오도록 하고,
비밀번호에 암호화를 하도록 수정해 줍니다.

AuthorizationServerConfig.java

@RequiredArgsConstructor
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final DataSource dataSource;

    private final PasswordEncoder passwordEncoder;

    private final AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource).passwordEncoder(passwordEncoder);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager);
    }
}

계정정보의 비밀번호도 암호화를 설정해 줍니다.

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("user")
                .password("{bcrypt}$2a$10$fMkleOIoPlxW.mWaleJj9Oo8uEJgCEsH2THjKF/7S4tqdLWvWrEDq")
                .roles("USER");
    }

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

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.

http://localhost:9000/oauth/authorize?response_type=code&client_id=foo&redirect_uri=http://localhost:8080/login/oauth2/code/local&scope=read

아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.

curl foo:bar@localhost:9000/oauth/token -dgrant_type=authorization_code -dcode=<인증코드> -dscope=read -dredirect_uri=http://localhost:8080/login/oauth2/code/local

고객 정보 JDBC 로 관리

고객정보도 MySQL 에서 읽어오도록 수정합니다.

CREATE TABLE `tbl_user` (
    `msrl` BIGINT(20) NOT NULL,
    `name` VARCHAR(100) NOT NULL,
    `password` VARCHAR(100) NULL DEFAULT NULL,
    `provider` VARCHAR(100) NULL DEFAULT NULL,
    `uid` VARCHAR(100) NOT NULL,
    `email` VARCHAR(100) NOT NULL,
    PRIMARY KEY (`msrl`),
    UNIQUE INDEX `UK_tbl_user_uid` (`uid`)
)
COLLATE='utf8_general_ci'
ENGINE=InnoDB;

CREATE TABLE `user_roles` (
    `user_msrl` BIGINT(20) NOT NULL,
    `roles` VARCHAR(255) NULL DEFAULT NULL,
    INDEX `FK7ie1lfmnysdogxy1g91ernbkv` (`user_msrl`),
    CONSTRAINT `FK_tbl_user_msrl` FOREIGN KEY (`user_msrl`) REFERENCES `tbl_user` (`msrl`)
)
COLLATE='utf8_general_ci'
ENGINE=INNODB;

User.java

@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "tbl_user")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    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(nullable = false, length = 100)
    private String email;

    @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;
    }
}

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUid(String uid);
}

WebMvcConfig.java

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private static final long MAX_AGE_SECONDS = 3600;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(MAX_AGE_SECONDS);
    }

    @Bean
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }

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

CustomAuthenticationProvider.java

@RequiredArgsConstructor
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final PasswordEncoder passwordEncoder;

    private final UserRepository userJpaRepo;

    public CustomAuthenticationProvider(UserRepository userJpaRepo) {
        this.userJpaRepo = userJpaRepo;
        this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @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);
    }
}

SecurityConfig.java

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final 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/**").permitAll()
                .and()
                    .formLogin()
                .and()
                    .httpBasic();
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

테스트 파일을 만들어 계정정보를 생성해 줍니다.

UserRepositoryTest.java

@RunWith(SpringRunner.class)
@SpringBootTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Test
    public void createUser() {
        userRepository.save(User.builder()
                .uid("user")
                .password(passwordEncoder.encode("pass"))
                .name("user")
                .email("skyer9@gmail.com")
                .roles(Collections.singletonList("ROLE_USER"))
                .build());
    }
}

아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.

http://localhost:9000/oauth/authorize?response_type=code&client_id=foo&redirect_uri=http://localhost:8080/login/oauth2/code/local&scope=read

아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.

curl foo:bar@localhost:9000/oauth/token -dgrant_type=authorization_code -dcode=<인증코드> -dscope=read -dredirect_uri=http://localhost:8080/login/oauth2/code/local

인증/토큰 정보 JDBC 로 관리

생성된 토큰정보를 JDBC 에 저장합니다.
서버를 재실행하더라도 발행한 토큰정보가 지워지지 않습니다.

create table IF NOT EXISTS oauth_client_token (
    token_id VARCHAR(256),
    token BLOB,
    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 BLOB,
    authentication_id VARCHAR(256) PRIMARY KEY,
    user_name VARCHAR(256),
    client_id VARCHAR(256),
    authentication BLOB,
    refresh_token VARCHAR(256)
);

create table IF NOT EXISTS oauth_refresh_token (
    token_id VARCHAR(256),
    token BLOB,
    authentication BLOB
);

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

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

JdbcTokenStore 를 이용하여 JDBC 에 토큰정보를 저장합니다.

@RequiredArgsConstructor
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final DataSource dataSource;

    private final PasswordEncoder passwordEncoder;

    private final AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource).passwordEncoder(passwordEncoder);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager).tokenStore(new JdbcTokenStore(dataSource));
    }
}

아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.

http://localhost:9000/oauth/authorize?response_type=code&client_id=foo&redirect_uri=http://localhost:8080/login/oauth2/code/local&scope=read

아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.

curl foo:bar@localhost:9000/oauth/token -dgrant_type=authorization_code -dcode=<인증코드> -dscope=read -dredirect_uri=http://localhost:8080/login/oauth2/code/local

JWT 방식

기존 방식은 발급한 토큰의 유효성을 검증하기 위해,
매번 인증서버에 유효성 검증을 요청하게 됩니다.

JWT 방식은 이런 문제점을 개선하기 위해,
토큰 검증에 필요한 정보까지 토큰에 포함해서 발행합니다.

토큰의 크기는 커지지만 토큰 유효성 검증을 리소스서버에서
자체적으로 할 수 있기에 인증서버의 부담이 줄어듭니다.

AuthorizationServerConfig.java

@RequiredArgsConstructor
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final DataSource dataSource;

    private final PasswordEncoder passwordEncoder;

    private final AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource).passwordEncoder(passwordEncoder);
    }

//    // JDBC 방식
//    @Override
//    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//        endpoints.authenticationManager(authenticationManager).tokenStore(new JdbcTokenStore(dataSource));
//    }

    // JWT 방식
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        super.configure(endpoints);
        endpoints.accessTokenConverter(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        return new JwtAccessTokenConverter();
    }
}

아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.

http://localhost:9000/oauth/authorize?response_type=code&client_id=foo&redirect_uri=http://localhost:8080/login/oauth2/code/local&scope=read

아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.

curl foo:bar@localhost:9000/oauth/token -dgrant_type=authorization_code -dcode=<인증코드> -dscope=read -dredirect_uri=http://localhost:8080/login/oauth2/code/local

JWT 토큰 재발급

위에서는 signkey 가 서버 메모리상에 저장됩니다.
따라서, 서버가 재부팅하게 되면, 갱신키를 발급하지 못하게 됩니다.

아래 설정으로 signkey 를 고정시키면,
서버가 재부팅해도 갱신키 발급이 가능해집니다.

application.yml

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

AuthorizationServerConfig.java

@RequiredArgsConstructor
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

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

    private final DataSource dataSource;

    private final PasswordEncoder passwordEncoder;

    private final AuthenticationManager authenticationManager;

    private final CustomUserDetailService userDetailService;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource).passwordEncoder(passwordEncoder);
    }

//    // JDBC 방식
//    @Override
//    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//        endpoints.authenticationManager(authenticationManager).tokenStore(new JdbcTokenStore(dataSource));
//    }

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

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

토큰 재발급을 할 경우, 고객정보를 확인 후 재발급합니다.

CustomUserDetailService.java

@Service
public class CustomUserDetailService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    private final AccountStatusUserDetailsChecker detailsChecker = new AccountStatusUserDetailsChecker();

    @Override
    public UserDetails loadUserByUsername(String name) {
        User user = userRepository.findByUid(name).orElseThrow(() -> new UsernameNotFoundException("user is not exists"));
        detailsChecker.check(user);
        return user;
    }
}

아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.

http://localhost:9000/oauth/authorize?response_type=code&client_id=foo&redirect_uri=http://localhost:8080/login/oauth2/code/local&scope=read

아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.

curl foo:bar@localhost:9000/oauth/token -dgrant_type=authorization_code -dcode=<인증코드> -dscope=read -dredirect_uri=http://localhost:8080/login/oauth2/code/local
curl foo:bar@localhost:9000/oauth/token -dgrant_type=refresh_token -drefresh_token=<토큰 갱신키>

JWT 토큰 비대칭 암호화

공개키/비밀키 기반의 비대칭 토큰 암호화는 리소스 서버 에서 설명합니다.

Brute Force

Spring Boot Brute Force(랜덤 문자열 공격) 대응

Spring Boot Cache

Spring Boot Cache for Authorization Server

프로필 서버

Spring Boot Profile Server for Authorization Server

2 thoughts on “Spring Boot OAuth2 Authorization Server 구축

  1. Pingback: Spring Boot Brute Force(랜덤 문자열 공격) 대응 – 상구리의 기술 블로그

  2. Pingback: Spring Boot 시작하기 – 상구리의 기술 블로그

답글 남기기