대표적인 인증방식은 username과 password를 html form을 통해 전달받는 방식입니다.
Filter에 대한 이전 포스팅에서 공식문서에 기재된 SecurityFilter의 종류를 보여드렸습니다.
그 filter들 중에 "UsernamePasswordAuthenticationFilter"라는 filter가 username과 password를 이용한 인증을 시도할 때 사용이 됩니다.
위의 flowchart가 인증 절차인데, 복잡해 보이지만 큼지막한 흐름만 얘기해보겠습니다.
1. Flowchart의 주황색 부분에 여러 SecurityFilterChain 중에 UsernamePasswordAuthenticationFilter가 동작하는 것을 볼 수 있습니다. 이제보니 filter 이름도 직관적으로 'UsernamePassword로 인증하는 필터'라고 되어 있네요. 까먹을 일은 없겠습니다.
2. 이 필터가 UsernamePasswordAuthenticationToken 짧게 말해 Token을 생성합니다.
3. 그리고 생성된 Token을 가지고 AuthenticationManager가 인증 절차를 수행합니다.
4. 인증에 성공한 경우는 flowchart의 4번과 같은 절차를, 실패한 경우는 3번과 같은 절차를 거칩니다. 한 가지 알아둘 사항은 실패와 성공에 상관 없이 모두 SecurityContextHolder와 RememberMeService를 거친다는 점입니다.
- 실패한 경우는 SecurityContextHolder가 지워지게 됩니다. 그리고 RememberMeServices.loginFail가 호출됩니다.
- 성공한 경우는 Authentication(인증정보)이 SecurityContextHolder에 저장됩니다. 그리고 RememberMeServices.loginSuccess가 호출됩니다.
SecurityContextHolder
인증이 성공하는 경우에 인증정보(Authentication)를 저장해둔다는 SecurityContextHolder는 spring security의 핵심적인 역할을 합니다. 이 안에 Authentication이라고 해서 인증된 사용자의 정보를 저장해 두게 됩니다.
(뭔가 생긴게 주민등록증 같은 id 카드 같지 않나요? ㅎㅎ)
Authentication 내부에는 세 가지 요소가 있는데,
- Principal: 사용자 식별하는 역할. 대개 UserDetails 인스턴스입니다.
- Credentials: 사용자 비밀번호. 유출방지를 위해 인증이 완료되면 지워집니다.
- Authorities: AuthenticationManger에 의해 부여된 권한 정보입니다.
다시 한 번 인증의 흐름을 정리하면 아래와 같습니다.
1. username & password 인증을 시도하면 UsernamePasswordAuthenticationFilter가 Token을 만든다. (UsernamePasswordAuthenticationToken)
2. AuthenticationManager가 Token을 가지고 인증절차를 수행한다.
3. 인증에 성공한 경우는 SecurityContextHolder에 Authentication을 저장한다.
(실패한 경우는 SecurityContextHolder를 비운다.)
아래는 AuthenticationManager가 Token을 가지고 어떻게 인증절차를 수행하는지까지의 흐름이 나온 flowchart여서 가져와보았습니다.
AuthenticationManager는 인터페이스인데 보통 ProviderManager가 구현체로서 작업을 수행합니다. ProviderManager는 AuthenticationProviders라는 리스트에 인증 작업을 위임합니다. 리스트이기 때문에 AuthenticationProviders 안에는 여러 종류의 맡은 업무가 각자 다른 AuthenticationProvider가 있습니다. 각각은 username/password가 유효한 것인지 확인하기도 하고, SAML assertion 작업을 수행하는 역할을 가지고 있기도 하는 등 담당하는 역할이 다릅니다.
이 AuthenticationProviders에서 UserDetailsService를 통해 UserDetails를 생성하고 생성된 UserDetails는 ProviderManager에게 전달되게 됩니다.
코드 구현해보기
이렇게 흐름을 쭉 알아보았는데, 코드로는 어떻게 구현할 수 있는지 살펴보도록 하겠습니다.
생성해주어야 하는 java 파일이 크게 두가지인데, ProviderManager가 인증처리를 위임하여 생성할 UserDetails와 UserDetails를 생성하기 위한 UserDetailsService를 만들어야 합니다.
[UserDetails를 구현한 PrincipalDetails 파일 생성]
🏷 UserDetails 좀 더 알아보기
UserDetails는 유저의 핵심 정보를 제공하는 역할을 하고 나중에 Authentication 객체 내부에 포함됩니다. 보안과 연관이 없는 유저 정보(이메일, 주소, 전화번호 등)을 편리한 장소에 저장할 수 있도록 도와줍니다.
@Data
public class PrincipalDetails implements UserDetails {
private Member member;
public PrincipalDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return member.getRole();
}
});
return null;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
엄청 많은 메서드를 작성해야 하나 싶지만 UserDetails를 구현한 class를 만들면 필수로 override하게 되는 메서드들입니다.
메서드들 중에 꼭 리턴값 설정을 해주어야 하는 메서드는 가장 위에있는 getAuthorities(), getUsername(), getPassword() 입니다.
[UserDetailsService를 구현한 PrincipalDetailsService 파일 생성]
🏷 UserDetailsService 좀 더 알아보기
UserDetailsService는 유저의 정보를 저장소에서 불러오는 역할을 합니다. 읽기 전용인 method(loadUserByUsername) 하나만을 필요로 합니다.
@Service
public class PrincipalDetailsService implements UserDetailsService {
//(1)
@Autowired
private MemberRepository memberRepository;
//(2)
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//(3)
Member memberEntity = memberRepository.findByUsername(username);
//(4)
if(memberEntity != null) {
return new PrincipalDetails(memberEntity);
}
return null;
}
}
UserDetails 보다는 훨씬 @Override하는 메서드 수가 적네요 ㅎㅎ loadUserByUsername이라는 메서드인데, 이름에서 알 수 있다시피 username으로 Repository에 저장된 유저 정보를 불러오는 메서드 입니다. (2)
- (1) Repository에 접근해야 하기 때문에 MemberRepository를 DI 받아줍니다. (@Autowired)
- (4) Repostiory에서 정보를 찾은 경우, UserDetails를 return하기 위해 먼저 생성해준 PrincipalDetails 인스턴스를 생성해서 return 해줍니다.
- 만약 Repository에서 정보를 찾지 못한 경우는 return null 해줍니다.
(3)에서 보면 .findByUsername() 메서드가 있는데, 기본적으로 제공되는 메서드가 아니므로 MemberRepository에 가서 생성해줍니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
public Member findByUsername(String username);
}
이렇게 하면 인증(로그인)을 위한 기본적인 작업은 완료됩니다. :)
인증 시 Spring Security 내부에서 어떻게 작동하는 지와 코드 구현방법을 간단하게 알아보았습니다.
새로운 용어들이 많이 등장해서 처음에는 생소함에 갈피를 못잡았었는데, 찬찬히 문서와 코드를 살펴보니 대략적으로 이해가 되는 것 같습니다. 혹시 저와 같은 분들께서 어느 정도 도움을 받을 수 있다면 좋겠습니다!
궁금하신 내용이나 수정이 필요한 사항이 있다면 댓글로 꼭 알려주세요
감사합니다.
참고자료
'Java Spring > Security' 카테고리의 다른 글
JWT란? JWT 생성과 사용 방법 알아보기 (0) | 2022.08.05 |
---|---|
Authorization(인가)-인가 내부 절차 & 권한 설정하기 (0) | 2022.08.01 |
Authentication(인증) (1) - 사용자 정보 저장하기 & 비밀번호 암호화하여 저장하기(DelegatingPasswordEncoder는 무엇인가?) (1) | 2022.07.29 |
Spring Security의 작동 방식 알아보기 (0) | 2022.07.28 |
Spring Security Framework은 어떤 도구인지 대표적인 기능 알아보기 (0) | 2022.07.25 |