먼저, 예제를 위해 엔티티 클래스를 생성한다.
ClubMember.class
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class ClubMember extends BaseEntity{
@Id
private String email;
private String password;
private String name;
private boolean fromSocial;
@ElementCollection(fetch = FetchType.LAZY)
@Builder.Default
private Set<ClubMemberRole> roleSet = new HashSet<>(); // ClubMember타입으로 HashSet 생성
// HashSet은 데이터를 중복 저장할 수 없고 순서를 보장하지 않음
public void addMemberRole(ClubMemberRole clubMemberRole){
roleSet.add(clubMemberRole); //Set의 add메서드를 이용하여 객체 추가
}
}
ClubMemberRole.class
public enum ClubMemberRole {
USER , MANAGER, ADMIN
}
위와 같이 클래스를 설정하고 테스트 코드로 100명의 회원을 만든다.
@SpringBootTest
public class ClubMemberTests {
@Autowired
private ClubMemberRepository repository;
@Autowired
private PasswordEncoder passwordEncoder;
@Test
public void insertDummies(){
//1 - 80까지는 USER만 지정
//81 - 90까지는 USER,MANAGER
//91 - 100까지는 USER,MANAGER,ADMIN
IntStream.rangeClosed(1,100).forEach(i -> {
ClubMember clubMember = ClubMember.builder()
.email("user"+i+"@zerock.org")
.name("사용자"+i)
.fromSocial(false)
.password( passwordEncoder.encode("1111"))
.build();
//default role
clubMember.addMemberRole(ClubMemberRole.USER);
if(i > 80){
clubMember.addMemberRole(ClubMemberRole.MANAGER);
}
if(i > 90){
clubMember.addMemberRole(ClubMemberRole.ADMIN);
}
repository.save(clubMember);
});
}
주목해야할 점은 80명은 'USER' 권한을, 20명은 'USER/MANAGER' 권한을, 10명은 'USER/MANAGER/ADMIN' 권한을 가지도록 설계한다.
DB는 위와 같이 기록된다.
[스프링 시큐리티의 특징]
1. 스프링 시큐리티에서는 회원이나 계정에 대해서 User라는 용어를 사용한다.
2. 회원 아이디라는 용어 대신에 username이라는 단어를 사용한다.
(스프링 시큐리티에서는 username이라는 단어 자체가 회원을 구별할 수 있는 식별 데이터를 의미하고, 문자열로 처리하는 점은 같지만 일반적으로 사용하는 회원의 이름이 아닌 id에 해당한다)
3. username과 password를 동시에 사용하지 않는다.
스프링 시큐리티는 UserDetailsService를 이용해서 회원의 존재만을 우선적으로 가져오고, 이후에 password가 틀리면 '잘못된 자격증명' 결과를 만들어 낸다.(인증)
4. username과 password로 인증 과정이 끝나면 원하는 자원(URL)에 접근할 수 있는 적절한 권한이 있는지 확인하고 인가 과정을 실행한다.
이 과정에서는 'Access Denied'와 같은 결과가 만들어 진다.
위의 모든 내용을 처리하는 가장 핵심적인 부분이 UserDetailsService이다.
UserDetailsSerivce 는 위와 같이 loadUserByUsername() 이라는 단 하나의 메서드를 가진다.
[ loadUserByUsername() ]
말 그대로 username이라는 회원 아이디와 같은 식별 값으로 회원 정보를 가져온다.
리턴 타입은 UserDetails 타입이고, 이를 통해서 다음과 같은 정보를 알아낼 수 있도록 구성되어 있다.
getAuthorities() : 사용자가 가지는 권한에 대한 정보
getPassword() : 인증을 마무리하기 위한 패스워드 정보
getUsername() : 인증에 필요한 아이디와 같은 정보
계정 만료 여부 : 더이상 사용이 불가능한 계정인지 알 수 있는 정보
계정 잠김 여부 : 현재 계정의 잠김 여부
위의 내용을 처리하기 위해 ClubMember를 처리할 수 있는 방법은 두 가지 방식이 있다.
1. 기존 DTO 클래스에 UserDetails 인터페이스를 구현하는 방법
2. DTO와 같은 개념으로 별도의 클래스를 구성하고 이를 활용하는 방법
진행할 예제에서는 2번을 활용한다. 인터페이스를 구현한 별도의 클래스가 있기 때문에 이를 사용하는 것이 더 수월하기 때문이다.
여러 개의 구현 클래스중
org.springframework.security.core.userdetails.User
위의 클래스를 사용한다.
User 클래스를 살펴보면 별도의 처리 없이 생성자에 몇 가지 정보를 전달하는 것으로 충분하다.
User클래스를 상속 받을 ClubAuthMemberDTO 클래스를 만들어 준다.
먼저, User클래스를 상속하고 부모 클래스인 User 클래스의 생성자를 호출할 수 있는 코드를 만들어준다.
public class ClubAuthMemberDTO extends User{
public ClubAuthMemberDTO(String username, String password, Collection<? extends GrantedAuthority> authorities){
super(username, password, authorities);
}
}
이제 DTO와 유사하게 코드를 구성한다.
@Log4j2
@Getter
@Setter
@ToString
public class ClubAuthMemberDTO extends User{ // DTO 역할을 수행하는 클래스인 동시에 인가/인증 작업에 사용할 수 있음
private String email;
private String name;
private boolean fromSocial;
public ClubAuthMemberDTO(
String username,
String password,
boolean fromSocial,
Collection<?extends GrantedAuthority> authorities){
super(username, password, authorities);
this.email = username;
this.fromSocial = fromSocial;
}
}
password는 부모 클래스를 사용하므로 별도의 멤버 변수로 선언하지 않았다.
이제 본격적으로 UserDetailsService를 구현한다.
스프링 시큐리티의 구조에서 인증을 담당하는 AuthenticationManager는 내부적으로 UserDetailsService를 호출해서 사용자의 정보를 가져오기 때문에, 현재 예제와 같이 JPA로 사용자의 정보를 가져오고 싶다면 이 부분을 UserDetailsService가 이용하는 구조로 작성해야 한다. 따라서 예제로 진행할 ClubUserDetailsService 클래스를 추가한다.
@Log4j2
@Service // 자동으로 스프링에서 빈으로 처리될 수 있게 함
public class ClubUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
log.info("ClubUserDetailsService loadUserByUsername " + username);
return null;
}
}
위의 ClubUserDetailsService 클래스에서 @Service로 빈(Bean)으로 등록되면 이를 자동으로 스프링 시큐리티에서 UserDetailsService로 인식하게 된다.
이제 직접 브라우저를 통해 로그인 해본다.
ClubMemberRepository나 DTO를 이용하는 처리가 이루어지지 않은 상태이기에 위와 같이 에러가 발생하기는 하나
서버에서는 정상적으로 ClubUserDetailsService가 동작하는 것을 확인할 수 있다.
이제 ClubMemberRepository 연동을 통하여 UserDetailsService 클래스를 수정한다.
@Log4j2
@Service // 자동으로 스프링에서 빈으로 처리될 수 있게 함
@RequiredArgsConstructor
public class ClubUserDetailsService implements UserDetailsService {
private final ClubMemberRepository clubMemberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
log.info("ClubUserDetailsService loadUserByUsername " + username);
Optional<ClubMember> result = clubMemberRepository.findByEmail(username, false);
if(result.isEmpty()){
throw new UsernameNotFoundException("Check Email or Social ");
}
ClubMember clubMember = result.get();
log.info("--------------------");
log.info(clubMember);
ClubAuthMemberDTO clubAuthMember = new ClubAuthMemberDTO(
clubMember.getEmail(),
clubMember.getPassword(),
clubMember.isFromSocial(),
clubMember.getRoleSet().stream()
.map(role -> new SimpleGrantedAuthority
("ROLE_"+role.name())).collect(Collectors.toSet())
);
clubAuthMember.setName(clubMember.getName());
clubAuthMember.setFromSocial(clubMember.isFromSocial());
return clubAuthMember;
}
}
주요 코드의 변경사항은 아래와 같다.
1.ClubMemberRepository를 주입받고 @RequiredArgsConstructor 처리를 해준다.
2.username이 실제로는 ClubMember의 email을 의미하므로 이를 사용해서 ClubMemberRepository의 findByEmail()을 호출해준다.
3.사용자가 존재하지 않으면 UsernameNotFoundException으로 처리한다.
4.ClubMember를 UserDetails 타입으로 처리하기 위해서 ClubAuthMemberDTO 타입으로 변환한다.
5.ClubMemberRole은 스프링 시큐리티에서 사용하는 SimpleGrantedAuthority로 변환해주고 이때 'ROLE_'라는접두어를추가해서 사용한다.
이제 프로젝트를 실행해서 웹 브라우저에서 잘못된 계정을 이용하게 되면
위와 같이 실행되고, 정상적인 계정을 이용하게 되면
정상적으로 member.html을 불러오게 된다.
컨트롤러에서 로그인된 사용자 정보를 확인하는 방법은 크게 2가지로 SecurityContextHolder 객체를 사용하는 방법과 직접 파라미터와 어노테이션을 사용하는 방식이 있다.
여기서는 @AuthenticationPrincipal 어노테이션을 사용해서 처리한다.
@GetMapping("/member")// 로그인한 사용자만 접근
public void exMember(@AuthenticationPrincipal ClubAuthMemberDTO clubAuthMember){
log.info("exMember...................");
log.info("-----------------------------------------");
log.info(clubAuthMember);
}
@AuthenticationPrincipal은 별도의 캐스팅 작업없이 실제 ClubAuthMemberDTO 타입을 사용할 수 있기 때문에 편하게 사용할 수 있다.
'코드로 배우는 스프링부트 웹 프로젝트' 카테고리의 다른 글
Springboot) 웹브라우저 <> 서버 <> 스프링 컨테이너 기본 (0) | 2022.05.06 |
---|---|
Springboot) 스프링 시큐리티 소셜 로그인 처리 (0) | 2022.02.07 |
Springboot) Springsecurity : AuthenticationManager , AuthenticationProvider , UserDetailsService (0) | 2022.01.21 |
Springboot) RedirectAttributes, addFlashAttribute() (0) | 2022.01.17 |
Springboot) @PostMapping (0) | 2022.01.11 |