조급하면 모래성이 될뿐

SpringSecurity 활용하여 계정 권한에 따른 접근을 제어해보기 본문

구현 기록/SpringSecurity

SpringSecurity 활용하여 계정 권한에 따른 접근을 제어해보기

Pawer0223 2020. 2. 10. 16:58

목표

로그인 계정이 가지고 있지 않은 URL에 정말 접속이 안되는지 확인해보자!

 

이전 글에서, SpringSecurity를 활용하기 위한 설정을 마치고 DB에 접근하여 로그인까지 확인해 보았다.

 

이제는 TEST계정이 가진 권한을 조회에서, 우리가 설정한 config에 해당하는 URL에만 접속이 가능한지 확인해보자.

* TEST계정은 ADMIN, MEMBER의 권한만 주었다.

	.antMatchers("/admin").hasRole("ADMIN") // 괄호의 권한을 가진 유저만 접근가능, ROLE_가 붙어서 적용 됨. 즉, 테이블에 ROLE_권한명 으로 저장해야 함.
	.antMatchers("/user").hasRole("USER")
	.antMatchers("/member").hasRole("MEMBER")

 

 

먼저, 목표를 해결하기 위해서 무엇이 필요한지를 다시 한번 고민해보자....

 

단순하게, 내 DB에 접근해서 계정 정보가 맞는지 검사하고, 이 계정이 가진 권한에만 접근이 가능하도록 하면 된다.

 

그런데 여기서 SpringSecurity는 정보를 가지고 오는 시점과 방법을 정해놨다.

 

그걸 따라야 한다.

 

그럼 그 방법이 무엇인가??

 

먼저, 이전 글의 로그인 기능을 수행하기 위해 SpringSecurity를 사용한 것을 다시 한번 되새겨보면..

 

그냥 loadUserByUsername 메서드에서 계정 정보를 조회한 후 UserDetails객체를 return만 해주었다.

 

의문이 들어야 할 것은.. 입력 pw 하고, db의 pw하고 동일한지 확인하는  코드를 우리는 따로 작성하지 않았다..

 

그럼에도 불구하고 

 

1. 올바른 pw는 접속이 된다.

 

2. 틀린 pw는 접속이 안된다.

 

3. 암호화 됐는지 안됬는지도 확인을 해서 알아서 처리해준다.

 

위 3가지 기능이 정상적으로 수행한다. ( 더 있겠지만.. 내가 느낀 점만 썼다.. )

 

정리하면 SpringSecurity는 알아서 로그인을 하면서 보안 관련 처리를 해주고 있고.. 그 과정에서 계정 정보는 loadUserByUsername메서드를 호출하면서 가지고 오고 있다.

 

이전 글에서 우리가 한 것은 저 메서드를 @Override 한 후에 내 DB에서 가져와!라고 정확한 위치를 알려준 것뿐이다.

 

다음으로 우리가 제어할 것은 " 내가 입력한 ID가 가진 권한(ROLE) 정보를 가져오도록 알려주면 된다. " 

 

그리고 SpringSecurity는 권한을 인증하기 위해 필요한 객체가 있다. 우리는 그걸 만들어주면 된다.

 

그 인증용 객체는 바로 UsernamePasswordAuthenticationToken이다.

 

한 가지 기억할 것은 SpringSecurity가 하나의 공장이면, 이 인증용 객체를 관리해주는 관리자는 AuthenticationManager 인터페이스이다.( 대표적 구현체는 ProviderManager클래스. )

 

* 이쯤에서 아래 그림을 한번 확인해보자.

* SpringSecurity의 인증 관련 아키텍처인데지금까지 우리가 구현한 interface들의 구현체를 만들어서 필요한 인증정보들을 전달 전달해서 최종 SecurityContextHolder에 담아준다. ( 이전 글을 실습했다면 익숙한 interface가 있을 것이다. )

* 금방 언급한 인증용 객체 UsernamePasswordAuthenticationToken을 받아오는 순서는 2번이고, 2번을 처리하기 위해 3~9의 과정을 거친다. ( 맞나..? ㅡ..ㅡ , 나는 그렇게 이해함.. )

 

* 전체적인 flow를 눈에 익혀두자..

 

지금까지 설명은 나도 '아~ 이런 건가?' 싶은 정도로 이해하고 작성하였고, 정확한 정보는 더 공부가 필요할 것 같다.

참조한 블로그 링크를 남긴다.. 참조한다면 이해를하는데 훨씬 더 도움이 될것이다.  ( 링크 1 ) , (링크2)

 

 

 

 

이제 AuthenticationManager인터페이스의 구현체에게 UsernamePasswordAuthenticationToken을 가져다줘보자..

 

방법은 AuthenticationProvider인터페이스를 구현한 클래스를 하나 만든다.

 

그러면 Override 되는 메서드가 2개가 있다.

 

1. authenticate

- 요게 핵심이다!

- 이 메서드에서 사용자 정보를 가지고 와서 Authentication구현체로 return 해주면 된다.

- 그 구현체가 UsernamePasswordAuthenticationToken이 되겠다.

- 이 객체를 return 하면 관리자(AuthenticationManager구현체)가 가져다 쓰겠지..!!

 

2. supports

- return true 해야 된다..

- false 하면 에러 난다.. ( default로 override 하면 false던데.. )

 

그럼 이제 진짜로 접속 계정이 가진 권한 정보를 가져와보자.

 

위의 authenticate에서 사용자 정보를 가지고와야 한다고 했다..

 

그렇다면 이 메서드가 호출될 때, loadUserByUsername메서드를 써서 계정 정보를 담은 vo클래스( UserDetail의 구현체)를전달받아야 한다.( 내 코드에서는 Account클래스가 되겠다. )

 

그런데 상식적으로 생각하면 권한을 가져오기 전에 계정이 있는지부터 봐야 된다! 계정이 없는데 권한만 있을 수는 없다..

 

그래서 loadUserByUsername에서 return 되는 Account에 권한 정보도 같이 담아주면 참 편하겠다..

 

그리고, 사실 Account가 구현한 UserDetail인터페이스에는 권한 정보를 가져오기 위한 메서드가 존재했었고, 우리는 그 메서드의 반환 값에 권한 정보가 잘 담길 수 있도록 재정의 해서 같이 담아주면 되겠다..

 

Accout에서 우리가 Override 했던 메서드이다.

		@Override
		public Collection<? extends GrantedAuthority> getAuthorities() {
			return this.authorities;
		}

- 이 코드에 return값 this.authorities에 권한 정보를 담아주는 set메서드만 추가해준다면

 

- 로그인 시, Account객체 하나에서 계정 및 권한정보를 모두 조회할 수 있을 것이다.

 

- 정리하면.. loadUserByUsername메서드 호출될 때, 권한 정보도 같이 조회해서 Account에 담아주자!

 

*loadUserByUsername 수정

public class AccountService implements UserDetailsService{

	@Autowired
	AccountRepository accounts;

	Logger log = LoggerFactory.getLogger(this.getClass());

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

		log.info("## loadUserByUsername ##");

		Account account = accounts.findById(username);

		if( account == null ) {
			throw new UsernameNotFoundException(username);
		}
		
		account.setAuthorities(getAuthorities(username));

		return account;
		
	}

	public Collection<GrantedAuthority> getAuthorities(String username) { 
		
		List<String> string_authorities = accounts.findauthoritiesbyid(username);
		
		if( string_authorities == null ) {
			log.info("## 해당 계정에 부여된 권한이 없습니다. ##");
			throw new UsernameNotFoundException(username);
		}
		
		List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(); 
		
		for (String authority : string_authorities) { 
			authorities.add(new SimpleGrantedAuthority(authority)); 
		} 
		
		return authorities; 

	}
}

- account.setAuthorities( getAuthorities(username) ); 을 추가해 주었다.

- 입력받은 id로 권한정보를 조회해서 account객체에 List<GrantedAuthoritiy> 타입으로 set 하는 작업이다.

 

- getAuthorities( String username ) 메서드는 아래에 직접 정의해 주었다.

- 기능은 findauthoritiesbyid메서드를 호출해서, DB에서 계정이 가진 권한 정보를 조회한다.

- 조회정보를 List<String>으로 받은 후 , SpringSecurity가 처리할 수 있는 자료형(GrantedAuthoritiy)으로 변환해준다.

 

*DAO 쪽 코드

	public List<String>findauthoritiesbyid(String username){
		return (List<String>)accountMapper.readAuthorites(username);
	}

* Mapper

List<String> readAuthorites(String id);
	<select id="readAuthorites"  parameterType="String" resultType="String">
		SELECT AUTHORITY_NAME 
		FROM AUTHORITY WHERE USERNAME=#{userName}
	</select>

- 여기서 주의 할 점은 readAuthorities의 resultType이다.

- id에 지정된 권한을 1개이상의 값을 담기위해서 List타입으로 처리를 해야하는데, 처음에 resultType을 list로 지정하였더니 에러가 났다.

- 구글링을 통해 list를 String으로 변경하였고, 이 경우 2개이상의 경우도 이상없이 데이터를 담을 수 있다.

 

마지막으로 authenticate메서드에서 계정 정보를 가져와서, Authentication구현체로 return 해주는 코드를 추가해주자.

 

AuthenticationProvider인터페이스를 구현한 class를 신규 추가하였다. [ 전체 소스코드 ]

* 여기서 신규 추가한 클래스는 @Component 어노테이션을 사용하였다. ( @Component와 @Bean의 차이점 [ 링크 ] )

 

* authenticate메서드

	@Autowired
	private PasswordEncoder passwordEncoder;

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {

		log.info("### authenticate ### ");

		String username = (String) authentication.getPrincipal();
		String password = (String) authentication.getCredentials();
		
		Account account = (Account) accountService.loadUserByUsername(username);
		
		// pw같은지 검증.
		if ( !passwordEncoder.matches(password,account.getPassword())) {
			throw new BadCredentialsException(username);
		}else if(!account.isEnabled()) {
			throw new DisabledException(username);
		}else if(!account.isAccountNonExpired()) {
			throw new AccountExpiredException(username);
		}else if(!account.isAccountNonLocked()) {
			throw new LockedException(username);
		}else if(!account.isCredentialsNonExpired()) {
			throw new CredentialsExpiredException(username);
		}

		return new UsernamePasswordAuthenticationToken(account, account, account.getAuthorities());
	}

 

- 입력받은 id로 loadUserByUsername메서드를 호출하여 계정 정보를 받아온다.

( return 받은 Account객체에는 로그인 시도한 계정의 정보와 권한 정보가 모두 담겨 있다. )

 

- 다음은, 입력받은 pw와 db의 pw가 동일한지 체크한다..! 이거 중요.. 

- 처음에 pw비교 로직을 빼먹었더니 아무 비밀번호나 입력해도 다 로그인이 되었다 ㅡ..ㅡ..

- 그 이유는 SpringSecurity에서 pw가 일치하는지 여부를 authenticate메서드에서 수행되고 있는데, 이걸 override 하면서 그 기능이 망가져버린 것이다.... ㅜㅜ ( 참조 )

- 그리고 암호화를 적용한 상태라면, 반드시 사용하는 PasswordEncoder의 matches함수로 비교해야 한다...

( 그냥 문자열 비교. equals를 사용했다가.. 함참 헤맸다..ㅠㅠㅠ )

- 그리고 matches함수의 인자 값은 [ 1. 암호화되지 않은 pw ( 입력 값 ), 2. 암호화된 pw ( DB 값 ) ]이 되겠다.

 

- 다음은 UsernamePasswordAuthenticationToken객체를 리턴해 주면된다.

- 리턴시 생성자를 활용하는데, 생성자의 인자 값으로 로그인을 시도한 계정의 인스턴스와, 권한정보를 전달해주고 있다.

* UsernamePasswordAuthenticationToken을 return시 파라미터 설정오류로 발생한 Invalid property 'principal.username' of bean class

 

* 2020-02-19 추가

이 메서드 오버라이드하면서 또 망가진 기능을 찾았다..

계정의 isEnabled값이 true, false에 상관없이 모두 정상로그인이 수행되었다..

그래서 UserDetails인터페이스의 기본 Override메서드 중 boolean으로 비교하는 메서드들을 모두 추가로 재정의 해주었다..

예외는 API문서에서 직접찾아서 올바른 에러로 던져주도록 하였다. ( 위 소스코드 내용 참조 )

 

이제 모든 설정을 끝냈다..

 

이제 TEST계정으로 로그인해서 가진 권한 페이지에만 접속이 가능한지 확인해보자!

 

로그인이 정상적으로 성공했다!

 

그리고 권한이 있는 페이지에만 접근이 되는 것도 직접 확인하였다..!

 

/admin

/member

/user

드디어 로그인하고, 계정권한에 따라서 접속 가능한 페이지를 제한하는 것도 직접 확인해보았다..!

 

며칠 걸려서 이 정도 진도를 나갔다....

 

SpringSecurity가 복잡해 보이기는 하지만, 그래도 생각보다 따라가는데 부담이 덜 한 느낌을 받았다..

 

정리

 

Spring Security를 사용해서 계정권한에 따른 접속 페이지의 제한을 어떻게 구현할까??

 

- SpringSecurity는 기본적으로 모든 URI가 수행될 때 [ 인증, 인가 ]에 대한 검증을 하고 있다. 즉, 모든 URI요청에 인증을 해야 한다. ( 로그인 )

* 그렇기 때문에, 기본적인 "/" 나 "/resources/**"의 요청들에 대해서도 지정해주어야 하고, 당연히 모든 권한을 가질 수 있도록 지정해주어야 한다.

 

- 특정 권한에 접속 가능한 URL을 설정할 수 있다. 이때 권한에 대한 명명규칙은 "ROLE_*"과 같이 맞춰주어야 한다. 

 

- 그래서 계정권한 데이터가 DB에 저장되어있다면 "ROLE_ADMIN"과 같이 형식을 맞추어 저장을 해두어야 한다.

 

- 특정 계정이 로그인을 시도하면 Security는 계정 정보와, 권한 정보를 조회한다.

 

- 그리고, 해당 계정이 접속을 요청하는 URL이 어떤 권한이 있는지 확인한 후, 계정이 가지고 있는 권한이면 허락해주고, 그렇지 않다면 접속을 못하게 해 준다.

EX) 설정에. antMatchers("/member"). hasRole("MEMBER")와 같이 되어있다면 /member 를 호출할 때 로그인 계정은 ROLE_MEMBER라는 권한을 가진 데이터가 존재해야 한다.

반응형