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
아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.
아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.
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();
}
}
아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.
아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.
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());
}
}
아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.
아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.
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));
}
}
아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.
아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.
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();
}
}
아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.
아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.
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;
}
}
아래 링크에 접속하면 클라이언트가 비밀번호를 입력할 수 있는 페이지가 표시되고,
비밀번호 입력 후 인증을 허용할 것인지에 대한 페이지가 표시됩니다.
아래 명령을 실행하면 실제 토큰이 발급되는 것을 확인할 수 있습니다.
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
Pingback: Spring Boot Brute Force(랜덤 문자열 공격) 대응 – 상구리의 기술 블로그
Pingback: Spring Boot 시작하기 – 상구리의 기술 블로그