Spring Security 연동 (3) (JPA 처리, Entity, Repository, Test)
Spring Security 연동 (3) (JPA 처리, Entity, Repository, Test)
Spring Security 연동 (2) (PasswordEncoder, CSRF, logout) Spring Security 연동 (2) (PasswordEncoder, CSRF, logout) Spring Security 연동 (1) (프로젝트 생성, 설정, 개념 및 이론) Spring Security 연동 (1) (프로젝트 생성, 설정, 개
soohykeee.tistory.com
Security를 위한 UserDetailsService
[개념] SpringSecurity - UserDetails, UserDetailsService
[개념] SpringSecurity - UserDetails, UserDetailsService
UserDetails 란? Spring Security에서 사용자의 정보를 담는 인터페이스이다. Spring Security에서 사용자의 정보를 불러오기 위해서 구현해야 하는 인터페이스로, 오버라이드 메소드들은 아래와 같다. 메소
soohykeee.tistory.com
앞서 모든 과정을 완료했다면, 이번에는 Spring Security가 ClubMemberRepository를 이용해서 회원을 처리하는 부분을 만들어줘야한다. Security를 사용하지 않는 경우에 로그인을 구현할 때는, 회원 ID와 PW로 DB를 조회하고, 올바른 데이터가 존재한다면 Session, Cookie로 처리하는 형태였다. Spring Security는 다음과 조금 다르다.
- Spring Security에서는 회원이나 계정에 대해서 User라는 용어를 사용한다. User라는 단어를 사용할 때는 주의가 필요하다.
- 회원 아이디라는 용어 대신, username이라는 단어를 사용한다. Spring Security에서는 username이라는 단어 자체가 회원을 구별할 수 있는 식별 데이터를 의미한다. 문자열로 처리하는 점은 같지만 일반적으로 사용하는 회원의 이름을 의미하는 것이 아니라, id에 해당하는 것이다.
- username과 password를 동시에 사용하지 않는다. Spring Security는 UserDetailsService를 이용해서 회원의 존재만을 우선적으로 가져오고, 이후에 password가 틀리면 'Bad Cridential (잘못된 자격증명)'이라는 결과를 만들어낸다. (인증)
- 사용자의 username과 password로 인증 과정이 끝나면 원하는 URL에 접근할 수 있는 적절한 권한이 있는지 확인하고 인가 과정을 실행한다. 해당 과정에서 권한이 없다면 'Access Denied'와 같은 결과가 만들어진다.
UserDetails 인터페이스
loadUserByUsername()은 username이라는 회원 아이디와 같은 식별 값으로 회원 정보를 가져온다. 해당 메서드의 리턴타입은 UserDetails이고, 해당 리턴타입을 통해 아래의 정보들을 알아낼 수 있다.
- getAuthorities() - 사용자가 가지는 권한에 대한 정보
- getPassword() - 인증을 마무리하기 위한 패스워드 정보
- getUsername() - 인증에 필요한 아이디와 같은 정보
- 계정 만료 여부 - 더이상 사용이 불가능한 계정인지 알 수 있는 정보
- 계정 잠김 여부 - 현재 계정의 잠김 여부
위의 기능들을 처리하기 위해서 ClubMember를 처리할 수 있는 방법은 크게 2가지가 존재한다.
- 기존 DTO 클래스에 UserDetails 인터페이스를 구현하는 방법
- DTO와 같은 개념으로 별도의 클래스를 구성하고 이를 활용하는 방법
해당 프로젝트에서는 위의 2가지 방법 중 두번째 방법인 별도의 클래스를 구성하고 이를 DTO처럼 사용하는 방식이다. org.springframework.security.core.userdetails.User라는 클래스를 사용하여 구현할 것이다.
이제 코드 구현을 위해 security 디렉토리를 생성하고, 그 하위에 dto, service 디렉토리와, 그 하위에 ClubAuthMemberDTO, ClubUserDetailsService를 생성해준다.
ClubAuthMemberDTO는 User 클래스를 상속하고 해당 생성자를 만들어줘야한다.
ClubAuthMemberDTO는 DTO 역할을 수행하는 클래스인 동시에, Spring Security에서 인가, 인증 작업에 사용할 수 있다.
package com.example.club.security.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
@Log4j2
@Getter
@Setter
@ToString
public class ClubAuthMemberDTO extends User {
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;
}
}
UserDetailsService 구현
ClubMember가 ClubAuthMemberDTO라는 타입으로 처리된 가장 큰 이유는 사용자의 정보를 가져오는 핵심적인 역할을 하는 UserDetailsService 인터페이스 때문이다. Spring Security 구조에서 인증을 담당하는 AuthenticationManager는 내부적으로 UserDetailsService를 호출해서 사용자의 정보를 가져온다. 현재 예제와 같이 JPA로 사용자의 정보를 가져오고 싶다면 이 부분을 UserDetailsService가 이용하는 구조로 작성할 필요가 있다.
package com.example.club.security.service;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Log4j2
@Service
public class ClubUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("ClubUserDetailsService loadUserByUsername : " + username);
return null;
}
}
해당 ClubUserDetailsService에는 @Service를 사용해서 자동으로 스프링에서 빈으로 처리될 수 있게 되어 있고, loadUserByUsername()에서는 별도의 처리없이 로그를 기록하고 있다.
package com.example.club.config;
import lombok.extern.log4j.Log4j2;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@Log4j2
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/sample/all").permitAll()
.antMatchers("/sample/member").hasRole("USER")
.antMatchers("/sample/admin").hasRole("ADMIN");
http.formLogin();
/*http.csrf().disable();
http.logout();*/
}
}
ClubUserDetailsService가 빈으로 등록되면 이를 자동으로 Spring Security에서 UserDetailsService로 인식한다. 이렇게 작성 후 프로젝트 실행 후 'sample/member'로 이동하려고 시도하면 로그인창이 나오게 된다. 그전에 우리가 ClubMember DB에 넣어두었던 더미데이터의 아이디와 비밀번호 중 하나를 입력하고 로그인 시도를 해보면, ClubMemberRepository나 ClubAuthMemberDTO를 이용하는 처리를 하지 않았기에 오류가 발생하지만, 콘솔창을 보면 ClubUserDetailsService가 동작하는 것을 확인할 수 있다.
ClubRepository 연동
정상적인 처리를 위해서 ClubUserDetailsService와 ClubMemberRepository를 연동해줘야한다.
package com.example.club.security.service;
import com.example.club.entity.ClubMember;
import com.example.club.repository.ClubMemberRepository;
import com.example.club.security.dto.ClubAuthMemberDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.stream.Collectors;
@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);
// email이 존재하지 않을 때 exception
if (result.isEmpty()) {
throw new UsernameNotFoundException("Check Email or Social");
}
ClubMember clubMember = result.get();
log.info("-------------------------");
log.info(clubMember);
ClubAuthMemberDTO clubAuthMemberDTO = new ClubAuthMemberDTO(
clubMember.getEmail(),
clubMember.getPassword(),
clubMember.isFromSocial(),
clubMember.getRoleSet().stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).collect(Collectors.toSet())
);
clubAuthMemberDTO.setName(clubMember.getName());
clubAuthMemberDTO.setFromSocial(clubMember.isFromSocial());
return clubAuthMemberDTO;
}
}
- ClubMemberRepository를 주입받을 수 있는 구조로 변경하고 @RequiredArgsConstructor 처리
- username이 실제로는 ClubMember에서는 email을 의미하므로, 이를 이용해서 ClubMemberRepository의 findByEmail()을 호출
- 사용자가 존재하지 않으면 UsernameNotFoundException 처리
- ClubMember를 UserDetails 타입으로 처리하기 위해서 ClubAuthMemberDTO 타입으로 변환
- ClubMemberRole은 Spring Security에서 사용하는 SimpleGrantedAuthority로 변환, 이때 'ROLE_' 이라는 접두어 추가
위 처럼 코드를 수정해 준 후, 다시 '/sample/member'로 접속하여 ClubMember DB에 존재하는 email과 password로 로그인 시, 로그인이 성공적으로 되고 정상적으로 접근이 되는것을 확인할 수 있다. 잘못된 정보로 로그인을 시도할 시에는 로그인이 실패하는 것도 확인할 수 있다.
Thymeleaf를 이용한 사용자 정보 출력
위의 로그인 기능이 정상적으로 출력이 되었다면, 로그인한 사용자의 정보를 화면이나 Controller에서 출력을 해볼것이다. Spring Security를 이용하는 경우에는 Authentication 타입을 이용해서 사용자의 정보를 추출할 수 있다.
우선 Thymeleaf에서 Spring Security를 이용하기 위해서는 아래의 라이브러리를 build.gradle에 추가해주어야 한다.
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
member.html 파일을 아래와 같이수정해준다.
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<h1>For Member........</h1>
<div sec:authorize="hasRole('USER')">Has ROLE = USER</div>
<div sec:authorize="hasRole('MANAGER')">Has ROLE = MANAGER</div>
<div sec:authorize="hasRole('ADMIN')">Has ROLE = ADMIN</div>
<div sec:authorize="isAuthenticated()">
Only Authenticated user can see this text
</div>
Authenticated username:
<div sec:authentication="name"></div>
Authenticated user roles:
<div sec:authentication="principal.authorities"></div>
</html>
로그인 한 유저의 정보가 Authentication에 접근하여 표시되는것을 확인할 수 있다.
Controller에서의 사용자 정보 출력
Controller에서 로그인에 성공한 사용자의 정보를 가져오는 방법은 크게 2가지가 존재한다.
- SecurityContextHolder 객체를 이용하는 방법
- 직접 파라미터와 어노테이션을 이용하는 바법
해당 예제에서는 @AuthenticationPrincipal 어노테이션을 사용해서 처리한다. SampleController를 수정해줄것이다.
@GetMapping("/member")
public void exMember(@AuthenticationPrincipal ClubAuthMemberDTO clubAuthMemberDTO) {
log.info("exMember...............");
log.info("-------------------------------");
log.info(clubAuthMemberDTO);
}
위의 코드에서 @AuthenticationPrincipal은 별도의 캐스팅 작업 없이 직접 실제 ClubAuthMemberDTO 타입을 사용할 수 있다. 위의 코드를 작성 후 '/sample/member'에 접근하여 로그인하면 콘솔창에 성공적으로 접근하여 log가 찍히는 것을 볼 수 있다.