파란하늘의 지식창고
article thumbnail
반응형

개인적인 공부를 진행한 과정을 정리한 내용입니다.


구성 개요

MSA로 유저 정보를 제공하는 security api 서버와 그걸 사용하는 서버를 구성하려고 한다.

두 서버 모두 Spring Security를 사용하지만 security api를 제공하는 서버는 외부에 노출되지 않는다는 전제 조건으로 응답을 하고 사용하는 서버는 앞단 웹 서버라고 가정해 본다.

별도로 구성하지 않고 되도록 Spring Security의 기본 설정을 사용한다고 가정해서 진행한다.

서버 구성은 다음과 같다.

  • security api server - api 서버, 인증 정보를 저장/응답하는 역할 담당
  • gate server - 호출하여 인증을 처리하는 서버

제공하려는 기능은 다음과 같다.

  • 일반적인 user password를 사용한 로그인
  • oauth2를 사용한 로그인
  • jdbc (mariadb) 기반으로 구성

user api server 설정

dependency 설정

<dependencies>
    <dependency>
        <groupId>io.github.luversof</groupId>
        <artifactId>bluesky-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-client</artifactId>
    </dependency>
    <dependency>
        <groupId>io.github.luversof</groupId>
        <artifactId>bluesky-boot-starter-web</artifactId>
    </dependency>
</dependencies>

properties datasource 설정

#security
datasource.user.username=유저이름
datasource.user.password=비밀번호
datasource.user.type=com.zaxxer.hikari.HikariDataSource
datasource.user.url=jdbc:mariadb://localhost:3306/user?useSSL=false&useUnicode=true&autoReconnection=true

Configuration 설정

@Configuration
public class JdbcUserConfig {

    @Bean
    @ConfigurationProperties("datasource.user")
    DataSourceProperties userDataSourceProperties() {
        return new DataSourceProperties();
    }
    
    @Bean
    DataSource userDataSource() {
        return userDataSourceProperties().initializeDataSourceBuilder().build();
    }
    
    @Bean
    JdbcTemplate jdbcTemplate(@Qualifier("userDataSource") DataSource dataSource, JdbcProperties properties) {
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        JdbcProperties.Template template = properties.getTemplate();
        jdbcTemplate.setFetchSize(template.getFetchSize());
        jdbcTemplate.setMaxRows(template.getMaxRows());
        if (template.getQueryTimeout() != null) {
            jdbcTemplate.setQueryTimeout((int) template.getQueryTimeout().getSeconds());
        }
        return jdbcTemplate;
    }
}

JdbcSchema 설정

유저 정보를 개별 구현하지 않고 스프링이 기본제공하는 유저 정보를 그대로 사용하였다.
(개별 구현도 그리 어렵지 않고 좀 더 자세한 설정을 구현할 수 있어 좋다. 실제로 사용하려면 개별 설정은 필수적이다.)

Spring Security Schema

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html#servlet-authentication-jdbc-schema-user

Spring Security의 기본 ddl 정보는 spring-security-core의 `org/springframework/security/core/userdetails/jdbc/users.ddl` 위치에 있다.

create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);
create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);

다만 mariadb의 경우 varchar_ignorecase를 지원하지 않으므로 varchar로 추가하였다.

Spring Security OAuth2 Client Schema

oauth2 client의 경우 spring-security-oauth2-client의 `org/springframework/security/oauth2/client/oauth2-client-schema.sql` 위치에 있다.

CREATE TABLE oauth2_authorized_client (
  client_registration_id varchar(100) NOT NULL,
  principal_name varchar(200) NOT NULL,
  access_token_type varchar(100) NOT NULL,
  access_token_value blob NOT NULL,
  access_token_issued_at timestamp NOT NULL,
  access_token_expires_at timestamp NOT NULL,
  access_token_scopes varchar(1000) DEFAULT NULL,
  refresh_token_value blob DEFAULT NULL,
  refresh_token_issued_at timestamp DEFAULT NULL,
  created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
  PRIMARY KEY (client_registration_id, principal_name)
);

공식 문서 Scheam 참고

jar에서 schema를 참고해도 되고 공식 문서를 참고해도 된다.

https://docs.spring.io/spring-security/reference/servlet/appendix/database-schema.html

여기까지 진행하였으면 security를 제공할 기본적인 환경은 갖춰졌다.

Spring Security를 사용할 설정을 진행해 본다.

UseDetailsManager 설정

jdbc에 저장하도록 userDetailsManager bean을 다음처럼 설정하면 이제 db로 유저 정보를 저장하고 호출하게 된다.

@Bean
UserDetailsManager userDetailsManager(@Qualifier("userDataSource") DataSource userDataSource) {
    return new JdbcUserDetailsManager(userDataSource);
}

PasswordEncoder 설정

유저 정보를 저장할 때 비밀번호는 암호화하여 저장하길 원할 것이다.

이를 위해 스프링은 PasswordEncoder를 제공한다.

아래와 같이 bean 설정만 하면 활성화된다.

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

위의 경우 다양한 encoder를 사용할 수 있게 해주는 설정이다.

만약 단일 encoder만 사용하고자 한다면 다음과 같이 선언할 수도 있다.

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

 

delegatingPasswordEncoder를 설정하는 것을 추천한다.

유저 정보 저장 테스트 해보기

여기까지 진행하였으면 유저 정보를 저장할 수 있는 설정을 모두 마쳤다.

db에 잘 저장되는지 다음과 같이 테스트해 본다.

@Autowired
private UserDetailsManager userDetailsManager;

@Autowired
private PasswordEncoder passwordEncoder;

@Test
void createUser() {
    UserDetails user = User.builder()
            .username("user")
            .passwordEncoder(passwordEncoder::encode)
            .password("pass")
            .roles("USER")
            .build();
    userDetailsManager.createUser(user);
    assertThat(user).isNotNull();
}

정상적으로 저장되었으면 데이터를 확인할 수 있다.

delegatingPasswordEncoder를 사용하면 앞에 `{encodeType} 암호화된 비밀번호` 와 같은 형태로 저장이 된다.

만약 BCryptPasswordEncoder나 다른 encoder를 bean으로 설정하였다면 단일 암호를 사용하기 때문에 {encodeType} 은 생략되고 저장된다.

OAuth2AuthorizedClientService 설정

oauth2를 사용하기 위해 dependency와 db schema를 추가하였다. 앞서 본 security의 UserDetailsManager 설정처럼 oauth2 client도 db를 사용하도록 다음과 같이 bean 설정을 추가한다.

@Bean
JdbcOAuth2AuthorizedClientService jdbcOAuth2AuthorizedClientService(JdbcOperations jdbcOperations, ClientRegistrationRepository clientRegistrationRepository) {
    return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
}

UserDetailsManager, OAuth2AuthorizedClientService의 API Controller 설정

여기까지 진행했으면 이미 이 서버는 Spring Security를 사용한 인증, oauth2 인증을 사용할 수 있는 상태이다.

하지만 이 user api server는 api를 제공하고 gate server에서 호출을 받아 사용하려고 하므로 위에서 설정한 UserDetailsManager와 OAuth2AuthorizedClientService를 응답하는 api도 다음과 같이 만들어야 한다.

@RestController
@RequestMapping(value = "/api/user/userDetails", produces = MediaType.APPLICATION_JSON_VALUE)
public class UserDetailsController {

    @Autowired
    private UserDetailsManager userDetailsManager;
    
    @GetMapping("/search/loadUserByUsername")
    public UserDetails loadUserByUsername(String username) {
        return userDetailsManager.loadUserByUsername(username);
    }
    
    @PostMapping
    public UserDetails createUser(@RequestBody User user) {
        userDetailsManager.createUser(user);
        return user;
    }
    
    @PutMapping
    public UserDetails updateUser(@RequestBody User user) {
        userDetailsManager.updateUser(user);
        return user;
    }
    
    @DeleteMapping
    public void deleteUser(String username) {
        userDetailsManager.deleteUser(username);
    }
}
@RestController
@RequestMapping(value = "/api/user/oAuth2AuthorizedClient", produces = MediaType.APPLICATION_JSON_VALUE)
public class OAuth2AuthorizedClientController {

    @Autowired
    private JdbcOAuth2AuthorizedClientService oAuth2AuthorizedClientService;
    
    @GetMapping
    public OAuth2AuthorizedClient loadAuthorizedClient(@RequestParam String clientRegistrationId, @RequestParam String principalName) {
        return oAuth2AuthorizedClientService.loadAuthorizedClient(clientRegistrationId, principalName);
    }
    
    @PostMapping
    public void saveAuthorizedClient(@RequestBody SaveAuthorizedClientParam saveAuthorizedClientParam) {
        oAuth2AuthorizedClientService.saveAuthorizedClient(saveAuthorizedClientParam.authorizedClient(), saveAuthorizedClientParam.principal());
    }
    
    @DeleteMapping
    public void removeAuthorizedClient(@RequestParam String clientRegistrationId, @RequestParam String principalName) {
        oAuth2AuthorizedClientService.removeAuthorizedClient(clientRegistrationId, principalName);
    }

    
    private static record SaveAuthorizedClientParam(OAuth2AuthorizedClient authorizedClient, OAuth2AuthenticationToken principal) {
        
    }
    
}

SecurityJackson2Modules 설정

user api server와 gate server 간 spring security의 인증 관련 domain을 전달할 수 있으려면 관련 jackson Module 설정이 있어야 한다.

Spring Security의 인증 관련 도메인들은 공개를 위해 구성된 도메인이 아니기 때문에 이 설정을 추가해 주어야 api로 전달받은 security 관련 정보를 사용할 수 있게 된다.

@Configuration
public class UserSecurityConfig {

    @Autowired
    private ObjectMapper objectMapper;
    
    @PostConstruct
    public void postConstruct() {
        objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader()));
    }

}

해당 module이 추가되고 요청을 하게 되면 다음과 같은 형태로 응답을 하게 된다.

{
    "@class": "org.springframework.security.authentication.UsernamePasswordAuthenticationToken",
    "authorities": [
        "java.util.Collections$UnmodifiableRandomAccessList",
        [
            {
                "@class": "org.springframework.security.core.authority.SimpleGrantedAuthority",
                "authority": "ROLE_USER"
            }
        ]
    ],
    "details": {
        "@class": "org.springframework.security.web.authentication.WebAuthenticationDetails",
        "remoteAddress": "192.168.65.4",
        "sessionId": null
    },
    "authenticated": true,
    "principal": {
        "@class": "org.springframework.security.core.userdetails.User",
        "password": null,
        "username": "user",
        "authorities": [
            "java.util.Collections$UnmodifiableSet",
            [
                {
                    "@class": "org.springframework.security.core.authority.SimpleGrantedAuthority",
                    "authority": "ROLE_USER"
                }
            ]
        ],
        "accountNonExpired": true,
        "accountNonLocked": true,
        "credentialsNonExpired": true,
        "enabled": true
    },
    "credentials": null
}

일반적인 json응답과 다르게 @class로 맵핑될 class를 지정하도록 json이 변경된다.
gate server 쪽에도 이 형태의 요청을 serialize, deserialize 하도록 설정을 추가하여야 한다.

gate server 설정

security api를 호출하여 사용하는 서버를 이제 만들어 본다.

dependency 설정

security api server의 설정에서 jdbc 부분만 빼고 동일하다.

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-client</artifactId>
    </dependency>
    <dependency>
        <groupId>io.github.luversof</groupId>
        <artifactId>bluesky-boot-starter-web</artifactId>
    </dependency>
</dependencies>

Feign Client Configuration설정

user api server에 유저 정보를 조회하여 가져올 수 있도록 controller를 제공하고 있다.

이 api를 조회하는 client를 설정한다.

아까 user api server에선 전역으로 사용하는 objectMapper에 SecurityJackson2Modules를 추가하였었다.
하지만 gate server의 경우 이런 형태를 전역으로 사용하면 gate 서버가 앞으로 내려주는 응답도 동일한 형태가 되어 사용하기 불편해진다.
따라서 feign client에서만 해당 형태로 serialize, deserialize 하도록 설정이 필요하다.

feign configuration은 각 feign client가 지정하여 사용하는 configuration으로 @Configuration으로 선언하면 안 된다.

public class UserClientFeignConfiguration {
    
    @Autowired
    private Jackson2ObjectMapperBuilder builder;
    
    @Autowired
    private ObjectProvider<HttpMessageConverter<?>> converters;
    
    @Autowired(required = false) 
    private FeignEncoderProperties encoderProperties;
    
    @Bean
    Decoder userFeignDecoder(ObjectProvider<HttpMessageConverterCustomizer> customizers) {
        return new OptionalDecoder(new ResponseEntityDecoder(new SpringDecoder(this::getHttpMessageConverters, customizers)));
    }
    
    @Bean
    Encoder feignEncoder(ObjectProvider<AbstractFormWriter> formWriterProvider, ObjectProvider<HttpMessageConverterCustomizer> customizers) {
        return new SpringEncoder(new SpringFormEncoder(), this::getHttpMessageConverters, encoderProperties, customizers);
    }

    private HttpMessageConverters getHttpMessageConverters() {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader()));
        
        List<HttpMessageConverter<?>> converterList = converters.orderedStream().filter(x -> !(x instanceof MappingJackson2HttpMessageConverter)).collect(Collectors.toList());
        converterList.add(new MappingJackson2HttpMessageConverter(objectMapper));
        return new HttpMessageConverters(converterList);
    }
    
}

UserDetailsManager, OAuth2AuthorizedClientService Feign Client 설정

이제 해당 Configuration을 사용한 UserDetailsManager, OAuth2AuthorizedClientService Feign Client를 설정한다.

@FeignClient(value = "bluesky-api-user", contextId = "api-user-userDetails", path = "/api/user/userDetails", url = "${gate.feign-client.url.user:}", configuration = UserClientFeignConfiguration.class)
public interface UserDetailsClient {

    @GetMapping("/search/loadUserByUsername")
    public Optional<UserDetails> loadUserByUsername(@RequestParam String username);
    
    @PostMapping
    public Optional<UserDetails> createUser(@RequestBody User user);
    
    @PutMapping
    public Optional<UserDetails> updateUser(@RequestBody User user);
    
    @DeleteMapping
    public void deleteUser(@RequestParam String username);

}
@FeignClient(value = "bluesky-api-user", contextId = "api-user-oauth2AuthorizedClient", path = "/api/user/oAuth2AuthorizedClient", url = "${gate.feign-client.url.user:}", configuration = UserClientFeignConfiguration.class)
public interface OAuth2AuthorizedClientClient {

    @GetMapping
    OAuth2AuthorizedClient loadAuthorizedClient(@RequestParam String clientRegistrationId, @RequestParam String principalName);
    
    @PostMapping
    void saveAuthorizedClient(@RequestBody SaveAuthorizedClientParam saveAuthorizedClientParam);
    
    @DeleteMapping
    void removeAuthorizedClient(@RequestParam String clientRegistrationId, @RequestParam String principalName);
    
    public static record SaveAuthorizedClientParam(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
        
    }
}

UserDetailsManager, OAuth2AuthorizedClientService 구현

Feign Client로 user api server의 응답을 받아 사용하는 UserDetailsManager, OAuth2AuthorizedClientService를 구현한다.

UserDetailsManager의 chagePassword, userExists의 경우 SecurityContextHolder를 사용하는 부분이 있어서 user api server 쪽에 controller 구현을 하지 못하였다. (이는 나중에 고민)

@Component
public class GateUserDetailsManager implements UserDetailsManager {
    

    @Autowired
    private UserDetailsClient userDetailsClient;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userDetailsClient.loadUserByUsername(username).orElseThrow(() -> new BlueskyException(GateUserErrorCode.NOT_EXIST_USER));
    }

    @Override
    public void createUser(UserDetails user) {
        userDetailsClient.createUser((User) user);
    }

    @Override
    public void updateUser(UserDetails user) {
        userDetailsClient.updateUser((User) user);
    }

    @Override
    public void deleteUser(String username) {
        userDetailsClient.deleteUser(username);
    }

    @Override
    public void changePassword(String oldPassword, String newPassword) {
        // TODO Auto-generated method stub
        
    }

    @Override
    public boolean userExists(String username) {
        // TODO Auto-generated method stub
        return false;
    }

}
@Service
public class GateOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService {
    
    @SuppressWarnings("unchecked")
    @Override
    public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) {
        return (T) getClient().loadAuthorizedClient(clientRegistrationId, principalName);
    }

    @Override
    public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
        getClient().saveAuthorizedClient(new SaveAuthorizedClientParam(authorizedClient, principal));
    }

    @Override
    public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
        getClient().removeAuthorizedClient(clientRegistrationId, principalName);
    }
    
    private OAuth2AuthorizedClientClient getClient() {
        return ApplicationContextUtil.getApplicationContext().getBean(OAuth2AuthorizedClientClient.class);
    }

}

Configuration 설정

OAuth2WebSecurityConfiguration에 개별 SecurityFilterChain bean 설정이 있긴 한데 Spring Security의 기본 제공 SecurityFilterChain과 2개가 등록된 경우 올바르게 동작하지 않았다.

두 SecurityFilterChain이 생성되지 않도록 개별 선언으로 지정해 주어야 올바르게 동작한다.

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

@Bean
SecurityFilterChain gateSecurityFilterchain(HttpSecurity http, UserDetailsService userDetailsService, GateOAuth2AuthorizedClientService gateOAuth2AuthorizedClientService)
        throws Exception {

    http.userDetailsService(userDetailsService);
    http.authorizeHttpRequests(requests -> requests.anyRequest().permitAll());
    http.oauth2Login(Customizer.withDefaults());
    http.oauth2Client().authorizedClientService(gateOAuth2AuthorizedClientService);

    var logoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
    logoutSuccessHandler.setUseReferer(true);

    var authenticationSuccessHandler = new SimpleUrlAuthenticationSuccessHandler();
    authenticationSuccessHandler.setUseReferer(true);

    http
            .headers().frameOptions().sameOrigin().and()
            .logout().logoutSuccessHandler(logoutSuccessHandler).and()
            .formLogin()
                .successHandler(authenticationSuccessHandler)
            .and()
            .csrf().disable()
    ;

    return http.build();
}

테스트해 보기

여기까지 진행하여 user api server와 gate server를 구성하였으면 각 서버를 띄우고 gate server에서 /login을 호출하면 다음과 같은 페이지를 확인할 수 있다.

Spring이 제공하는 간단한 로그인 창이다.

아까 테스트로 넣었던 유저 정보 user/pass로 로그인을 하면 로그인 이후 리턴처리가 되어 있지 않아 그냥 해당 화면이 그대로 떠있는 상태이다.

로그인 정보를 확인할 수 있는 controller를 하나 추가하고 해당 주소로 확인해 보면

@GetMapping("/authentication")
public Authentication testSecurityHasRole() {
    SecurityContext context = SecurityContextHolder.getContext();
    return context.getAuthentication();
}

다음과 같이 응답을 확인할 수 있다.

{
    "authorities": [
        {
            "authority": "ROLE_USER"
        }
    ],
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "sessionId": null
    },
    "authenticated": true,
    "principal": {
        "password": null,
        "username": "user",
        "authorities": [
            {
                "authority": "ROLE_USER"
            }
        ],
        "accountNonExpired": true,
        "accountNonLocked": true,
        "credentialsNonExpired": true,
        "enabled": true
    },
    "credentials": null,
    "name": "user"
}

서버와 서버 간 요청/응답 처리에 있는 @class 같은 처리 없이 깔끔하게 로그인 정보를 확인할 수 있다.

oauth2 client 사용해 보기

개발자가 가장 간단하게 oauth2 인증을 사용하는 건 github oauth2를 사용하는 것이다.

github의 Settings -> Developer setting의 OAuth Apps를 선택하고

New OAuth App을 선택하여 새로운 OAuth app을 만들면 된다.

보통 api 제공하는 대부분의 사이트는 localhost 같은 리턴 주소나 http 프로토콜은 허용하지 않지만 github는 로컬 개발을 위한 oauth2 인증을 허용하고 있다.

내 경우 github-local 이란 이름으로 만들었고 다음과 같이 설정하였다.

로컬에서 테스트할 gate server의 포트를 올바르게 입력하고 callback URL은 `/login/oauth2/code/[생성이름]`을 명시한다.

생성하면 Client ID와 Client secret이 부여된다.

이 두 개의 값을 다음과 같이 properties에 추가하면 된다.

spring.security.oauth2.client.registration.github.provider=github
spring.security.oauth2.client.registration.github-local.client-id=생성된ClientId
spring.security.oauth2.client.registration.github-local.client-secret=생성된Secret

이제 gate server에서 /login을 호출하면 기본 로그인 화면 아래에 다음과 같이 추가되어 있는 것을 확인할 수 있다.

github 링크를 클릭하면 github에서 로그인이 되고 db에 생성 oauth2_authorized_client 테이블에 해당 값이 추가되는 것을 확인할 수 있다.

아까 로그인 정보 확인을 위해 추가한 호출 주소 /authentication으로 호출해 보면 기본 인증 결과인 UsernamePasswordAuthenticationToken이 아닌 OAuth2AuthenticationToken 정보를 확인할 수 있다.


여기까지 Spring Security, Spring Security OAuth2 Client를 MSA로 구성하기 위해 진행해 보았다.

웹서비스 구현시 필요한 인증 처리에 대한 설명은 제외하였기 때문에 해당 내용을 알고 있어야 위 내용이 도움이 되지 않을까 싶다.

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요