스프링

 

 

중급자를 위해 준비한
[백엔드] 강의입니다.

다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발하는 강의입니다.

✍️
이런 걸
배워요!

Self-Describtive Message와 HATEOAS를 만족하는 REST API를 이해

다양한 스프링 기술을 활용하여 REST API 개발

스프링 HATEOAS와 스프링 REST Docs 프로젝트 활용

테스트 주도 개발(TDD)

스프링으로 REST를 따르는 API를 만들어보자!
백기선의 스프링 기반 REST API 개발

스프링 기반 REST API 개발

이 강의에서는 다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발합니다.

그런 REST API로 괜찮은가

2017년 네이버가 주관한 개발자 컨퍼런스 Deview에서 그런 REST API로 괜찮은가라는 이응준님의 발표가 있었습니다. 현재 REST API로 불리는 대부분의 API가 실제로는 로이 필딩이 정의한 REST를 따르고 있지 않으며, 그 중에서도 특히 Self-Descriptive Message와 HATEOAS가 지켜지지 않음을 지적했고, 그에 대한 대안을 제시되었습니다.

이번 강의는 해당 발표에 영감을 얻어 만들어졌습니다. 2018년 11월에 KSUG에서 동일한 이름으로 세미나를 진행한 경험이 있습니다. 4시간이라는 짧지 않은 발표였지만, 빠르게 진행하느라 충분히 설명하지 못하고 넘어갔던 부분이 있었습니다. 내용을 더 보충하고, 또 해결하려는 문제에 대한 여러 선택지를 제공하는 것이 좋을 것 같아 이 강의를 만들게 되었습니다.
또한 이 강의에서는 제가 주로 사용하는 IntelliJ 단축키도 함께 설명하고 있습니다.

 

인프런 :

 

강의 :  https://www.inflearn.com/course/spring_rest-api#

 

 

강의 자료 :  https://docs.google.com/document/d/1GFo3W6XxqhxDVVqxiSEtqkuVCX93Tdb3xzINRtTIx10/edit

 

 

강의 소스 :

 

https://gitlab.com/whiteship/natural

 

https://github.com/keesun/study

ksug201811restapi

 

 

 

이번 강좌에서는 다음의 다양한 스프링 기술을 사용하여 REST API를 개발합니다.

  • 스프링 프레임워크

  • 스프링 부트

  • 스프링 데이터 JPA

  • 스프링 HATEOAS

  • 스프링 REST Docs

  • 스프링 시큐리티 OAuth2

 

또한 개발은 테스트 주도 개발(TDD)로 진행하기 때문에 평소 테스트 또는 TDD에 관심있던 개발자에게도 이번 강좌가 도움이 될 것으로 기대합니다.

 

사전 학습

  • 스프링 프레임워크 핵심 기술 (필수)

  • 스프링 부트 개념과 활용 (필수)

  • 스프링 데이터 JPA (선택)

 

학습 목표

  • Self-Describtive Message와 HATEOAS를 만족하는 REST API를 이해합니다.

  • 다양한 스프링 기술을 활용하여 REST API를 개발할 수 있습니다.

  • 스프링 HATEOAS와 스프링 REST Docs 프로젝트를 활용할 수 있습니다.

  • 테스트 주도 개발(TDD)에 익숙해 집니다.

 

 

REST API 는 다음 두가지를 만족해야 한다.

1) Self-Describtive Message

2) HATEOAS

 

 

 

 

 

 

 

[5]  5. REST API 보안 적용

 

 

 

 

 

32. Account 도메인 추가

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16440&category=questionDetail&tab=curriculum

 

OAuth2로 인증을 하려면 일단 Account 부터

  • id

  • email

  • password

  • roels

 

AccountRoles

  • ADMIN, USER

 

JPA 맵핑

  • @Table(“Users”)

 

JPA enumeration collection mapping

 

 @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    private Set roles;

 

Event에 owner 추가

  @ManyToOne
    Account manager;

 

 

 

 

1)Account

package net.macaronics.restapi.accounts;

import jakarta.persistence.*;
import lombok.*;

import java.util.Set;

@Entity
@Getter
@Setter
@EqualsAndHashCode(of="id")
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Account {

    @Id
    @GeneratedValue
    private Integer id;

    private String email;

    private String password;

    

    //권한 정보
    //ADMIN, USER 권한 모두 있는 경우
    //ADMIN 권한만 있는 경우
    //USER 권한만 있는 경우
    //컬렉션 타입으로 설정  @ElementCollection
    @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    private Set roles;
    
}

 

 

2) AccountRole

package net.macaronics.restapi.accounts;

public enum AccountRole {
    ADMIN, USER
}

 

 

 

 

 

 

 

 

 

33. 스프링 시큐리티 적용

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16441&category=questionDetail&tab=curriculum

 

 

스프링 시큐리티

  • 웹 시큐리티 (Filter 기반 시큐리티)

  • 메소드 시큐리티 

  • 이 둘 다 Security Interceptor를 사용합니다.

    • 리소스에 접근을 허용할 것이냐 말것이냐를 결정하는 로직이 들어있음.

 

 

 

 

 

		
		
			org.springframework.security.oauth.boot
			spring-security-oauth2-autoconfigure
			2.6.8
		

 

 

테스트 다 깨짐 (401 Unauthorized)

  • 깨지는 이유는 스프링 부트가 제공하는 스프링 시큐리티 기본 설정 때문

 

 

1)  AccountService 

package net.macaronics.restapi.accounts;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.io.Serial;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class AccountService implements UserDetailsService {

    @Autowired
    AccountRepository accountRepository;

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

    private Collection authorities(Set roles){
        return  roles.stream().map(r->new SimpleGrantedAuthority("ROLE_"+r.name()))
                .collect(Collectors.toSet());
    }



}

 

 

 

2) AccountRepository

package net.macaronics.restapi.accounts;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface AccountRepository  extends JpaRepository {

   Optional findByEmail(String username);
}

 

 

 

3) 테스트

AccountServiceTest

 

package net.macaronics.restapi.accounts;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Set;


@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
class AccountServiceTest {

    @Autowired
    AccountService accountService;


    @Autowired
    AccountRepository accountRepository;

    @Test
    public void findByUsername(){
        //Given
        String password="1111";
        String username="junho@gmail.com";
        Account account=Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        accountRepository.save(account);

        //WHEN
        UserDetailsService userDetailsService=(UserDetailsService) accountService;
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        //Then
        Assertions.assertThat(userDetails.getPassword()).isEqualTo(password);
    }



}

 

 

 

 

 

 

 

 

 

 

 

 

 

34. 예외테스트

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16442&category=questionDetail&tab=curriculum

 

 

1. @Test(expected)

 

예외 타입만 확인 가능

 

2. try-catch

 

예외 타입과 메시지 확인 가능.

하지만 코드가 다소 복잡.

 

3. @Rule ExpectedException

 

코드는 간결하면서 예외 타입과 메시지 모두 확인 가능

 

 

AccountServiceTest

 

package net.macaronics.restapi.accounts;

import org.assertj.core.api.Assertions;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.jupiter.api.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Set;


@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
class AccountServiceTest {

    @Rule
    public ExpectedException expectedException=ExpectedException.none();

    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;

    @Test
    public void findByUsername(){
        //Given
        String password="1111";
        String username="junho@gmail.com";
        Account account=Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        accountRepository.save(account);

        //WHEN
        UserDetailsService userDetailsService=(UserDetailsService) accountService;
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        //Then
        Assertions.assertThat(userDetails.getPassword()).isEqualTo(password);
    }



    @Test
    public void findByUsernameFail(){
        String username = "test@gmail.com";
        try{
            accountService.loadUserByUsername(username);
            Assert.fail("supposed to be failed");
        }catch (UsernameNotFoundException e){
            Assertions.assertThat(e.getMessage()).containsSequence(username);
        }
    }



    @Test
    public void findByUsernameFail2(){
        //에러 발생 가능성을 먼저 코딩한다
        String username="test@gmail.com";
        expectedException.expect(UsernameNotFoundException.class);
        expectedException.expectMessage(Matchers.containsString(username));

        //When
        accountService.loadUserByUsername(username);
    }

    
}

 

 

 

 

 

 

 

 

 

 

 

35. 스프링 시큐리티 기본 설정

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16443&category=questionDetail&tab=curriculum

 

라이브러리

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-oauth2-client</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>

 

 

 

RestapiApplication

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




 

 

 

 

1) AccountService

package net.macaronics.restapi.accounts;


import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Getter
public class AccountService implements UserDetails {

    private  Account account;

    public AccountService(Account account) {
        this.account = account;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            private static final long serialVersionUID = 1L;

            @Override
            public String getAuthority() {

                return account.getRoles().toString();
            }
        });
        return collect;
    }

    /**
     * 사용자를 인증하는 데 사용된 암호를 반환합니다.
     */
    @Override
    public String getPassword() {
        return account.getPassword();
    }

    /**
     * 사용자를 인증하는 데 사용된 사용자 이름을 반환합니다. null을 반환할 수 없습니다.
     */
    @Override
    public String getUsername() {
        return account.getEmail();
    }

    /**
     * 사용자의 계정이 만료되었는지 여부를 나타냅니다. 만료된 계정은 인증할 수 없습니다.
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 사용자가 잠겨 있는지 또는 잠금 해제되어 있는지 나타냅니다. 잠긴 사용자는 인증할 수 없습니다.
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 사용자의 자격 증명(암호)이 만료되었는지 여부를 나타냅니다. 만료된 자격 증명은 인증을 방지합니다.
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 사용자가 활성화되었는지 비활성화되었는지 여부를 나타냅니다. 비활성화된 사용자는 인증할 수 없습니다.
     */
    @Override
    public boolean isEnabled() {
        // 우리 사이트 1년동안 회원이 로그인을 안하면!! 휴먼 계정으로 하기로 함.
        // 현재시간-로긴시간=>1년을 초과하면 return false;
        return true;
    }

    public boolean isWriteEnabled() {
        if (account.getRoles().equals(AccountRole.USER))
            return false;
        else
            return true;
    }

    public boolean isWriteAdminAndManagerEnabled() {
        if (account.getRoles().equals(AccountRole.ADMIN) || account.getRoles().equals(AccountRole.USER))
            return true;
        else
            return false;
    }
}

 

 

 

 

2)PrincipalDetailsService

package net.macaronics.restapi.accounts;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService{


    private final AccountRepository accountRepository;

    private final BCryptPasswordEncoder passwordEncoder;


    public Account saveAccount(Account account){
        account.setPassword(this.passwordEncoder.encode(account.getPassword()));
        return this.accountRepository.save(account);
    }


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

    private Collection<? extends GrantedAuthority> authorities(Set<AccountRole> roles){
        return  roles.stream().map(r->new SimpleGrantedAuthority("ROLE_"+r.name()))
                .collect(Collectors.toSet());
    }



}



 

 

 

3)SecurityConfig

package net.macaronics.restapi.configs;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.DispatcherType;
import lombok.RequiredArgsConstructor;
import net.macaronics.restapi.accounts.CustomAuthFailureHandler;
import net.macaronics.restapi.accounts.PrincipalDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 1.securedEnabled
 *
 * @Secured 애노테이션을 사용하여 인가 처리를 하고 싶을때 사용하는 옵션이다. 기본값은 false 2.prePostEnabled
 *
 *          @PreAuthorize, @PostAuthorize 애노테이션을 사용하여 인가 처리를 하고 싶을때 사용하는 옵션이다.
 *          기본값은 false 3.jsr250Enabled
 *
 * @RolesAllowed 애노테이션을 사용하여 인가 처리를 하고 싶을때 사용하는 옵션이다. 기본값은 false
 *
 *               @Secured, @RolesAllowed 특정 메서드 호출 이전에 권한을 확인한다. SpEL 지원하지 않는다.
 * @Secured 는 스프링에서 지원하는 애노테이션이며, @RolesAllowed는 자바 표준
 *
 *          @Secured("ROLE_ADMIN") @RolesAllowed("ROLE_ADMIN")
 *
 */

//구글로그인이 완료된 뒤의 후처리가 필요함. 1.코드 받기(인증) , 2.엑세스토큰(권한), 3.사용자프로필 정보를 가져오기
//4.그 정보를 토대로 회원가입을 자동으로 진행시키기도 함
//4-2 (이메일,전화번호,이름,아이디) 쇼핑몰 -> (집주소),

@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) // secured 어노테이션 활성화
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 됩니다.
@RequiredArgsConstructor
@Component
public class SecurityConfig {

    private final CustomAuthFailureHandler customFailureHandler;

    private final ObjectMapper objectMapper;


    @Autowired
    PrincipalDetailsService principalDetailsService;


    private final BCryptPasswordEncoder passwordEncoder;




    /**
     * Spring Secureity 에서 인증은 AuthenticationManager 를 통해 이루어지며
     * AuthenticationManagerBuilder 가 AuthenticationManager 를 생성합니다.
     * userDetailsService 를 구현하고 있는 객체로 principalDetailsServicee를 지정해 주며, 비밀번호 암호화를
     * 위해 passwordEncoder 를 지정해줍니다.
     */
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(principalDetailsService).passwordEncoder(passwordEncoder);
    }


    @Bean
    public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(principalDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return provider;
    }

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**").allowedMethods("*");
            }

//			@Override
//			public void addResourceHandlers(ResourceHandlerRegistry registry) {
//				// /images/** 은 /resources/images/ 으로 시작하는 uri호출은 /resources/images/ 경로 하위에 있는 리소스 파일이다 라는 의미입니다.
//				registry.addResourceHandler("/resources/upload/**").addResourceLocations("file:///C:/upload/");
//			}


        };
    }


    /**
     * 정적인 영역 무시
     * @return
     */
    @Bean
    public WebSecurityCustomizer configure() {
        return (web) -> web.ignoring().requestMatchers(
                "/h2-console/**"
        );
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable().cors().disable()

                .authorizeHttpRequests(request -> request
                        .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()

                        .requestMatchers("/h2-console","/h2-console/**", "/docs/index.html").permitAll()
                        .requestMatchers(HttpMethod.GET,"/api/**").authenticated()
                        .anyRequest().authenticated()


                )
                //.anonymous().and()
                .headers().frameOptions().sameOrigin()  // 여기!
                .and()

                .formLogin(
                        login -> login.usernameParameter("email")
                            .passwordParameter("password")
                                .failureHandler(customFailureHandler).permitAll()
                )
                

//                .formLogin(login -> login
//                        .loginPage("/loginForm")
//                        .loginProcessingUrl("/login")
//                        .usernameParameter("userId")
//                        .passwordParameter("password")
//                        .defaultSuccessUrl("/", true)
//                        .failureHandler(customFailureHandler) // 로그인 오류 실패 체크 핸들러
//                        .permitAll()
               // )
                .logout();

        return http.build();
    }





}

 

 

 

 

 

4)CustomAuthFailureHandler

package net.macaronics.restapi.accounts;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;

import java.io.IOException;
import java.net.URLEncoder;

@Configuration
@Log4j2
public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        String errorMessage;

        log.info("*****  CustomAuthFailureHandler  ==>  {} ", exception.getMessage());

        if (exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해 주세요.";
        } else if (exception instanceof InternalAuthenticationServiceException) {
            errorMessage = "내부적으로 발생한 시스템 문제로 인해 요청을 처리할 수 없습니다. 관리자에게 문의하세요.";
        } else if (exception instanceof UsernameNotFoundException) {
            errorMessage = "계정이 존재하지 않습니다. 회원가입 진행 후 로그인 해주세요.";
        } else if (exception instanceof AuthenticationCredentialsNotFoundException) {
            errorMessage = "인증 요청이 거부되었습니다. 관리자에게 문의하세요.";
        } else {
            errorMessage = exception.getMessage();
            // errorMessage = "알 수 없는 이유로 로그인에 실패하였습니다 관리자에게 문의하세요.";
        }
        errorMessage = URLEncoder.encode(errorMessage, "UTF-8");
        setDefaultFailureUrl("/loginForm?error=true&exception=" + errorMessage);

        super.onAuthenticationFailure(request, response, exception);
    }
}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

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

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16444&category=questionDetail&tab=curriculum

 

 

 

1) 테스트

package net.macaronics.restapi.accounts;

import org.assertj.core.api.Assertions;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.jupiter.api.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Set;


@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
class AccountServiceTest {

    @Rule
    public ExpectedException expectedException=ExpectedException.none();

    @Autowired
    PrincipalDetailsService principalDetailsService;

    @Autowired
    AccountRepository accountRepository;


    @Autowired
    BCryptPasswordEncoder passwordEncoder;

    @Test
    public void findByUsername(){
        //Given
        String password="1111";
        String username="junho@gmail.com";
        Account account=Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        Account save = principalDetailsService.saveAccount(account);

        //WHEN
        UserDetailsService userDetailsService=(UserDetailsService) principalDetailsService;
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        //Then
        Assertions.assertThat(this.passwordEncoder.matches(password, userDetails.getPassword())).isTrue();
    }



    @Test
    public void findByUsernameFail(){
        String username = "test@gmail.com";
        try{
            principalDetailsService.loadUserByUsername(username);
            Assert.fail("supposed to be failed");
        }catch (UsernameNotFoundException e){
            Assertions.assertThat(e.getMessage()).containsSequence(username);
        }
    }



    @Test
    public void findByUsernameFail2(){
        //에러 발생 가능성을 먼저 코딩한다
        String username="test@gmail.com";
        expectedException.expect(UsernameNotFoundException.class);
        expectedException.expectMessage(Matchers.containsString(username));

        //When
        principalDetailsService.loadUserByUsername(username);
    }


}

 

 

 

2)시작시 테스트 계정 등록 처리

package net.macaronics.restapi;

import net.macaronics.restapi.accounts.Account;
import net.macaronics.restapi.accounts.AccountRole;
import net.macaronics.restapi.accounts.PrincipalDetailsService;
import org.modelmapper.ModelMapper;
import org.modelmapper.config.Configuration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.util.Set;

@SpringBootApplication
public class RestapiApplication {

	public static void main(String[] args) {
		SpringApplication.run(RestapiApplication.class, args);
	}

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




	@Bean
	public ApplicationRunner applicationRunner(){
		return new ApplicationRunner() {

			@Autowired
			PrincipalDetailsService principalDetailsService;
			@Override
			public void run(ApplicationArguments args) throws Exception {
				Account newAccount = Account.builder()
						.email("test@gmail.com")
						.password("1111")
						.roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
						.build();
				Account saveAccount = principalDetailsService.saveAccount(newAccount);
				System.out.println( "저장된 비밀번호 " +saveAccount.getPassword());
			}
		};
	}



}

 

 

 

 

 

 

 

 

 

AuthorizationServerConfigurerAdapter 스프링 부트 AuthorizationServer  dprecate 되어 스프링부트 3.0  서는 사용할 수 없다.

 

참조 :

 

1. [springboot, oauth] Authorization Server(인증서버) 구축하기

 

 

 

2. https://buddhiprabhath.medium.com/spring-boot-oauth-2-0-separating-authorization-service-and-resource-service-1641ebced1f0

 

 

 

oaut2   + jwt 방식으로 사용할 것

 

따라서,  강의 내용에서 인증 방식은 참조만 할것

 

 

 

 

 

@ConfigurationProperties 사용법 

 

 

ConfigurationProperties

*.properties , *.yml 파일에 있는 property를 자바 클래스에 값을 가져와서(바인딩) 사용할 수 있게 해주는 어노테이션

 

 

Spring boot 에서는 운영에 필요한 설정(DB 정보, LOG설정 등등 )들을 *.properties , *.yml 에 써두고 관리한다.

이 설정은 KEY - VALUE 의 형태로 저장되어 관리하고 있으며 @Value 을 사용하여 바인딩을 할 수 있다.

 

아래와 같은 properties 파일이 있다고 가정할 때 

 

site-url.naver=https://www.naver.com
site-url.google=https:/google.com

 

 

@Value 를 사용하여 바인딩을 하면 다음과 같은 자바 코드가 나온다.

@Value("${site-url.naver}")
private String naver;

@Value("${site-url.google}")
private String google

 

@Value를 사용하여 바인딩을 하는 방법은 문자열을 사용하기에 오타가 날 수도 있다.

그래서 클래스 파일로 관리하는 방법을 찾아봤다

 

1. properties에서 오토컴플릿을 지원하도록 하기 위한 dependency를 추가

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

 

 

2. 클래스 파일 생성

@ConfigurationProperties 이 좋은 이유 여러 표기법에 대해서 오토로 바인딩해 준다. ( 아래 참고 )

 

acme.my-project.person.first-nameproperties 와 .yml에 권장되는 표기 방법 

acme.myProject.person.firstName표준 카멜 케이스 문법.

acme.my_project.person.first_name.properties와 .yml 에서 사용가능한 방법 ( - 표기법이 더 표준 )

ACME_MYPROJECT_PERSON_FIRSTNAME시스템 환경 변수를 사용할 때 권장

 

 

 

 

@Component로 bean을 등록해야 한다.

@ConfigurationProperties에 prifix를 설정한다.

properties 파일에 있는 site-url.* 에 대하여 바인딩한다.

 

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import lombok.Data;

@Component
@ConfigurationProperties(prefix = "site-url")
@Data
public class siteUrlProperties {
	private String naver;
	private String google;
	
}

 

 

 

3. 확인

@Controller
@RequestMapping("/")
@Slf4j
public class MainController {
	
	@Autowired
	siteUrlProperties siteUrlProperties;
	
	@GetMapping("")
	@ResponseBody
	public String test(Model model) {
		return siteUrlProperties.getNaver();
	}
	
}

 

 

@ConfiguConfigurationProperties 어노테이션을 사용하여 property 값을 사용하면 매핑을 유연하게 할 수 있다는 장점이 있지만 SpEL를 사용할 수 없다.

SpEL를 사용할 때에는 @Value를 사용해야 한다.

 

그 외에는 @ConfiguConfigurationProperties를 사용하는게 코드가 깔끔해진다.

 

 

 

 

39.스프링 시큐리티 현재 사용자

 

SecurityContext

  • 자바 ThreadLocal 기반 구현으로 인증 정보를 담고 있다.

  • 인증 정보 꺼내는 방법: 

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

 

@AuthenticationPrincipal spring.security.User user

  • 인증 안한 경우에 null

  • 인증 한 경우에는 username과 authorities 참조 가능

 

spring.security.User를 상속받는 클래스를 구현하면

  • 도메인 User를 받을 수 있다.

  • @AuthenticationPrincipa me.whiteship.user.UserAdapter 

  • Adatepr.getUser().getId()

 

SpEL을 사용하면

  • @AuthenticationPrincipa(expression=”account”) me.whiteship.user.Account 

 

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

 

커스텀 애노테이션을 만들면

  • @CurrentUser Account account

  • 엇? 근데 인증 안하고 접근하면..?

 

expression = "#this == 'anonymousUser' ? null : account"

  • 현재 인증 정보가 anonymousUse 인 경우에는 null을 보내고 아니면 “account”를 꺼내준다.

 

조회 API 개선

  • 현재 조회하는 사용자가 owner인 경우에 update 링크 추가 (HATEOAS)

 

수정 API 개선

현재 사용자가 이벤트 owner가 아닌 경우에 403 에러 발생

 

 

 

 

 

Events API 개선: 출력값 제한하기

 

생성 API 개선

  • Event owner 설정

  • 응답에서 owner의 id만 보내 줄 것.

{
  "id" : 4,
  "name" : "test 3PISM1Ju",
  "description" : "test event",
...
  "free" : false,
  "eventStatus" : "DRAFT",
  "owner" : {
    "id" : 3,
    "email" : "keesun@email.com",
    "password" : "{bcrypt}$2a$10$3z/rHmeYsKpoOQR3aUq38OmZjZNsrGfRZxSnmpLfL3lpLxjD5/JZ6",
    "roles" : [ "USER", "ADMIN" ]
  },

 

  • JsonSerializer<User> 구현

  • @JsonSerialize(using) 설정

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

불평, 불만, 헛된 마음가짐으로 정신을 더럽히는 자는 자신의 육체와 주변 환경까지도 경시하여 더럽힐 것이다.

댓글 ( 4)

댓글 남기기

작성