ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • REST API 보안 적용 - 2
    백기선(인프런 강의)/스프링 기반 REST API 개발 2021. 1. 29. 10:46
    반응형

    공부중인 GIT 주소

    위 GIT주소에 개발 step 별로 commit 해두었다.

     

    진행 과정

    이벤트 조회 및 수정 REST API 개발

    1. Account 도메인 추가
    2. 스프링 시큐리티 적용
    3. 예외 테스트
    4. 스프링 시큐리티 기본 설정
    5. 스프링 시큐리티 폼 인증 설정
    6. 스프링 시큐리티 OAuth2 인증 서버 설정
    7. 리소스 서버 설정
    8. 문자열을 외부 설정으로 빼내기 
    9. 이벤트 API 점검
    10. 현재 사용자 조회
    11. 출력값 제한하기

     

     

     

    5. 스프링 시큐리티 폼 인증 설정

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .anonymous() //익명 사용자 허용
                .and()
            .formLogin() // form인증 허용
                .and()
            .authorizeRequests()
                .mvcMatchers(HttpMethod.GET, "/api/**").authenticated() // 인증 걸림
                //.mvcMatchers(HttpMethod.GET, "/api/**").anonymous() //인증 안걸림
                .anyRequest().authenticated();
    }

    5-1. 익명 사용자 사용 활성화

    • 폼 인증 방식 활성화
    • 스프링 시큐리티가 기본 로그인 페이지 제공

     

    5-2. 요청에 인증 적용

    • /api 이하 모든 GET 요청에 인증이 필요함. (permitAll()을 사용하여 인증이 필요없이 익명으로 접근이 가능케 할 수 있음)
    • 그밖에 모은 요청도 인증이 필요함.

     

     

     

    6. 스프링 시큐리티 OAuth2 인증 서버 설정

    6-1. 의존성 추가

    <dependency>
    	<groupId>org.apache.maven.plugins</groupId>
    	<artifactId>maven-resources-plugin</artifactId>
    	<version>3.2.0</version>
    </dependency>

     

    6-2. GrantType

    Spring OAuth2.0이 인증하는 6가지 인증 방법중 하기 2가지 인증방식을 사용

    Password

    refreshToken

    • 인증된 정보를 계속 요청할 수 있는 토큰(권한)

     

    6-3. AuthorizationServer 

    @Configuration
    @EnableAuthorizationServer
    public class AutoServerConfig extends AuthorizationServerConfigurerAdapter {
    
        @Autowired
        PasswordEncoder passwordEncoder;
    
        @Autowired
        AuthenticationManager authenticationManager;
    
        @Autowired
        AccountService accountService;
    
        @Autowired
        TokenStore tokenStore;
    
        /**
         * 패스워드 설정
         * clientSecret를 사용할 때 사용
         */
        @Override
        public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
            security.passwordEncoder(passwordEncoder);
        }
    
        /**
         * client 설정
         * id 설정
         * password 설정
         * grant_type 설정
         * scope 설정
         * accessTokenValiditySeconds(액세스 토큰 유지 시간)
         * refreshTokenValiditySeconds(리프레시 토큰 유지 시간)
         */
        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            clients.inMemory()
                    .withClient("myApp")
                    .authorizedGrantTypes("password", "refresh_token")
                    .scopes("read", "write") // 정의 한대로
                    .secret(this.passwordEncoder.encode("pass"))
                    .accessTokenValiditySeconds(10 * 60)
                    .refreshTokenValiditySeconds(6 * 10 * 60);
        }
    
        /**
         * thentiacationManager 설정
         * UserDetailsService 설정
         * TokenStored 설정
         */
        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints.authenticationManager(authenticationManager)
                    .userDetailsService(accountService)
                    .tokenStore(tokenStore);
        }
    }

     

    6-4. 응답 결과

     

     

    7. 리소스 서버 설정

    7-1. 리소스 서버 구현 기준

    • 리소스 서버는 이벤트리소스를 제공하는 서버와 같이있는게 맞고
    • 인증서버는 분리하는게 맞다(작은 서비스에서는 합쳐도 상관없음)

     

    7-2. ResourceServer 설정

    EventControllerTest를 돌리게 된다면 Get만 빼고 다 테스트 실패

    @Configuration
    @EnableResourceServer
    public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            resources.resourceId("event");
        }
    
        @Override
        public void configure(HttpSecurity http) throws Exception {
            http
                    .anonymous()
                    .and()
                    .authorizeRequests()
                    .antMatchers("/h2-console/**").permitAll()
                    .mvcMatchers(HttpMethod.GET, "/api/**")
                    .permitAll()
                    .anyRequest() // GET을 제외한 그밖의 요청은 인증 필요
                    .authenticated()
                    .and()
                    .exceptionHandling()
                    .accessDeniedHandler(new OAuth2AccessDeniedHandler());
        }
    }

     

    Get말고 다른 테스트 성공을 하기 위해서는

    @Before
    public void setUp(){
        /*
        테스트를 도는 중에는 데이터를 공유하기때문에 이와같이 삭제를 해줘야 한다.
         */
        this.eventRepository.deleteAll();
        this.accountRepository.deleteAll();
    }

    Header에 token을 넣어야 한다.

     

    private String getBearerToken() throws Exception {
        return "Bearer " + getAccessToken();
    }
    
    private String getAccessToken() throws Exception {
        String userName = "JinSeok";
        String password = "jinSeok";
    
        Account jinSeok = Account.builder()
                .email(userName)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        this.accountService.saveAccount(jinSeok);
    
        String clientId = "myApp";
        String clientSecret = "pass";
    
        ResultActions perform = this.mockMvc.perform(post("/oauth/token")
                .with(httpBasic(clientId, clientSecret))
                .param("username", userName)
                .param("password", password)
                .param("grant_type", "password"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("access_token").exists());
        var responseBody = perform.andReturn().getResponse().getContentAsString();
        Jackson2JsonParser parser = new Jackson2JsonParser();
        return parser.parseMap(responseBody).get("access_token").toString();
    }

     

     

    8. 문자열을 외부 설정으로 빼내기 

    8-1. 의존성 추가

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>

     

    8-2. Java code 추가

    @Component
    @ConfigurationProperties(prefix = "my-app")
    @Getter @Setter
    public class AppProperties {
    
        @NotEmpty
        private String adminUserName;
        @NotEmpty
        private String adminPassword;
        @NotEmpty
        private String userUserName;
        @NotEmpty
        private String userPassword;
        @NotEmpty
        private String clientId;
        @NotEmpty
        private String clientSecret;
    
    }

     

    8-3 properties 추가

    my-app.admin-username=admin@test.com
    my-app.admin-password=admin
    my-app.user-username=user@test.com
    my-app.user-password=user
    my-app.client-id=myApp
    my-app.client-secret=pass

     

     

    9. 이벤트 API 점검

    9-1. 토큰 발급 받기

    토큰 발급 받기

    • POST /oauth/token
    • BASIC authentication 헤더
      • client Id(myApp) + client secret(pass)

    요청 본문 폼

    • username: admin@email.com
    • password: admin
    • grant_type: password

     

     

    9-2. 토큰 갱신하기

    POST /oauth/token

    • BASIC authentication 헤더
    • client Id(myApp) + client secret(pass)

    요청 본문 폼

    • token: 처음에 발급받았던 refersh 토큰
    • grant_type: refresh_token

     

    9-3. 이벤트 목록 조회 API

    로그인 했을 때

    • 이벤트 생성 링크 제공

     

    9-4. 이벤트 조회

    로그인 했을 때

    • 이벤트 Manager인 경우에는 이벤트 수정 링크 제공

     

    10. 현재 사용자 조회

    10-1. SecurityContext

    User principal = (User)authentication.getPrincipal(); 에서 꺼낸 객체는 아래 메소드에서 리턴된 값을 꺼낸다.

    package study.accounts.service;
    
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));
        return new User(account.getEmail(), account.getPassword() ,authorities(account.getRoles()));
    }

     

    10-2. @AuthenticationPrincipal spring.security.User user

    • 인증 안한 경우에 null
    • 인증 한 경우에는 username과 authorities 참조 가능
    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler,
                                      @AuthenticationPrincipal User user) {
        Page<Event> page = this.eventRepository.findAll(pageable);
        // PagedModel<EntityModel<Event>> pageResource = assembler.toModel(page, e -> new EventResource(e));
        var pageResource = assembler.toModel(page, e -> new EventResource(e));
        pageResource.add(Link.of("/docs/index.html#resources-events-list").withRel("profile"));
    
        if(user != null){
            pageResource.add(linkTo(EventController.class).withRel("create-event"));
        }
    
        return ResponseEntity.ok(pageResource);
    }

     

    10-2. @AuthenticationPrincipal

    10-1, 10-2의 코드가 너무 길다면 @AuthenticationPrincipal을 이용하면 된다. 사용하게 되면 user의 정보를 알 수 있기 때문이다.

     

    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler,
                                      @AuthenticationPrincipal(expression = "account ") Account account) {
        Page<Event> page = this.eventRepository.findAll(pageable);
        // PagedModel<EntityModel<Event>> pageResource = assembler.toModel(page, e -> new EventResource(e));
        var pageResource = assembler.toModel(page, e -> new EventResource(e));
        pageResource.add(Link.of("/docs/index.html#resources-events-list").withRel("profile"));
    
        if(account != null){
            pageResource.add(linkTo(EventController.class).withRel("create-event"));
        }
    
        return ResponseEntity.ok(pageResource);
    }

     

    SpEL을 사용

    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    @AuthenticationPrincipal(expression = "account'")
    public @interface CurrentUser {
    
    }

     

    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler,
                                      @CurrentUser Account account) {
        Page<Event> page = this.eventRepository.findAll(pageable);
        // PagedModel<EntityModel<Event>> pageResource = assembler.toModel(page, e -> new EventResource(e));
        var pageResource = assembler.toModel(page, e -> new EventResource(e));
        pageResource.add(Link.of("/docs/index.html#resources-events-list").withRel("profile"));
    
        if(account != null){
            pageResource.add(linkTo(EventController.class).withRel("create-event"));
        }
    
        return ResponseEntity.ok(pageResource);
    }

     

    하지만 위와같이 사용하게 되면 오류가 발생이 된다. 그 이유는 account가 인증이 안되었기 때문에 아래와같이 수정해야 한다.

    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
    public @interface CurrentUser {
    	// 현재 인증 정보가 anonymousUse 인 경우에는 null을 보내고 아니면 “account”를 꺼내준다.
    }

     

     

     

    11. 출력값 제한하기

    11-1. JsonSerializer<Account> 구현

    public class AccountSerializer extends JsonSerializer<Account> {
    
        @Override
        public void serialize(Account account, JsonGenerator gen, SerializerProvider serializers) throws IOException {
            gen.writeStartObject();
            gen.writeNumberField("id", account.getId());
            gen.writeEndObject();
        }
    }

     

    11-2. @JsonSerialize(using) 설정

     

    11-3. 설정 전후 비교

    설정전 데이터

    설정 전

    설정 후 데이터

    설정 후

     

    반응형
Designed by Tistory.