요즘은 처음에 시작했던 시리즈는 만들지 않고 짧은 코딩들이나 팁을 계속 올리고 있는데 이것은 시리즈를 만들기 위해 잠깐 숨고르기를 하고 있는 중이기 때문이라고 믿어주길 바란다는 서문을 미리 깔고....
요즘 지난번 포스팅에서 언급했듯 프로젝트를 진행하면서 Spring boot로 Restful APIs 서버를 개발하고 있는 중인데
이 Restful APIs의 가장 치명적인 단점은 보안모델을 만들기가 매우 까다롭다는 것이다. 아무렇게나 대충 만들면 스니핑 툴을 이용한 Javascript 변조를 이용해 해킹이 가능할 수도 있고 쿠키를 이용하면 그건 그것대로 문제가 된다. 툴키디급 해커들의 좋은 놀이터를 만들어주게 된다는 거다. 그래서 조금(?) 공부를 한 친구들은 OAuth2나 Spring Security를 이용한 인증처리를 하면 되는 것 아니냐 하면 그건 반만 생각한 대답일거다.
인증이라는 것은 크게 두가지로 생각해볼 수 있는데 상술(上述)한 로그인 인증과 다른 하나는 API에 대한 접근 "권한" 처리다.
- 로그인 인증
- 권한처리
이 두가지가 보안처리의 핵심적인 기능이다.
로그인이야 너무 당연하고 보안관련해서 가장먼저 배우는 것이니 이부분은 나중에 OAuth2를 포스팅하면서 같이 이야기하도록 하고 오늘은 두번째 "권한처리"에 대한 이야기를 먼저 해보자.
API를 만들다보면 어떤 조건에 부합하는 사용자만 접근을 할 수 있도록 제한을 둬야만 할 때가 있다. 예를들어, 관리자만 접근할 수 있는 API, 본인만 접근 할 수 있는 API 등등 뭔가 까다롭고 복잡한 조건을 걸어서 접근을 제한하고 싶은 API가 있다면 그걸 어떻게 처리할 것이냐가 오늘 포스팅의 주제다.
일단 Spring Security는 ROLE 기반의 권한처리가 기본이다. 아래의 코드 처럼.
@Secured("ROLE_ADMIN")
@RequestMapping( value = "/my/api/address", method = RequestMethod.GET )
public String somthingMethod( HttpServletRequest request, HttpServletResponse response ){
return "Hello, World!";
}
@Secured 어노테이션을 이용하면 사용자의 권한정보에 따라 자동으로 해당 메서드의 접근을 제한할 수 있게 된다. 생각외로 매우 간편한 방식이다. 그런데 이렇게 Spring Security를 셋팅하고 사용하면서 나는 생각했다.
요 메서드는 "사용자 본인"과 관리자만 접근할 수 있는 메서드로 지정하고 싶구나
이러한 요구사항은 웹사이트를 개발할 때 빈번하게 발생하는 요구사항이다. 가장 많이 발생하는 부분이 바로 개인정보영역이다.
SM(Server(or Service) Management) 업무를 하는 개발자의 경우 가장 많이 받는 연락이 "제가 암호를 까먹어서"라는 연락이다. 뭐, 바로 암호를 알려주면 좋겠지만 일반적으로 암호같은 것은 해시(Hash)화 해서 저장해 놓기때문에 관리자라 해도 암호를 알려줄 수 없는 경우가 많다. 그래서 대신 암호를 초기화 하거나 초기화 할 수 있도록 메일을 보내주는데 이때 관리자는 개인정보 영역을 접근할 권한을 들고 있어야하는 경우가 생긴다.
(물론 관리자 대부분은 (법적으로 책임을 지는)보안서약을 하고 업무를 하고 있기때문에 개인정보를 유출 시킬 수 없다.)
위와 같은 경우 개인정보에 접근이 가능한 사용자는 "사용자 본인"을 포함한 "관리자 권한"을 들고 있는 사용자여야 한다.
그럼 위에서 이야기한 @Secured 어노테이션의 권한설정을 관리자로 지정하더라도 문제는 "본인"만 접근이 가능해야한다는 조건엔 부합되지 않는 다는 문제가 생긴다.
그래서 일반 사용자용, 관리자용 함수를 두개 만들어서 처리하는 방법도 있을 수 있지만 아름다운(?) 코딩을 지향하는 우리가 같은 역할을 하는 함수를 권한문제 때문에 두개나 만드는건 좋은 방법이 아니라는 것을 본능처럼 알고 있을 것이라고 믿어 의심치 않는다. 게다가 코드 상에서 본인임을 인증하기위한 로직을 짜기 위해서 SecurityContextHolder.getContext().getAuthentication().getPrincipal() 에 접근해서 현재 사용자의 정보를 들고와 확인하는 코드를 만들어내는건 효율하고는 상관이 없어지게 된다. 매번 클라이언트의 데이터 변조를 통한 해킹을 방지하려고 저 코드를 사용하기엔 너무 비효율적이다.
물론 결과만을 놓고 보면 충분히 생각해낼 수 있는 방법이라고 생각한다. 나도 예전에 Security를 처음 접했을 땐 과정은 모르겠고 일단 만들자 해서 문제들을 해결(?) 했었다.
(우리는 흔한 말로 문제를 시멘트로 발라버린다고 표현했었는데..)
하지만 좋은 방법이 아니다. 코드는 아름다워야한다. 간결하고 단순해야하며 나 이외의 다른 사람이 봤을 때 부끄러운 코드를 작성해서는 안된다.
그래서 아래의 해결방법을 찾았다.
일단, 우리는 위에서 이야기했던 요구사항에 대응하기 위해서 몇가지 표현식들을 알아둘 필요가 있다.
(https://docs.spring.io/spring-security/site/docs/3.0.x/reference/el-access.html#el-common-built-in)
- hasRole([role]) : 현재 사용자의 권한이 파라미터의 권한과 동일한 경우 true
- hasAnyRole([role1,role2]) : 현재 사용자의 권한디 파라미터의 권한 중 일치하는 것이 있는 경우 true
- principal : 사용자를 증명하는 주요객체(User)를 직접 접근할 수 있다.
- authentication : SecurityContext에 있는 authentication 객체에 접근 할 수 있다.
- permitAll : 모든 접근 허용
- denyAll : 모든 접근 비허용
- isAnonymous() : 현재 사용자가 익명(비로그인)인 상태인 경우 true
- isRememberMe() : 현재 사용자가 RememberMe 사용자라면 true
- isAuthenticated() : 현재 사용자가 익명이 아니라면 (로그인 상태라면) true
- isFullyAuthenticated() : 현재 사용자가 익명이거나 RememberMe 사용자가 아니라면 true
이 표현식들을 어떻게 사용하느냐하면 아래와같이 사용할 수 있다.
@PostAuthorize("isAuthenticated() and (( returnObject.name == principal.name ) or hasRole('ROLE_ADMIN'))")
@RequestMapping( value = "/{seq}", method = RequestMethod.GET )
public User getuser( @PathVariable("seq") long seq ){
return userService.findOne(seq);
}
@PostAuthorize 어노테이션은 함수를 실행하고 클라이언트한테 응답을 하기 직전에 권한을 검사하는 어노테이션이다. 표현식에는 포함되지 않았지만 returnObject란 이 함수가 반환하는 데이터 오브젝트를 의미한다.
그래서 위의 @PostAuthorize 어노테이션의 의미를 우리말로 번역하면 이렇게 되는 거다.
잠깐, 클라이언트한테 응답하기 전에 검문이 있겠습니다. 로그인상태 입니까?
그리고 반환되는 사용자의 이름과 현재 사용자의 이름이 일치합니까? 또는 현재 사용자가 관리자 권한을 들고있습니까?
이 조건을 만족하는 사용자의 경우에만 응답할 수 있습니다. 아니라면 403 에러로 응답해드립니다.
이렇게 이야기 할 수 있다. 그리고 @PostAuthorize와는 다르게 @PreAuthorize 라는 어노테이션이 있는데 이건 요청이 들어와 함수를 실행하기 전에 권한을 검사하는 어노테이션이다. 아래와 같이 사용할 수 있다.
@PreAuthorize("isAuthenticated() and (( #user.name == principal.name ) or hasRole('ROLE_ADMIN'))")
@RequestMapping( value = "", method = RequestMethod.PUT)
public ResponseEntity<Message> updateUser( User user ){
userService.updateUser( user );
return new ResponseEntity<Message>( new Message(), HttpStatus.OK );
}
@PreAuthorize에도 @PostAuthorize의 returnObject 처럼 "파라미터"에 접근할 수 있는 접두문자가 있는데 그게 바로 "#"이다. 그래서 표현식에서 #user 라고 하면 파라미터로 전달되는 user 객체에 접근할 수 있다. 의미는 위에서 이야기 한 것과 비슷한 의미다. 다른 점이 있다면 함수를 실행하기 전이냐 아니면 사용자에게 응답하기 전이냐의 시점차이가 있을 뿐이다.
@Secured 어노테이션 외에 @PreAuthorize와 @PostAuthorize 어노테이션을 이용하면 조금 더 복잡하고 어려운 권한 설정을 손쉽게 해결할 수 있다.
오늘은 여기까지!