스프링 시큐리티로 인증 폼 구현하기

franc·2021년 8월 19일
0
post-thumbnail

대부분의 서비스는 해당 서비스의 사용자라는 인증을 요구한다.
우리는 로그인이라는 인증절차를 거치게 되며, 인증을 마치면 서비스에 일정부분 접근할 수 있는 인가 권한을 갖게 된다.

Spring에서는 이런 복잡한 인증/인가 처리 환경을 Spring Security라는 프레임워크에 위임하여 간단하게 구성할 수 있는데, Spring Security에서 인증/인가, 보안과 관련된 많은 옵션을 제공해주기 때문에, 개발자는 이 옵션을 환경에 맞게 설정하기만 하면 된다.


📖 스프링 시큐리티 연동하기

스프링부트 웹 어플리케이션에 시큐리티를 연동했을 때 생기는 일

@Controller
public class FormController {

    /**
     * 메인 페이지 (인증없이 접근 가능)
     */
    @GetMapping("/")
    public String index() {
        return "/index";
    }

    /**
     * 관리자 페이지 (인증필요, ADMIN 권한만 접근 가능)
     */
    @GetMapping("/admin")
    public String admin() {
        return "/admin";
    }

    /**
     * 회원 페이지 (인증필요, USER 이상의 권한 접근 가능)
     */
    @GetMapping("/user")
    public String user() {
        return "/user";
    }

}

위와 같이 3개의 페이지로 구성된 애플리케이션이 있다.
메인화면인 index를 제외한 나머지 페이지는 인증/인가 처리가 필요한 상황이다.

하지만 현재는 아무런 처리를 하지 않은 상태기 때문에, 바램과 달리 아무나 접근 할 수 있으며, 우리는 이를 스프링 시큐리티를 통해 해결하고자 한다.


스프링 시큐리티 연동

먼저 스프링 시큐리티 의존성을 추가한다.
org.springframework.boot:spring-boot-starter-security

의존성만 추가하고 인덱스를 호출한 결과
만든적 없는 경로(/login)로 이동.. 그리고 처음보는 로그인 폼...

의존성만 추가했는데 스프링 시큐리티가 활성화 되었다.
(인증이 필요한 경우 시큐리티는 요청을 가로채서 로그인 화면으로 튕겨낸다.)

로그인 폼의 경우 시큐리티가 제공하는 기능 중 하나인, 자체 폼-로그인이 활성화 된 것으로, 별도의 설정을 통해 로그인 폼과 경로를 직접 만들어 사용할 수도 있다.

디폴트 유저계정 [id:'user', pwd: 로그에 임시 패스워드 출력]
디폴트 계정을 통해 로그인을 하면, 모든 페이지에 정상적으로 접근이 가능하다.
(/logout을 통해 로그아웃도 가능하다.)

시큐리티 연동으로 인증 기능이 활성화 된 것은 확인했다.
하지만 현재 모든 페이지가 인증을 거쳐야 하는 상태로, 아래의 문제점을 해결해야 한다.

  1. index의 경우 인증 없이 누구나 접근 가능해야 한다.
  2. user, admin의 경우 인증 뿐만 아니라 권한으로 접근을 통제해야 한다.

시큐리티 설정을 통해 접근 통제하기

시큐리티는 인증/인가 그리고 보안에 관한 다양한 옵션을 제공한다.
이 옵션들을 아래와 같이 config 클래스를 작성해 사용할 수 있다.


/**
 * @EnableWebSecurity + WebSecurityConfigurerAdapter
 *    ==> 웹과 관련된 시큐리티 설정을 할 수 있다.    
 */
@Configuration
@EnableWebSecurity
public class SecurityConrig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	// HttpSecurity를 통해 HTTP 요청에 대한 웹 기반 보안을 구성할 수 있다.
        http
            .authorizeRequests() //HttpServletRequest에 따라 접근을 제한
                .antMatchers("/").permitAll() // 해당 요청은 인증이 필요 없다.
                .antMatchers("/admin").hasRole("ADMIN") // 해당 요청은 ADMIN 권한만 접근하도록...
                .antMatchers("/user").hasRole("USER") // 해당 요청은 USER 권한만 접근하도록...
                .anyRequest().authenticated(); // 이외의 모든 요청은 인증을 거친다.

        http
            .formLogin() // 폼 기반 로그인 사용
                .and()
            .httpBasic(); // Http 기반 로그인 사용
    }
}

이제 index는 인증없이 접근 할 수 있으며, 나머지 페이지는 특정 권한만 접근할 수 있게 되었다.

하지만 아직 처리해야 할 문제점은 남아있는데...

  1. 디폴트 계정엔 권한정보가 없기 때문에, admin, user 페이지에 대한 인가 테스트가 불가능하다.
  2. 디폴트 계정 말고, DB 회원 풀을 통한 인증/인가 환경을 구현하고 싶다.


📖 DB 회원 풀을 통한 인증

시큐리티를 DB의 회원 풀과 연동하여 로그인/회원가입 처리

보통의 환경에서는 DAO(또는 Repository)에 쿼리메서드를 작성하고, 이를 Service에서 비즈니스에 맞게 호출하는 방식으로 CRUD를 처리한다.

스프링 시큐리티 또한 DAO와 연동하여 인증을 할 수 있도록 'Service 인터페이스'를 제공하는데, 우리는 이 인터페이스가 제공하는 메서드에 DAO_쿼리메서드를 기반으로 한 인증 로직을 구현하기만 하면 된다.

DB연동을 위한 선행작업

[❗️ 편의를 위해 'JPA'와 'H2(In-Memory DB)'를 통해 회원 풀을 구축합니다.]

1. 회원 스키마 생성

  • 인증 필수 항목 : username, password, role

@Entity
@SequenceGenerator(
        name = "seq_member_id",
        sequenceName = "MEMBER_ID_SEQ",
        initialValue = 1000000001,
        allocationSize = 1
)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE
    	, generator = "seq_member_id")
    private Long member_id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String role;
    
    ... 
    

2. 'username'으로 회원정보를 가져오는 DAO_쿼리메서드 작성

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByUsername(String username);
}

스프링 시큐리티의 DAO 연동 인터페이스 구현

선행작업이 모두 끝났다면, 본격적으로 시큐리티와 DAO를 연동해서 DB를 통한 인증을 구현해본다.

@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
    
    final MemberRepository memberRepository;

    /**
     * 인증/인가 DB 회원 풀로 연동
     * @param username
     * @return UserDetails
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. DAO_findByUsername 쿼리메서드로 유저정보 가져오기
        Member member = memberRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("Member Not Found : " + username));

        // 2. (1)의 유저정보를 UserDetails 타입으로 리턴
        return User.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .roles(member.getRole())
                .build();
    }
}

UserDetailsService는 DAO와 연동하여 인증을 할 수 있도록 시큐리티에서 제공하는 인터페이스로, loadUserByUsername(String username) 메소드를 제공한다.

결국 이 메서드의 매개인 'username'으로 유저를 가져오는 쿼리메서드를 호출하여, 유저정보를 UserDetails 타입으로 리턴해주면 끝!
시큐리티는 DB에서 유저정보를 가져와 인증을 하게 된다.

리턴타입인 UserDetails는 스프링 시큐리티에서 유저정보를 담는 인터페이스로, 애플리케이션의 유저정보를 시큐리티가 이해할 수 있는 타입으로 변환한다고 생각하면 된다.

(물론 UserDetails를 커스텀해서 더 많은 유저정보/기능을 담을 수도 있고, 별도 설정을 통해 username 대신 email과 같은 항목으로 인증-키를 바꿀수도 있다.)


인증/인가 기능 확인하기 (+회원등록)

이제 DB_유저정보를 기반으로 인증을 하는 환경이 구축됐다.
인증/인가 테스트를 하려면 회원 데이터가 필요하니, 아래와 같이 간소한 등록기능을 구현해 회원을 등록해본다.

/*
 * MemberController
 */
@GetMapping("/member/{username}/{password}/{role}")
public Member insertMember(@ModelAttribute Member member) {

     log.info(" *** Insert User is {}", member.toString());
     return memberService.insertMember(member);
     
}

----------------------------

/*
 * MemberService
 */
 public Member insertMember(Member member) {
     return memberRepository.save(member);
 }
 
 ----------------------------
 
 /*
  * SecurityConfig
  */
  
  // 회원등록(/member/..) 인증에서 제외
  .antMatchers("/", "/member/**").permitAll()
  

성공적으로 회원데이터가 등록됐다면, 로그인을 통해 인증/인가를 테스트 해본다.
[/user 호출 -> 로그인(인증) -> 유저페이지에 접근되는 지 확인(인가)]


‼️ 만약 아래와 같은 오류가 발생한다면?? (스프링 시큐리티 5↑)

스프링 시큐리티5 부터는 패스워드-인코더가 변경됐기 때문에, 따로 관련 설정을 하거나, 아래와 같이 비밀번호 앞에 인코딩 포맷을 명시해야 한다.
우선 아래와 같이 비밀번호를 넣고 회원가입을 해본다.

{인코더}비밀번호 ==> 예) {noop}u123
('noop'은 암호화 없이 평문을 사용하겠다는 뜻)



위의 계정으로 로그인을 하게되면 'USER'권한을 가지고 있기 때문에 /user페이지에도 접근할 수 있다.
('ADMIN' 계정도 위와 같은 방식으로 등록과 로그인을 해본다.)

이제 DB 회원 풀을 통한 인증/인가 처리가 가능해졌다.
하지만 시큐리티5의 경우 매번 위와 같은 방식으로 패스워드를 가공할 순 없기에, 따로 설정을 해주려 한다.



📖 시큐리티5: 패스워드 인코더

왜 '{알고리즘}패스워드' 포맷이 생겼는가?

이전 버젼까지는 'noop(평문)'이 기본전략이었다가, 시큐리티5부터 'bcrypt'로 바뀌었다.

하지만 기본전략이 바뀌면서 아래의 문제를 해결해야 했다.

  1. 이전 버젼에서 기본전략(평문)을 사용하던 환경에서는 시큐리티5로 버전-업 할 경우 문제가 생김
  2. bcrypt 말고 다른 암호화 방식을 사용하고 싶은 경우는??

이런 문제를 모두 해결할 수 있도록 {알고리즘}패스워드 방식의 포맷을 채용하게 됐다.


인코더 설정을 통해 시큐리티5 기본전략 사용하기

다른 포맷을 사용하게 될 경우 위와 같은 포맷을 사용하면 된다.
하지만 'bcrypt'를 사용하는 경우 포맷작업 없이 자동으로 암호화되도록 설정할 수 있다.


1. 패스워드-인코더 구현 및 빈으로 등록

 public class SecurityConrig extends WebSecurityConfigurerAdapter {

	
    /**
     * 패스워드-인코더 구현 + 빈으로 등록
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
    
   ...

2. 패스워드-인코더를 통해 평문 패스워드 암호화 하기


// 1. Entity or DTO에 패스워드 암호화 메서드 추가
public class Member {

	private String password;
    
	...
    
    /**
     * 패스워드 암호화 메서드
     */
    public void passwordEncode(PasswordEncoder passwordEncoder) {
        this.password = passwordEncoder.encode(this.password);
    }

----------------------------------

// 2. 암호화 메서드를 통해 패스워드 암호화
public class MemberService implements UserDetailsService {
	
    final PasswordEncoder passwordEncoder;

	...
    
    public Member insertMember(Member member) {
        member.passwordEncode(passwordEncoder); // DB작업 전 패스워드 암호화
        
        // 암호화 처리 상태를 확인하기 위한 로그
        log.info("======== Insert User Info {}", member.toString());
                
        return memberRepository.save(member);
    }

설정을 마쳤다면 {알고리즘}비밀번호 포맷이 아닌, 비밀번호만 기입하여 회원을 등록해본다.

인코딩 설정이 잘 됐다면, 따로 포맷을 명시하지 않았어도, 기본전략(bcrypt)으로 암호화 될 것이며, 로그인 시 아까와 같은 에러도 발생하지 않는다.

로그인 시 입력한 패스워드=u123, 실제 DB에 저장된 패스워드={bcrypt}$2a$10$XLxqjEEeoR3ATCt4AqFTzOD30.RBTDmWjK9QVChga2jyOf0BcjFiu



📖 스프링 시큐리티 테스트

테스트 코드를 작성하여 인증/인가를 테스트 하는 방법

security-test 의존성만 추가하면 시큐리티의 기능들을 테스트를 해볼 수 있다.
org.springframework.security:spring-security-test
(scope=test)

테스트 케이스 1 - 인증/인가 테스트

 /**
  * 메인 페이지 (인증없이 접근 가능)
  */
  @GetMapping("/")
  public String index() {return "/index";}

 /**
  * 관리자 페이지 (인증필요, ADMIN 권한만 접근 가능)
  */
  @GetMapping("/admin")
  public String admin() {return "/admin";}

 /**
  * 회원 페이지 (인증필요, USER 이상의 권한 접근 가능)
  */
  @GetMapping("/user")
  public String user() {return "/user";}

위에서 해당 페이지들을 통해 시큐리티의 인증/인가 기능을 확인해봤다.
여태까진 서비스를 구동해서 결과를 확인해왔지만, 이번에는 테스트코드를 작성하여 인증/인가를 테스트 해본다.

테스트 방법은 일반적인 JUnit 테스트와 동일하다.
가령 MockMvc를 통해 특정 페이지의 요청을 테스트 한다고 하면, 여기에 인증된(혹은 미인증된) 임시계정을 만들어주면 된다.

Mock 계정 어노테이션
@WithAnonymousUser : 미인증 유저
@WithMockUser(username = "testUser", roles = "USER") : 인증된 유저

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class FormControllerTest {

    @Autowired
    MockMvc mockMvc;


    /**
     * index 페이지에 미인증 유저도 접근이 가능해야된다.
     */
    @Test
    @WithAnonymousUser
    public void index_anonymous() throws Exception {
        mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andDo(print());
    }

    /**
     * admin 페이지에 ADMIN 권한이 아니면 열람 불가하다.
     */
    @Test
    @WithMockUser(username = "user1", roles = "USER")
    public void admin_another_role() throws Exception {
        mockMvc.perform(get("/admin"))
                .andExpect(status().isForbidden())
                .andDo(print());
    }

    /**
     * admin 페이지에 ADMIN 권한은 접근할 수 있다.
     */
    @Test
    @WithMockUser(username = "admin1", roles = "ADMIN")
    public void admin_success() throws Exception {
        mockMvc.perform(get("/admin"))
                .andExpect(status().isOk())
                .andDo(print());
    }
    
    ...

테스트 케이스 2 - 폼_로그인 테스트

시큐리티 테스트는 MockUser 뿐만 아니라, 폼-로그인 테스트도 지원한다.
MockUser는 가짜인증계정을 통해 인증 이후의 상황을 테스트 하기 위한 기능이라면, 폼-로그인 테스트는 폼-로그인 인증 그 자체를 검증하기 위한 기능이다.

mockMvc.perform(formLogin().user(username).password(password)) .andExpect(authenticated());

/**
 * 폼-로그인 테스트
 */
 @Test
 @Transactional
 public void form_login() throws Exception {
        String username = "user";
        String password = "u123";
        String role = "USER";

        // 로그인 테스트 전 회원 등록
        Member member = memberService.insertMember(Member.builder()
                                                        .username(username)
                                                        .password(password)
                                                        .role(role)
                                                    .build());

	// 해당 유저를 폼-로그인을 통해 로그인 했을 때 잘 되는지?
  	mockMvc.perform(formLogin().user(username).password(password))
    		.andExpect(authenticated());

}



📖 앞으로 정리해야 할 목록

  1. 스프링시큐리티 동작원리와 구성요소에 대한 정리
    • 스프링시큐리티 아키텍쳐 & 스프링 시큐리티가 제공하는 필터들
    • 필터를 구현(혹은 커스텀)하는 방법
  2. 토큰기반(JWT) 인증방식 적용하기
    • 시큐리티는 인증정보를 HTTPSession에 저장한다.
    • 이를 토큰저장 방식으로 변경하려고 한다.
  3. OAuth2.0 인증 적용하기
    • 소셜 로그인 연동하기
profile
안녕하세요~

0개의 댓글